Repository: rhayes777/PyAutoFit (python)

Issue:
Aggregator for csv building
You should first read this issue the `AggregatorPng`, as the general design and idea is the same:

https://github.com/rhayes777/PyAutoFit/issues/1086

**Use Case:**

Similar to .png splicing, it was common for me to want to view the numerical results of lens modeling in a single
.csv file, rather than navigating the `output` folder to find the information I needed.

**Implementation:**

Here is an example of the .csv file for the 2 example images on the `agg_png_csv` URL above:

https://github.com/Jammy2211/autolens_workspace_test/blob/main/agg_png_csv/result.csv

This .csv file contains headers which come from different parts of the `dataset` and `output` folders, thus navigating
the folders to find the information requires combining information from multiple files.

Here is the example .csv maker python file I was using, which is a bit of a mess, but works:

https://github.com/Jammy2211/autolens_workspace_test/blob/main/agg_png_csv/csv_make.py

**AggregatorCSV**

I am picturing an `AggregatorCSV` class that would take the `output` folder and help build the .csv file.

**Using Output Folder**

For the example on the workspace, all information used to build the .csv file comes from the `info.json` and
`result.json` files in the `dataset` folder.

So you would just need to write an `AggregatorCSV` that navigates the `dataset` folder and loads the `info.json` and
`result.json` files, and produces the .csv file.

My .csv building never used the `output` folder, but this was because I wrote a quite complicated pipeline (in a hurry)
which output all results to the `dataset` folder.

I think the general use case would be that the `output` folder is used to extract all information whenever possible.

For example, the `einstein_radius_max_lh` is stored here:

![image](https://github.com/user-attachments/assets/382e6775-4ddc-401b-ba1b-6db1701513ef)

Where `samples_summary.json` has all the information on the model and instance, with an einstein radius at this part:

```
    "median_pdf_sample": {
            "type": "instance",
            "class_path": "autofit.non_linear.samples.sample.Sample",
            "arguments": {
                "log_likelihood": 21775.911731379132,
                "log_prior": 1.4651345875102018,
                "weight": 6.53867998265804e-05,
                "kwargs": {
                    "type": "dict",
                    "arguments": {
                        "galaxies.lens.bulge.profile_list.59.centre.centre_0": -0.07025111305343998,
                        "galaxies.lens.bulge.profile_list.59.centre.centre_1": -0.02258690706633907,
                        "galaxies.lens.bulge.profile_list.29.ell_comps.ell_comps_0": 0.250115244578848,
                        "galaxies.lens.bulge.profile_list.29.ell_comps.ell_comps_1": -0.18539079820818452,
                        "galaxies.lens.bulge.profile_list.59.ell_comps.ell_comps_0": 0.05644153124215467,
                        "galaxies.lens.bulge.profile_list.59.ell_comps.ell_comps_1": -0.16025101750452186,
                        "galaxies.source.bulge.profile_list.19.centre.centre_0": -0.02911164136961475,
                        "galaxies.source.bulge.profile_list.19.centre.centre_1": 0.16875116292548728,
                        "galaxies.source.bulge.profile_list.19.ell_comps.ell_comps_0": -0.3747737836275342,
                        "galaxies.source.bulge.profile_list.19.ell_comps.ell_comps_1": -0.191211048679011,
                        "galaxies.lens.mass.ell_comps.ell_comps_0": 0.12954201123863499,
                        "galaxies.lens.mass.ell_comps.ell_comps_1": -0.09446519305629113,
                        "galaxies.lens.mass.einstein_radius": 0.8099997493702147,
                        "galaxies.lens.shear.gamma_1": -0.04293296870204821,
                        "galaxies.lens.shear.gamma_2": 0.10475246785931866
                    }
                }
            }
        },
```

Therefore an API which allows the user to use the `model_path` to choose what the .csv headers are would be ideal, something like:

```
agg = AggregatorCSV.from_directory(
    directory=path.join("output),
)

agg.add_column(
    folder=source_lp[1],
    name="einstein_radius_max_lh",
    model_path="galaxies.lens.mass.einstein_radius",
)
```


**Errors**

Note that `samples_summary.json` also stores errors on parameters, so the API above should be extended to specify the
error on the parameter, e.g.:

```
agg.add_column(
    folder=source_lp[1],
    name="einstein_radius_max_lh",
    model_path="galaxies.lens.mass.einstein_radius",
    error="errors_at_sigma_3", # this string is in the `samples_summary.json` file
)
```

**Latent Variable API**

The `AggregatorCSV` should also support the latent variable API, so that the user can use the `model_path` to access the
latent variables of the model.

The example GitHub repo does not have latent variables, but the `AggregatorCSV` should be designed to support them,
as they will just be in a `latent_summary.json` file analogous to the `samples_summary.json` file.

Errors should also be supported for latent variables.

**Manual Function API**

From `samples_summary.json` the `AggregatorCSV` can create an instance of the maximum likelihood or median PDF model.

A user may want to compute a quantity from this `instance` and add it to the .csv file. This value may be something
you could add as a latent variable, but lets pretend the user forgot to add it before running the pipeline or doesnt
want loads of latent variables in the code.

The following API could allows the user to do this:

```
agg = AggregatorCSV.from_directory(
    directory=path.join("output),
)

def einstein_radius_x2_from(instance):
    einstein_radius = instance.galaxies.lens.mass.einstein_radius
    return einstein_radius * 2.0

agg.add_column(
    folder=source_lp[1],
    name="einstein_radius_x2_max_lh",
    latent_func=einstein_radius_x2_from,
    use_max_lh_instance=True, # as opposed to the median PDF instance
)
```


**Manual Function with Samples API**

The example above uses `samples_summary.json` to create an instance of the maximum likelihood or median PDF model.
It does not use the full set of non-linear search samples and therefore cannot provide an error estimate on the
quantity computed.

The samples are fully included in `samples.json` and the `AggregatorCSV` should support the following API to compute
a quantity from the samples and add it to the .csv file, which can then include an error estimate:

```
agg = AggregatorCSV.from_directory(
    directory=path.join("output),
)

def einstein_radius_x2_via_samples_from(samples):

    random_draws = 50

    einstein_radius_x2_list = []
    
    for i in range(random_draws):

        instance = samples.draw_randomly_via_pdf()
    
        ell_comps = instance.galaxies.lens.mass.ell_comps
    
        einstein_radius_x2 = al.convert.einstein_radius_x2_from(ell_comps=ell_comps)
    
        einstein_radius_x2_list.append(einstein_radius_x2)
    
    median_einstein_radius_x2, lower_einstein_radius_x2, upper_einstein_radius_x2 = af.marginalize(
        parameter_list=einstein_radius_x2_list,
        sigma=3.0,
    )

    return median_einstein_radius_x2, lower_einstein_radius_x2, upper_einstein_radius_x2
    
agg.add_x3_columns_with_errors(
    folder=source_lp[1],
    name="einstein_radius_x2",
    samples_func=einstein_radius_x2_via_samples_from,
)
```

**Missing .json Files**

A user can disable the output of `samples.json`, so the `AggregatorCSV` should raise an warning if the user tries to use
the `samples_func` API and the `samples.json` file is not present, and leave blank the column in the .csv file.


Pull request description:
feature/csv error columns
Resolves #1087

New usage:

```python

aggregate_csv.add_variable(
    "galaxies.lens.bulge.centre.centre_0",
    value_types=[ValueType.Median, ValueType.ValueAt1Sigma],
)
```

Median is included by default


Patch:
diff --git a/autofit/aggregator/summary/aggregate_csv.py b/autofit/aggregator/summary/aggregate_csv.py
deleted file mode 100644
index 72c1750cc..000000000
--- a/autofit/aggregator/summary/aggregate_csv.py
+++ /dev/null
@@ -1,305 +0,0 @@
-from abc import ABC, abstractmethod
-from typing import Optional, Callable, List, Union, Tuple
-
-from pathlib import Path
-
-from autofit.aggregator.aggregator import Aggregator
-from autofit.aggregator.search_output import SearchOutput
-
-import csv
-
-
-class AbstractColumn(ABC):
-    def __init__(self, name: str):
-        """
-        A column in the summary table.
-
-        Parameters
-        ----------
-        name
-            An optional name for the column
-        """
-        self.name = name
-
-    @abstractmethod
-    def value(self, row: "Row"):
-        pass
-
-
-class Column(AbstractColumn):
-    def __init__(
-        self,
-        argument: str,
-        name: Optional[str] = None,
-        use_max_log_likelihood: Optional[bool] = False,
-    ):
-        """
-        A column in the summary table.
-
-        Parameters
-        ----------
-        argument
-            The argument as it appears in the median_pdf_sample arguments
-        name
-            An optional name for the column
-        """
-        super().__init__(
-            name
-            or argument.replace(
-                ".",
-                "_",
-            )
-        )
-        self.argument = argument
-        self.use_max_log_likelihood = use_max_log_likelihood
-
-    def value(self, row: "Row"):
-        if self.use_max_log_likelihood:
-            kwargs = row.max_likelihood_kwargs
-        else:
-            kwargs = row.median_pdf_sample_kwargs
-
-        return kwargs[self.path]
-
-    @property
-    def path(self) -> tuple:
-        """
-        The path of an argument in the median_pdf_sample arguments.
-        """
-        return tuple(self.argument.split("."))
-
-
-class ComputedColumn(AbstractColumn):
-    def __init__(self, name: str, compute: Callable):
-        """
-        A column in the summary table that is computed from other columns.
-
-        Parameters
-        ----------
-        name
-            The name of the column
-        compute
-            A function that takes the median_pdf_sample arguments and returns the computed value
-        """
-        super().__init__(name)
-        self.compute = compute
-
-    def value(self, row: "Row"):
-        try:
-            return self.compute(row.result.samples)
-        except AttributeError as e:
-            raise AssertionError(
-                "Cannot compute additional fields if no samples.json present"
-            ) from e
-
-
-class LabelColumn(AbstractColumn):
-    def __init__(self, name: str, values: list):
-        """
-        A column in the summary table that is a label.
-
-        Parameters
-        ----------
-        name
-            The name of the column
-        """
-        super().__init__(name)
-        self.values = values
-
-    def value(self, row: "Row"):
-        return self.values[row.number]
-
-
-class Row:
-    def __init__(
-        self,
-        result: SearchOutput,
-        columns: list,
-        number: int,
-    ):
-        """
-        A row in the summary table corresponding to one search.
-
-        Parameters
-        ----------
-        result
-            The search output from the aggregator
-        columns
-            The columns to include in the summary. These are taken from samples_summary or latent_summary
-        """
-        self.result = result
-        self._columns = columns
-        self.number = number
-
-    def _all_paths(self, path: Tuple[str, ...]):
-        model = self.result.model
-        return model.all_paths_for_prior(model.object_for_path(path))
-
-    def _add_paths(self, kwargs):
-        return {
-            path: value
-            for key, value in kwargs.items()
-            for path in self._all_paths(key)
-        }
-
-    @property
-    def median_pdf_sample_kwargs(self) -> dict:
-        """
-        The median_pdf_sample arguments for the search from the samples_summary and latent_summary.
-        """
-        samples_summary = self.result.value("samples_summary")
-        kwargs = self._add_paths(samples_summary.median_pdf_sample.kwargs)
-
-        latent_summary = self.result.value("latent.latent_summary")
-        if latent_summary is not None:
-            kwargs.update(latent_summary.median_pdf_sample.kwargs)
-
-        return kwargs
-
-    @property
-    def max_likelihood_kwargs(self):
-        """
-        The median_pdf_sample arguments for the search from the samples_summary and latent_summary.
-        """
-        samples_summary = self.result.value("samples_summary")
-        kwargs = self._add_paths(samples_summary.median_pdf_sample.kwargs)
-
-        latent_summary = self.result.value("latent.latent_summary")
-        if latent_summary is not None:
-            kwargs.update(latent_summary.max_log_likelihood_sample.kwargs)
-
-        return kwargs
-
-    def dict(self) -> dict:
-        """
-        The row as a dictionary including an id and one entry for each column.
-        """
-        row = {"id": self.result.id}
-        for column in self._columns:
-            row[column.name] = column.value(self)
-
-        return row
-
-
-class AggregateCSV:
-    def __init__(self, aggregator: Aggregator):
-        """
-        Summarise results from the aggregator as a CSV.
-
-        Parameters
-        ----------
-        aggregator
-        """
-        if len(aggregator) == 0:
-            raise ValueError("The aggregator is empty.")
-
-        self._aggregator = aggregator
-        self._columns = []
-
-    def add_column(
-        self,
-        argument: str,
-        name: Optional[str] = None,
-        use_max_log_likelihood: Optional[bool] = False,
-    ):
-        """
-        Add a column to the summary table.
-
-        This will be taken from the samples_summary or latent_summary.
-
-        Parameters
-        ----------
-        argument
-            The argument as it appears in the median_pdf_sample arguments
-            e.g. "galaxies.lens.bulge.centre.centre_0"
-        name
-            An optional name for the column. If not provided, the argument will be used.
-        use_max_log_likelihood
-            If True, the maximum likelihood value will be used instead of the median PDF value.
-        """
-        self._columns.append(
-            Column(
-                argument,
-                name=name,
-                use_max_log_likelihood=use_max_log_likelihood,
-            )
-        )
-
-    def add_computed_column(
-        self,
-        name: str,
-        compute: Callable,
-    ):
-        """
-        Add a column to the summary table that is computed from other columns.
-
-        Parameters
-        ----------
-        name
-            The name of the column
-        compute
-            A function that takes the median_pdf_sample arguments and returns the computed value
-        """
-        self._columns.append(
-            ComputedColumn(
-                name,
-                compute,
-            )
-        )
-
-    def add_label_column(
-        self,
-        name: str,
-        values: list,
-    ):
-        """
-        Add a column to the summary table that is a label.
-
-        Parameters
-        ----------
-        name
-            The name of the column
-        values
-            The values of the column
-        """
-        self._columns.append(
-            LabelColumn(
-                name,
-                values,
-            )
-        )
-
-    @property
-    def fieldnames(self) -> List[str]:
-        """
-        The fieldnames for the CSV file.
-        """
-        return ["id"] + [column.name for column in self._columns]
-
-    def save(self, path: Union[str, Path]):
-        """
-        Save the summary table to a CSV file.
-
-        Parameters
-        ----------
-        path
-            The path to save the file to
-        """
-
-        folder_path = path.parent if path.suffix else path
-        folder_path.mkdir(parents=True, exist_ok=True)
-
-        with open(path, "w") as f:
-            writer = csv.DictWriter(
-                f,
-                fieldnames=self.fieldnames,
-            )
-            writer.writeheader()
-            for i, result in enumerate(self._aggregator):
-                row = Row(
-                    result,
-                    self._columns,
-                    i,
-                )
-
-                writer.writerow(row.dict())
diff --git a/autofit/aggregator/summary/aggregate_csv/__init__.py b/autofit/aggregator/summary/aggregate_csv/__init__.py
new file mode 100644
index 000000000..3bd8531d4
--- /dev/null
+++ b/autofit/aggregator/summary/aggregate_csv/__init__.py
@@ -0,0 +1,145 @@
+from typing import Optional, Callable, List, Union
+
+from pathlib import Path
+
+from autofit.aggregator.aggregator import Aggregator
+
+import csv
+
+from autofit.aggregator.summary.aggregate_csv.column import (
+    LabelColumn,
+    Column,
+    ComputedColumn,
+    ValueType,
+)
+from autofit.aggregator.summary.aggregate_csv.row import Row
+
+
+class AggregateCSV:
+    def __init__(self, aggregator: Aggregator):
+        """
+        Summarise results from the aggregator as a CSV.
+
+        Parameters
+        ----------
+        aggregator
+        """
+        if len(aggregator) == 0:
+            raise ValueError("The aggregator is empty.")
+
+        self._aggregator = aggregator
+        self._columns = []
+
+    def add_variable(
+        self,
+        argument: str,
+        name: Optional[str] = None,
+        value_types: list = (ValueType.Median,),
+    ):
+        """
+        Add a variable to include columns for in the summary table.
+
+        This will be taken from the samples_summary or latent_summary.
+
+        Parameters
+        ----------
+        argument
+            The argument as it appears in the median_pdf_sample arguments
+            e.g. "galaxies.lens.bulge.centre.centre_0"
+        name
+            An optional name for the column. If not provided, the argument will be used.
+        value_types
+            Value types to include. See ValueType.
+        """
+        self._columns.append(
+            Column(
+                argument,
+                name=name,
+                value_types=value_types,
+            )
+        )
+
+    def add_computed_column(
+        self,
+        name: str,
+        compute: Callable,
+    ):
+        """
+        Add a column to the summary table that is computed from other columns.
+
+        Parameters
+        ----------
+        name
+            The name of the column
+        compute
+            A function that takes the median_pdf_sample arguments and returns the computed value
+        """
+        self._columns.append(
+            ComputedColumn(
+                name,
+                compute,
+            )
+        )
+
+    def add_label_column(
+        self,
+        name: str,
+        values: list,
+    ):
+        """
+        Add a column to the summary table that is a label.
+
+        Parameters
+        ----------
+        name
+            The name of the column
+        values
+            The values of the column
+        """
+        self._columns.append(
+            LabelColumn(
+                name,
+                values,
+            )
+        )
+
+    @property
+    def fieldnames(self) -> List[str]:
+        """
+        The fieldnames for the CSV file.
+        """
+        for i, result in enumerate(self._aggregator):
+            row = Row(
+                result,
+                self._columns,
+                i,
+            )
+            return row.column_names
+
+    def save(self, path: Union[str, Path]):
+        """
+        Save the summary table to a CSV file.
+
+        Parameters
+        ----------
+        path
+            The path to save the file to
+        """
+
+        folder_path = path.parent if path.suffix else path
+        folder_path.mkdir(parents=True, exist_ok=True)
+
+        with open(path, "w") as f:
+            writer = csv.DictWriter(
+                f,
+                fieldnames=self.fieldnames,
+            )
+            writer.writeheader()
+            for i, result in enumerate(self._aggregator):
+                row = Row(
+                    result,
+                    self._columns,
+                    i,
+                )
+
+                writer.writerow(row.dict())
diff --git a/autofit/aggregator/summary/aggregate_csv/column.py b/autofit/aggregator/summary/aggregate_csv/column.py
new file mode 100644
index 000000000..662209e32
--- /dev/null
+++ b/autofit/aggregator/summary/aggregate_csv/column.py
@@ -0,0 +1,130 @@
+from abc import ABC, abstractmethod
+from enum import Enum
+from typing import Optional, Callable
+
+
+class AbstractColumn(ABC):
+    def __init__(self, name: str):
+        """
+        A column in the summary table.
+
+        Parameters
+        ----------
+        name
+            An optional name for the column
+        """
+        self.name = name
+
+    @abstractmethod
+    def value(self, row: "Row"):
+        pass
+
+
+class ValueType(Enum):
+    """
+    Possible value types to create columns for.
+    """
+
+    Median = 0  # The median_pdf_sample value
+    MaxLogLikelihood = 1  # The max likelihood value
+    ValuesAt1Sigma = 2  # The values at 1 sigma. This includes a lower and upper value
+    ValuesAt3Sigma = 3  # The values at 3 sigma. This includes a lower and upper value
+
+
+class Column(AbstractColumn):
+    def __init__(
+        self,
+        argument: str,
+        name: Optional[str] = None,
+        value_types: list[ValueType] = (ValueType.Median,),
+    ):
+        """
+        A column in the summary table.
+
+        Parameters
+        ----------
+        argument
+            The argument as it appears in the median_pdf_sample arguments
+        name
+            An optional name for the column
+        value_types
+            Value types to include. See ValueType.
+        """
+        super().__init__(
+            name
+            or argument.replace(
+                ".",
+                "_",
+            )
+        )
+        self.argument = argument
+        self.value_types = value_types
+
+    def value(self, row: "Row"):
+        result = {}
+
+        if ValueType.Median in self.value_types:
+            result[""] = row.median_pdf_sample_kwargs[self.path]
+
+        if ValueType.MaxLogLikelihood in self.value_types:
+            result["max_lh"] = row.max_likelihood_kwargs
+
+        if ValueType.ValuesAt1Sigma in self.value_types:
+            lower, upper = row.values_at_sigma_1_kwargs[self.path]
+            result["lower_1_sigma"] = lower
+            result["upper_1_sigma"] = upper
+
+        if ValueType.ValuesAt3Sigma in self.value_types:
+            lower, upper = row.values_at_sigma_3_kwargs[self.path]
+            result["lower_3_sigma"] = lower
+            result["upper_3_sigma"] = upper
+
+        return result
+
+    @property
+    def path(self) -> tuple:
+        """
+        The path of an argument in the median_pdf_sample arguments.
+        """
+        return tuple(self.argument.split("."))
+
+
+class ComputedColumn(AbstractColumn):
+    def __init__(self, name: str, compute: Callable):
+        """
+        A column in the summary table that is computed from other columns.
+
+        Parameters
+        ----------
+        name
+            The name of the column
+        compute
+            A function that takes the median_pdf_sample arguments and returns the computed value
+        """
+        super().__init__(name)
+        self.compute = compute
+
+    def value(self, row: "Row"):
+        try:
+            return self.compute(row.result.samples)
+        except AttributeError as e:
+            raise AssertionError(
+                "Cannot compute additional fields if no samples.json present"
+            ) from e
+
+
+class LabelColumn(AbstractColumn):
+    def __init__(self, name: str, values: list):
+        """
+        A column in the summary table that is a label.
+
+        Parameters
+        ----------
+        name
+            The name of the column
+        """
+        super().__init__(name)
+        self.values = values
+
+    def value(self, row: "Row"):
+        return self.values[row.number]
diff --git a/autofit/aggregator/summary/aggregate_csv/row.py b/autofit/aggregator/summary/aggregate_csv/row.py
new file mode 100644
index 000000000..b73a31528
--- /dev/null
+++ b/autofit/aggregator/summary/aggregate_csv/row.py
@@ -0,0 +1,124 @@
+from functools import cached_property
+from typing import Tuple
+from autofit.aggregator.search_output import SearchOutput
+
+
+class Row:
+    def __init__(
+        self,
+        result: SearchOutput,
+        columns: list,
+        number: int,
+    ):
+        """
+        A row in the summary table corresponding to one search.
+
+        Parameters
+        ----------
+        result
+            The search output from the aggregator
+        columns
+            The columns to include in the summary. These are taken from samples_summary or latent_summary
+        """
+        self.result = result
+        self._columns = columns
+        self.number = number
+
+    def _all_paths(self, path: Tuple[str, ...]):
+        model = self.result.model
+        return model.all_paths_for_prior(model.object_for_path(path))
+
+    def _add_paths(self, kwargs):
+        return {
+            path: value
+            for key, value in kwargs.items()
+            for path in self._all_paths(
+                key.split(".") if isinstance(key, str) else key,
+            )
+        }
+
+    @property
+    def median_pdf_sample_kwargs(self) -> dict:
+        """
+        The median_pdf_sample arguments for the search from the samples_summary and latent_summary.
+        """
+        samples_summary = self.result.value("samples_summary")
+        kwargs = self._add_paths(samples_summary.median_pdf_sample.kwargs)
+
+        latent_summary = self.result.value("latent.latent_summary")
+        if latent_summary is not None:
+            kwargs.update(latent_summary.median_pdf_sample.kwargs)
+
+        return kwargs
+
+    @property
+    def max_likelihood_kwargs(self):
+        """
+        The median_pdf_sample arguments for the search from the samples_summary and latent_summary.
+        """
+        samples_summary = self.result.samples_summary
+        kwargs = self._add_paths(samples_summary.median_pdf_sample.kwargs)
+
+        latent_summary = self.result.value("latent.latent_summary")
+        if latent_summary is not None:
+            kwargs.update(latent_summary.max_log_likelihood_sample.kwargs)
+
+        return kwargs
+
+    @cached_property
+    def values_at_sigma_1_kwargs(self) -> dict:
+        """
+        The values_at_sigma_1 arguments for the search from the samples_summary.
+        """
+        kwargs = self._add_paths(self.result.samples_summary.values_at_sigma_1)
+
+        latent_summary = self.result.value("latent.latent_summary")
+        if latent_summary is not None:
+            kwargs.update(
+                {
+                    tuple(key.split(".")): value
+                    for key, value in latent_summary.values_at_sigma_1.items()
+                }
+            )
+
+        return kwargs
+
+    @cached_property
+    def values_at_sigma_3_kwargs(self) -> dict:
+        """
+        The values_at_sigma_3 arguments for the search from the samples_summary.
+        """
+        kwargs = self._add_paths(self.result.samples_summary.values_at_sigma_3)
+
+        latent_summary = self.result.value("latent.latent_summary")
+        if latent_summary is not None:
+            kwargs.update(
+                {
+                    tuple(key.split(".")): value
+                    for key, value in latent_summary.values_at_sigma_1.items()
+                }
+            )
+
+        return kwargs
+
+    def dict(self) -> dict:
+        """
+        The row as a dictionary including an id and one entry for each column.
+        """
+        row = {"id": self.result.id}
+        for column in self._columns:
+            value = column.value(self)
+            if isinstance(value, dict):
+                for key, value in value.items():
+                    row[f"{column.name}_{key}" if key else column.name] = value
+            else:
+                row[column.name] = column.value(self)
+
+        return row
+
+    @property
+    def column_names(self):
+        """
+        The names of the columns.
+        """
+        return list(self.dict().keys())
diff --git a/autofit/non_linear/samples/interface.py b/autofit/non_linear/samples/interface.py
index a3dd55caf..636416814 100644
--- a/autofit/non_linear/samples/interface.py
+++ b/autofit/non_linear/samples/interface.py
@@ -1,6 +1,6 @@
 from abc import ABC, abstractmethod
 from functools import wraps
-from typing import Union, List, Tuple
+from typing import Union, List, Tuple, Dict
 
 from autofit.mapper.model import ModelInstance
 from autofit.mapper.prior_model.abstract import AbstractPriorModel
@@ -23,10 +23,17 @@ def to_instance(func):
 
     @wraps(func)
     def wrapper(
-        self, *args, as_instance: bool = True, **kwargs
-    ) -> Union[List, ModelInstance]:
+        self,
+        *args,
+        as_instance: bool = True,
+        as_dict: bool = False,
+        **kwargs,
+    ) -> Union[List, Dict, ModelInstance]:
         vector = func(self, *args, **kwargs)
 
+        if as_dict:
+            return {".".join(path[0]): value for path, value in zip(self.paths, vector)}
+
         if as_instance:
             return self._instance_from_vector(vector)
 
diff --git a/autofit/non_linear/samples/pdf.py b/autofit/non_linear/samples/pdf.py
index 083cfc836..48f96e463 100644
--- a/autofit/non_linear/samples/pdf.py
+++ b/autofit/non_linear/samples/pdf.py
@@ -1,4 +1,3 @@
-import logging
 import math
 import pathlib
 import warnings
@@ -62,10 +61,22 @@ class SamplesPDF(Samples):
             max_log_likelihood_sample=self.max_log_likelihood_sample,
             median_pdf_sample=median_pdf_sample,
             log_evidence=self.log_evidence,
-            errors_at_sigma_1=self.errors_at_sigma(sigma=1.0, as_instance=False),
-            errors_at_sigma_3=self.errors_at_sigma(sigma=3.0, as_instance=False),
-            values_at_sigma_1=self.values_at_sigma(sigma=1.0, as_instance=False),
-            values_at_sigma_3=self.values_at_sigma(sigma=3.0, as_instance=False),
+            errors_at_sigma_1=self.errors_at_sigma(
+                sigma=1.0,
+                as_dict=True,
+            ),
+            errors_at_sigma_3=self.errors_at_sigma(
+                sigma=3.0,
+                as_dict=True,
+            ),
+            values_at_sigma_1=self.values_at_sigma(
+                sigma=1.0,
+                as_dict=True,
+            ),
+            values_at_sigma_3=self.values_at_sigma(
+                sigma=3.0,
+                as_dict=True,
+            ),
         )
 
     @classmethod
diff --git a/autofit/non_linear/samples/sample.py b/autofit/non_linear/samples/sample.py
index 60051a826..0346fbabe 100644
--- a/autofit/non_linear/samples/sample.py
+++ b/autofit/non_linear/samples/sample.py
@@ -31,7 +31,7 @@ class Sample:
         kwargs
             Dictionary mapping model paths to values for the sample
         """
-        self.log_likelihood = log_likelihood
+        self.log_likelihood = float(log_likelihood)
         self.log_prior = log_prior
         self.weight = weight
         self.kwargs = {


Test patch:
diff --git a/test_autofit/aggregator/summary_files/aggregate_summary/fit_1/files/latent/latent_summary.json b/test_autofit/aggregator/summary_files/aggregate_summary/fit_1/files/latent/latent_summary.json
index dafef9958..4583ec3dd 100644
--- a/test_autofit/aggregator/summary_files/aggregate_summary/fit_1/files/latent/latent_summary.json
+++ b/test_autofit/aggregator/summary_files/aggregate_summary/fit_1/files/latent/latent_summary.json
@@ -31,6 +31,31 @@
           }
         }
       }
+    },
+    "values_at_sigma_3": {
+      "type": "dict",
+      "arguments": {
+        "galaxies.lens.bulge.centre.latent": {
+          "type": "tuple",
+          "values": [
+            1.6742483855526449,
+            5.93749816282317
+          ]
+        }
+      }
+    },
+    "log_evidence": -4.214938026111044,
+    "values_at_sigma_1": {
+      "type": "dict",
+      "arguments": {
+        "galaxies.lens.bulge.centre.latent": {
+          "type": "tuple",
+          "values": [
+            3.4319440071038327,
+            5.134685987907622
+          ]
+        }
+      }
     }
   }
 }
\ No newline at end of file
diff --git a/test_autofit/aggregator/summary_files/aggregate_summary/fit_1/files/samples_summary.json b/test_autofit/aggregator/summary_files/aggregate_summary/fit_1/files/samples_summary.json
index 163ab48bc..072379d23 100644
--- a/test_autofit/aggregator/summary_files/aggregate_summary/fit_1/files/samples_summary.json
+++ b/test_autofit/aggregator/summary_files/aggregate_summary/fit_1/files/samples_summary.json
@@ -33,6 +33,45 @@
           }
         }
       }
+    },
+    "values_at_sigma_3": {
+      "type": "dict",
+      "arguments": {
+        "galaxies.lens.bulge.centre.centre_0": {
+          "type": "tuple",
+          "values": [
+            1.6742483855526449,
+            5.93749816282317
+          ]
+        },
+        "galaxies.lens.bulge.centre.centre_1": {
+          "type": "tuple",
+          "values": [
+            -0.21891974607628695,
+            2.6115900726705954
+          ]
+        }
+      }
+    },
+    "log_evidence": -4.214938026111044,
+    "values_at_sigma_1": {
+      "type": "dict",
+      "arguments": {
+        "galaxies.lens.bulge.centre.centre_0": {
+          "type": "tuple",
+          "values": [
+            3.4319440071038327,
+            5.134685987907622
+          ]
+        },
+        "galaxies.lens.bulge.centre.centre_1": {
+          "type": "tuple",
+          "values": [
+            0.6034814540293423,
+            1.5110342872835507
+          ]
+        }
+      }
     }
   }
 }
\ No newline at end of file
diff --git a/test_autofit/aggregator/summary_files/aggregate_summary/fit_2/files/latent/latent_summary.json b/test_autofit/aggregator/summary_files/aggregate_summary/fit_2/files/latent/latent_summary.json
index b5f85f863..4c30b8304 100644
--- a/test_autofit/aggregator/summary_files/aggregate_summary/fit_2/files/latent/latent_summary.json
+++ b/test_autofit/aggregator/summary_files/aggregate_summary/fit_2/files/latent/latent_summary.json
@@ -31,6 +31,31 @@
           }
         }
       }
+    },
+    "values_at_sigma_3": {
+      "type": "dict",
+      "arguments": {
+        "galaxies.lens.bulge.centre.latent": {
+          "type": "tuple",
+          "values": [
+            1.6742483855526449,
+            5.93749816282317
+          ]
+        }
+      }
+    },
+    "log_evidence": -4.214938026111044,
+    "values_at_sigma_1": {
+      "type": "dict",
+      "arguments": {
+        "galaxies.lens.bulge.centre.latent": {
+          "type": "tuple",
+          "values": [
+            3.4319440071038327,
+            5.134685987907622
+          ]
+        }
+      }
     }
   }
 }
\ No newline at end of file
diff --git a/test_autofit/aggregator/summary_files/aggregate_summary/fit_2/files/samples_summary.json b/test_autofit/aggregator/summary_files/aggregate_summary/fit_2/files/samples_summary.json
index 32b1652ea..c87d60c0c 100644
--- a/test_autofit/aggregator/summary_files/aggregate_summary/fit_2/files/samples_summary.json
+++ b/test_autofit/aggregator/summary_files/aggregate_summary/fit_2/files/samples_summary.json
@@ -33,6 +33,45 @@
           }
         }
       }
+    },
+    "values_at_sigma_3": {
+      "type": "dict",
+      "arguments": {
+        "galaxies.lens.bulge.centre.centre_0": {
+          "type": "tuple",
+          "values": [
+            1.6742483855526449,
+            5.93749816282317
+          ]
+        },
+        "galaxies.lens.bulge.centre.centre_1": {
+          "type": "tuple",
+          "values": [
+            -0.21891974607628695,
+            2.6115900726705954
+          ]
+        }
+      }
+    },
+    "log_evidence": -4.214938026111044,
+    "values_at_sigma_1": {
+      "type": "dict",
+      "arguments": {
+        "galaxies.lens.bulge.centre.centre_0": {
+          "type": "tuple",
+          "values": [
+            3.4319440071038327,
+            5.134685987907622
+          ]
+        },
+        "galaxies.lens.bulge.centre.centre_1": {
+          "type": "tuple",
+          "values": [
+            0.6034814540293423,
+            1.5110342872835507
+          ]
+        }
+      }
     }
   }
 }
\ No newline at end of file
diff --git a/test_autofit/aggregator/summary_files/test_aggregate_csv.py b/test_autofit/aggregator/summary_files/test_aggregate_csv.py
index 0a64170a2..3be6fbffd 100644
--- a/test_autofit/aggregator/summary_files/test_aggregate_csv.py
+++ b/test_autofit/aggregator/summary_files/test_aggregate_csv.py
@@ -2,7 +2,7 @@ import csv
 
 from pathlib import Path
 
-from autofit.aggregator.summary.aggregate_csv import AggregateCSV
+from autofit.aggregator.summary.aggregate_csv import AggregateCSV, ValueType
 
 import pytest
 
@@ -57,7 +57,7 @@ def test_add_column(
     summary,
     load_output,
 ):
-    summary.add_column("galaxies.lens.bulge.centre.centre_0")
+    summary.add_variable("galaxies.lens.bulge.centre.centre_0")
     summary.save(output_path)
 
     dicts = load_output()
@@ -71,16 +71,16 @@ def test_use_max_log_likelihood(
     summary,
     load_output,
 ):
-    summary.add_column(
+    summary.add_variable(
         "galaxies.lens.bulge.centre.centre_0",
-        use_max_log_likelihood=True,
+        value_types=[ValueType.MaxLogLikelihood],
     )
     summary.save(output_path)
 
     dicts = load_output()
 
-    assert dicts[0]["galaxies_lens_bulge_centre_centre_0"] == "-1.0" or "-5.0"
-    assert dicts[1]["galaxies_lens_bulge_centre_centre_0"] == "-5.0" or "-1.0"
+    assert dicts[0]["galaxies_lens_bulge_centre_centre_0_max_lh"] == "-1.0" or "-5.0"
+    assert dicts[1]["galaxies_lens_bulge_centre_centre_0_max_lh"] == "-5.0" or "-1.0"
 
 
 def test_add_named_column(
@@ -88,7 +88,7 @@ def test_add_named_column(
     summary,
     load_output,
 ):
-    summary.add_column(
+    summary.add_variable(
         "galaxies.lens.bulge.centre.centre_0",
         name="centre_0",
     )
@@ -105,7 +105,7 @@ def test_add_latent_column(
     summary,
     load_output,
 ):
-    summary.add_column(
+    summary.add_variable(
         "latent.value",
     )
     summary.save(output_path)
@@ -133,3 +133,90 @@ def test_computed_column(
     dicts = load_output()
 
     assert dicts[0]["computed"] == "1"
+
+
+def test_dict_computed_column(
+    output_path,
+    summary,
+    load_output,
+):
+    def compute(samples):
+        return {"a": 1, "b": 2}
+
+    summary.add_computed_column(
+        "computed",
+        compute,
+    )
+    summary.save(output_path)
+
+    dicts = load_output()
+
+    first = dicts[0]
+    assert first["computed_a"] == "1"
+    assert first["computed_b"] == "2"
+
+
+def test_values_at_1_sigma(
+    output_path,
+    summary,
+    load_output,
+):
+    summary.add_variable(
+        "galaxies.lens.bulge.centre.centre_0",
+        value_types=[ValueType.ValuesAt1Sigma],
+    )
+    summary.save(output_path)
+
+    dicts = load_output()
+
+    first = dicts[0]
+    assert (
+        first["galaxies_lens_bulge_centre_centre_0_lower_1_sigma"]
+        == "3.4319440071038327"
+    )
+    assert (
+        first["galaxies_lens_bulge_centre_centre_0_upper_1_sigma"]
+        == "5.134685987907622"
+    )
+
+
+def test_values_at_3_sigma(
+    output_path,
+    summary,
+    load_output,
+):
+    summary.add_variable(
+        "galaxies.lens.bulge.centre.centre_0",
+        value_types=[ValueType.ValuesAt3Sigma],
+    )
+    summary.save(output_path)
+
+    dicts = load_output()
+
+    first = dicts[0]
+    assert (
+        first["galaxies_lens_bulge_centre_centre_0_lower_3_sigma"]
+        == "1.6742483855526449"
+    )
+    assert (
+        first["galaxies_lens_bulge_centre_centre_0_upper_3_sigma"] == "5.93749816282317"
+    )
+
+
+def test_latent_values_at_1_sigma(
+    output_path,
+    summary,
+    load_output,
+):
+    summary.add_variable(
+        "galaxies.lens.bulge.centre.latent",
+        value_types=[ValueType.ValuesAt1Sigma],
+    )
+    summary.save(output_path)
+
+    dicts = load_output()
+
+    first = dicts[0]
+    assert (
+        first["galaxies_lens_bulge_centre_latent_lower_1_sigma"] == "3.4319440071038327"
+    )
diff --git a/test_autofit/serialise/test_samples.py b/test_autofit/serialise/test_samples.py
index 6f7331d03..d333176ae 100644
--- a/test_autofit/serialise/test_samples.py
+++ b/test_autofit/serialise/test_samples.py
@@ -52,6 +52,22 @@ def make_summary_dict():
         "type": "instance",
         "class_path": "autofit.non_linear.samples.summary.SamplesSummary",
         "arguments": {
+            "values_at_sigma_1": {
+                "type": "dict",
+                "arguments": {
+                    "centre": {"type": "tuple", "values": [0.0, 2.0]},
+                    "normalization": {"type": "tuple", "values": [1.0, 4.0]},
+                    "sigma": {"type": "tuple", "values": [2.0, 6.0]},
+                },
+            },
+            "errors_at_sigma_3": {
+                "type": "dict",
+                "arguments": {
+                    "centre": {"type": "tuple", "values": [2.0, 0.0]},
+                    "normalization": {"type": "tuple", "values": [3.0, 0.0]},
+                    "sigma": {"type": "tuple", "values": [4.0, 0.0]},
+                },
+            },
             "median_pdf_sample": {
                 "type": "instance",
                 "class_path": "autofit.non_linear.samples.sample.Sample",
@@ -69,13 +85,30 @@ def make_summary_dict():
                     },
                 },
             },
-            "values_at_sigma_3": {
-                "type": "list",
-                "values": [
-                    {"type": "tuple", "values": [0.0, 2.0]},
-                    {"type": "tuple", "values": [1.0, 4.0]},
-                    {"type": "tuple", "values": [2.0, 6.0]},
-                ],
+            "max_log_likelihood_sample": {
+                "type": "instance",
+                "class_path": "autofit.non_linear.samples.sample.Sample",
+                "arguments": {
+                    "log_likelihood": 4.0,
+                    "log_prior": 5.0,
+                    "weight": 6.0,
+                    "kwargs": {
+                        "type": "dict",
+                        "arguments": {
+                            "centre": 2.0,
+                            "normalization": 4.0,
+                            "sigma": 6.0,
+                        },
+                    },
+                },
+            },
+            "errors_at_sigma_1": {
+                "type": "dict",
+                "arguments": {
+                    "centre": {"type": "tuple", "values": [2.0, 0.0]},
+                    "normalization": {"type": "tuple", "values": [3.0, 0.0]},
+                    "sigma": {"type": "tuple", "values": [4.0, 0.0]},
+                },
             },
             "model": {
                 "class_path": "autofit.example.model.Gaussian",
@@ -98,48 +131,15 @@ def make_summary_dict():
                     },
                 },
             },
-            "max_log_likelihood_sample": {
-                "type": "instance",
-                "class_path": "autofit.non_linear.samples.sample.Sample",
+            "log_evidence": None,
+            "values_at_sigma_3": {
+                "type": "dict",
                 "arguments": {
-                    "log_likelihood": 4.0,
-                    "log_prior": 5.0,
-                    "weight": 6.0,
-                    "kwargs": {
-                        "type": "dict",
-                        "arguments": {
-                            "centre": 2.0,
-                            "normalization": 4.0,
-                            "sigma": 6.0,
-                        },
-                    },
+                    "centre": {"type": "tuple", "values": [0.0, 2.0]},
+                    "normalization": {"type": "tuple", "values": [1.0, 4.0]},
+                    "sigma": {"type": "tuple", "values": [2.0, 6.0]},
                 },
             },
-            "values_at_sigma_1": {
-                "type": "list",
-                "values": [
-                    {"type": "tuple", "values": [0.0, 2.0]},
-                    {"type": "tuple", "values": [1.0, 4.0]},
-                    {"type": "tuple", "values": [2.0, 6.0]},
-                ],
-            },
-            "errors_at_sigma_1": {
-                "type": "list",
-                "values": [
-                    {"type": "tuple", "values": [2.0, 0.0]},
-                    {"type": "tuple", "values": [3.0, 0.0]},
-                    {"type": "tuple", "values": [4.0, 0.0]},
-                ],
-            },
-            "errors_at_sigma_3": {
-                "type": "list",
-                "values": [
-                    {"type": "tuple", "values": [2.0, 0.0]},
-                    {"type": "tuple", "values": [3.0, 0.0]},
-                    {"type": "tuple", "values": [4.0, 0.0]},
-                ],
-            },
-            "log_evidence": None,
         },
     }