"""The result class for global analysis."""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import field
from dataclasses import replace
from typing import TYPE_CHECKING
from typing import Any
from typing import List
from typing import cast
import numpy as np
import xarray as xr
from numpy.typing import ArrayLike
from tabulate import tabulate
from glotaran.deprecation import deprecate
from glotaran.io import load_result
from glotaran.io import save_result
from glotaran.model import Model
from glotaran.parameter import ParameterGroup
from glotaran.parameter import ParameterHistory
from glotaran.project.dataclass_helpers import exclude_from_dict_field
from glotaran.project.dataclass_helpers import file_loadable_field
from glotaran.project.dataclass_helpers import init_file_loadable_fields
from glotaran.project.scheme import Scheme
from glotaran.utils.io import DatasetMapping
from glotaran.utils.ipython import MarkdownStr
if TYPE_CHECKING:
from typing import Callable
from typing import Mapping
from glotaran.typing import StrOrPath
[docs]@dataclass
class Result:
"""The result of a global analysis."""
number_of_function_evaluations: int
"""The number of function evaluations."""
success: bool
"""Indicates if the optimization was successful."""
termination_reason: str
"""The reason (message when) the optimizer terminated"""
glotaran_version: str
"""The glotaran version used to create the result."""
free_parameter_labels: list[str]
"""List of labels of the free parameters used in optimization."""
scheme: Scheme = file_loadable_field(Scheme) # type:ignore[type-var]
initial_parameters: ParameterGroup = file_loadable_field( # type:ignore[type-var]
ParameterGroup
)
optimized_parameters: ParameterGroup = file_loadable_field( # type:ignore[type-var]
ParameterGroup
)
parameter_history: ParameterHistory = file_loadable_field( # type:ignore[type-var]
ParameterHistory
)
"""The parameter history."""
data: Mapping[str, xr.Dataset] = file_loadable_field( # type:ignore[type-var]
DatasetMapping, is_wrapper_class=True
)
"""The resulting data as a dictionary of :xarraydoc:`Dataset`.
Notes
-----
The actual content of the data depends on the actual model and can be found in the
documentation for the model.
"""
additional_penalty: np.ndarray | None = exclude_from_dict_field(None)
"""A vector with the value for each additional penalty, or None"""
cost: ArrayLike | None = exclude_from_dict_field(None)
"""The final cost."""
# The below can be none in case of unsuccessful optimization
chi_square: float | None = None
r"""The chi-square of the optimization.
:math:`\chi^2 = \sum_i^N [{Residual}_i]^2`."""
covariance_matrix: ArrayLike | None = exclude_from_dict_field(None)
"""Covariance matrix.
The rows and columns are corresponding to :attr:`free_parameter_labels`."""
degrees_of_freedom: int | None = None
"""Degrees of freedom in optimization :math:`N - N_{vars}`."""
jacobian: ArrayLike | list | None = exclude_from_dict_field(None)
"""Modified Jacobian matrix at the solution
See also: :func:`scipy.optimize.least_squares`
"""
number_of_data_points: int | None = None
"""Number of data points :math:`N`."""
number_of_jacobian_evaluations: int | None = None
"""The number of jacobian evaluations."""
number_of_variables: int | None = None
"""Number of variables in optimization :math:`N_{vars}`"""
optimality: float | None = None
reduced_chi_square: float | None = None
r"""The reduced chi-square of the optimization.
:math:`\chi^2_{red}= {\chi^2} / {(N - N_{vars})}`.
"""
root_mean_square_error: float | None = None
r"""
The root mean square error the optimization.
:math:`rms = \sqrt{\chi^2_{red}}`
"""
source_path: StrOrPath = field(
default="result.yml", init=False, repr=False, metadata={"exclude_from_dict": True}
)
loader: Callable[[StrOrPath], Result] = field(
default=load_result, init=False, repr=False, metadata={"exclude_from_dict": True}
)
def __post_init__(self):
"""Validate fields and cast attributes to correct type."""
init_file_loadable_fields(self)
if isinstance(self.jacobian, list):
self.jacobian = np.array(self.jacobian)
self.covariance_matrix = np.array(self.covariance_matrix)
@property
def model(self) -> Model:
"""Return the model used to fit result.
Returns
-------
Model
The model instance.
"""
return self.scheme.model
[docs] def get_scheme(self) -> Scheme:
"""Return a new scheme from the Result object with optimized parameters.
Returns
-------
Scheme
A new scheme with the parameters set to the optimized values.
For the dataset weights the (precomputed) weights from the original scheme are used.
"""
data = {}
for label, dataset in self.data.items():
data[label] = dataset.data.to_dataset(name="data")
if "weight" in dataset:
data[label]["weight"] = dataset.weight
return replace(self.scheme, parameters=self.optimized_parameters)
[docs] def markdown(self, with_model: bool = True, base_heading_level: int = 1) -> MarkdownStr:
"""Format the model as a markdown text.
Parameters
----------
with_model : bool
If `True`, the model will be printed with initial and optimized parameters filled in.
base_heading_level : int
The level of the base heading.
Returns
-------
MarkdownStr : str
The scheme as markdown string.
"""
general_table_rows: list[list[Any]] = [
["Number of residual evaluation", self.number_of_function_evaluations],
["Number of variables", self.number_of_variables],
["Number of datapoints", self.number_of_data_points],
["Degrees of freedom", self.degrees_of_freedom],
["Chi Square", f"{self.chi_square or np.nan:.2e}"],
["Reduced Chi Square", f"{self.reduced_chi_square or np.nan:.2e}"],
["Root Mean Square Error (RMSE)", f"{self.root_mean_square_error or np.nan:.2e}"],
]
if self.additional_penalty is not None:
general_table_rows.append(["RMSE additional penalty", self.additional_penalty])
result_table = tabulate(
general_table_rows,
headers=["Optimization Result", ""],
tablefmt="github",
disable_numparse=True,
)
if len(self.data) > 1:
RMSE_rows = [
[
f"{index}.{label}:",
dataset.weighted_root_mean_square_error,
dataset.root_mean_square_error,
]
for index, (label, dataset) in enumerate(self.data.items(), start=1)
]
RMSE_table = tabulate(
RMSE_rows,
headers=["RMSE (per dataset)", "weighted", "unweighted"],
floatfmt=".2e",
tablefmt="github",
)
result_table = f"{result_table}\n\n{RMSE_table}"
if with_model:
model_md = self.model.markdown(
parameters=self.optimized_parameters,
initial_parameters=self.initial_parameters,
base_heading_level=base_heading_level,
)
result_table = f"{result_table}\n\n{model_md}"
return MarkdownStr(result_table)
def _repr_markdown_(self) -> str:
"""Return a markdown representation str.
Special method used by ``ipython`` to render markdown.
Returns
-------
str
The scheme as markdown string.
"""
return str(self.markdown(base_heading_level=3))
def __str__(self) -> str:
"""Overwrite of ``__str__``."""
return str(self.markdown(with_model=False))
[docs] def save(self, path: str) -> list[str]:
"""Save the result to given folder.
Parameters
----------
path : str
The path to the folder in which to save the result.
Returns
-------
list[str]
Paths to all the saved files.
"""
return cast(
List[str],
save_result(result_path=path, result=self, format_name="folder", allow_overwrite=True),
)
[docs] def recreate(self) -> Result:
"""Recrate a result from the initial parameters.
Returns
-------
Result :
The recreated result.
"""
from glotaran.analysis.optimize import optimize
return optimize(self.scheme)
[docs] def verify(self) -> bool:
"""Verify a result.
Returns
-------
bool :
Weather the recreated result is equal to this result.
"""
recreated = self.recreate()
if self.root_mean_square_error != recreated.root_mean_square_error:
return False
for label, dataset in self.data.items():
for attr, array in dataset.items():
if not np.allclose(array, recreated.data[label][attr]):
return False
return True
[docs] @deprecate(
deprecated_qual_name_usage="glotaran.project.result.Result.get_dataset(dataset_label)",
new_qual_name_usage=("glotaran.project.result.Result.data[dataset_label]"),
to_be_removed_in_version="0.6.0",
importable_indices=(2, 2),
)
def get_dataset(self, dataset_label: str) -> xr.Dataset:
"""Return the result dataset for the given dataset label.
Warning
-------
Deprecated use ``glotaran.project.result.Result.data[dataset_label]``
instead.
Parameters
----------
dataset_label : str
The label of the dataset.
Returns
-------
xr.Dataset :
The dataset.
.. # noqa: DAR401
"""
try:
return self.data[dataset_label]
except KeyError:
raise ValueError(f"Unknown dataset '{dataset_label}'")