import numpy as np
from dataclasses import dataclass
from ForMoSA.core.errors import ForMoSAError
[docs]
@dataclass
class NSResults:
'''
Dataclass to handle reading and storing results of the Nested Sampling algorithm
Notes
-----
Authors: Allan Denis
'''
samples: np.ndarray
weights: np.ndarray
logl: np.ndarray
logvol: np.ndarray
logz: list[float, float]
free_parameters: list[str]
burn_in: int = 0
# ===================
# Post init
# ===================
def __post_init__(self):
self.samples = np.asarray(self.samples, dtype=float)
self.weights = np.asarray(self.weights, dtype=float)
self.logl = np.asarray(self.logl, dtype=float)
self.logvol = np.asarray(self.logvol, dtype=float)
# Checks
if self.samples.ndim != 2:
raise ForMoSAError(f"<samples must be 2D, got shape {self.samples.shape}>")
if not self.weights.shape == self.logl.shape == self.logvol.shape:
raise ForMoSAError("<weights, logl and logvol must have the same shapes>")
if (not isinstance(self.logz, list)) or len(self.logz) != 2:
raise ForMoSAError("<logz must be a list of 2 elements>")
if self.samples.shape[0] != self.weights.shape[0]:
raise ForMoSAError("<samples and weights must have same length>")
if len(self.free_parameters) != self.samples.shape[1]:
raise ForMoSAError(f'<Number of free parameters ({len(self.free_parameters)}) does not correspond to the shape of samples: ({self.samples.shape})>')
if self.burn_in > self.samples.shape[0]:
raise ForMoSAError(f'<burn_in ({self.burn_in}) does not correspond to the shape of samples: ({self.samples.shape})>')
# ===================
# Properties
# ===================
@property
def to_dict(self) -> dict:
"""Dictionary view of the results."""
return {
"samples": self.samples.tolist(),
"weights": self.weights.tolist(),
"logl": self.logl.tolist(),
"logvol": self.logvol.tolist(),
"logz": self.logz,
"burn_in": self.burn_in,
"free_parameters": self.free_parameters
}
@property
def median_parameters(self) -> dict[str, float]:
"""Weighted posterior median for each parameter."""
return {
name: self._weighted_quantile(self.samples[self.burn_in:, i], self.weights[self.burn_in:], 0.5)
for i, name in enumerate(self.free_parameters)
}
@property
def param_samples_dict(self) -> dict[str, float]:
"""Samples of each parameter."""
return {name: self.samples[self.burn_in:, i] for i, name in enumerate(self.free_parameters)}
@property
def best_logL(self) -> float:
"""Averaged value of logL."""
return np.average(self.results.logl[self.burn_in:], weights = self.results.weights[self.burn_in:])
# ===================
# Class methods
# ===================
[docs]
@classmethod
def from_dict(cls, data: dict) -> "NSResults":
'''
Load NSResults from dictionary.
Parameters
----------
data : dict
Dictionary of NSResults
free_parameters : list[str]
List of free parameters names
Returns
-------
NSResults
Instance of class NSResults
Notes
-----
Authors: Allan Denis
'''
return cls(
samples=np.asarray(data["samples"], dtype=float),
weights=np.asarray(data["weights"], dtype=float),
logl=np.asarray(data["logl"], dtype=float),
logvol=np.asarray(data["logvol"], dtype=float),
logz=data["logz"],
burn_in=data["burn_in"],
free_parameters=data["free_parameters"],
)
[docs]
@classmethod
def from_nestle(cls, res: dict, free_parameters: list[str]) -> "NSResults":
'''
Return an instance of NSResults based on the results of a Nestle algorithm.
Parameters
----------
res : dict
Dictionary containing Nested Sampling results
free_parameters : list[str]
List of free parameters names
Returns
-------
NestleResults
An instance of NSResults
Notes
-----
Authors: Allan Denis
'''
return cls(
samples=res['samples'],
weights=res['weights'],
logl=res['logl'],
logvol=res['logvol'],
logz=[res['logz'], res['logzerr']],
free_parameters=free_parameters
)
[docs]
@classmethod
def from_pymultinest(cls, results_path: str, free_parameters: list[str]) -> "NSResults":
'''
Return an instance of NSResults based on the results of a PyMultinest algorithm.
Parameters
----------
results_path : str | os.PathLike
Path to the PyMultiNest output folder containing ``RAW_stats.dat``, ``RAW_ev.dat``, ``RAW_.txt``
free_parameters : list[str]
List of free parameters names
Returns
-------
NSResults
An instance of NSResults
Notes
-----
Authors: Allan Denis
'''
# Read global evidence and error
with open(f"{results_path}/RAW_stats.dat", 'rb') as f:
line = f.readline().strip().split()
logz = [float(line[5]), float(line[7])]
# Read event file (samples, logl, logvol)
sample_multi, logl_multi, logvol_multi = [], [], []
with open(f"{results_path}/RAW_ev.dat", 'rb') as f:
for line in f:
parts = line.strip().split()
sample_multi.append([float(p) for p in parts[:-3]])
logl_multi.append(float(parts[-3]))
logvol_multi.append(float(parts[-2]))
# Read weights and match to samples
samples, weights, logl, logvol = [], [], [], []
with open(f"{results_path}/RAW_.txt", 'rb') as f:
for line in f:
parts = line.strip().split()
point = [float(p) for p in parts[2:]]
if point in sample_multi:
idx = sample_multi.index(point)
samples.append(point)
weights.append(float(parts[0]))
logl.append(logl_multi[idx])
logvol.append(logvol_multi[idx])
# Convert to numpy arrays
samples = np.asarray(samples)
weights = np.asarray(weights)
logl = np.asarray(logl)
logvol = np.asarray(logvol)
return cls(
samples=samples,
weights=weights,
logl=logl,
logvol=logvol,
logz=logz,
free_parameters=free_parameters
)
[docs]
@classmethod
def from_ultranest(cls, res: dict, free_parameters: list[str]) -> "NSResults":
"""
Construct an instance of UltraNestResults from the UltraNest output folder.
Parameters
----------
res : dict
Dictionary containing Nested Sampling results
free_parameters : list[str]
List of free parameters names
Returns
-------
NSResults
An instance of NSResults
Notes
-----
Authors: Allan Denis
"""
ws = res['weighted_samples']
samples, weights, loglike, logz, logz_err = ws['points'], ws['weights'], ws['logl'], res['logz'], res['logzerr']
logvol = np.log(weights) - loglike + logz
return cls(
samples=samples,
weights=weights,
logl=loglike,
logvol=logvol,
logz=[logz, logz_err],
free_parameters=free_parameters
)
# ===================
# Methods
# ===================
def _weighted_quantile(self, values: np.ndarray, weights: np.ndarray, q: float) -> np.ndarray:
'''
Compute weighted quantile for 1D array.
Parameters
----------
values : np.ndarray
Values we want to compute the quantile for
weights : np.ndarray
Weights
q : float
Quantile
Returns
-------
np.ndarray
Weighted quantile
'''
values, weights = np.asarray(values, dtype=float), np.asarray(weights, dtype=float)
# Initial checks
if len(values) != len(weights):
raise ForMoSAError('<values and weights must have same length>')
if (not isinstance(q, float)) or (q < 0 or q > 1):
raise ForMoSAError('Expected a float between 0 and 1 for quantile>')
sorter = np.argsort(values)
values = values[sorter]
weights = weights[sorter]
cumsum = np.cumsum(weights)
cumsum /= cumsum[-1]
return np.interp(q, cumsum, values)
def _quantile_parameters(self, q: float) -> dict[str, float]:
'''
Weighted posterior quantile for each free parameter.
Parameters
----------
q : float
Quantile in [0, 1]
Returns
-------
dict[str, float]
Dictionary of parameter name associated to its quantile
Notes
-----
Authors: Allan Denis
'''
return {
name: self._weighted_quantile(self.samples[:, i], self.weights, q)
for i, name in enumerate(self.free_parameters)
}
def _interval(self, sigma: int = 1) -> dict[str, tuple[float, float]]:
'''
Weighted interval for each parameter.
sigma = 1 → [16%, 84%]
sigma = 2 → [2%, 98%]
Parameters
----------
sigma : int
Sigma value
Returns
-------
dict[str, tuple[float, float]]: Dictionary of parameter name associated to its interval
Notes
-----
Authors: Allan Denis
'''
if sigma == 1:
q_low, q_high = 0.16, 0.84
elif sigma == 2:
q_low, q_high = 0.02, 0.98
else:
raise ForMoSAError("sigma must be 1 or 2")
return {
name: (self._weighted_quantile(self.samples[:, i], self.weights, q_low),
self._weighted_quantile(self.samples[:, i], self.weights, q_high))
for i, name in enumerate(self.free_parameters)
}
[docs]
def summary(self, sigma: int = 1, include_map: bool = True) -> str:
'''
Print a summary of the posterior distributions.
Parameters
----------
sigma : int
Credible interval (1 or 2 sigma)
include_map : bool
hether to include MAP estimate
Returns
-------
str
Summary
Notes
-----
Authors: Allan Denis
'''
lines = []
lines.append("======== Nested Sampling Summary ====================")
lines.append(f"LogZ = {self.logz[0]:.3f} ± {self.logz[1]:.3f}")
lines.append("")
lines.append(f"Posterior estimates (median ± {sigma} sigma interval)")
lines.append("")
if include_map:
idx_map = np.argmax(self.logl)
for i, name in enumerate(self.free_parameters):
samples = self.samples[:, i]
weights = self.weights
median = self._weighted_quantile(samples, weights, 0.5)
low, high = self._interval(sigma)[name]
minus = median - low
plus = high - median
line = (
f"{name:12s}: "
f"{median:10.4f} "
f"-{minus:8.4f} +{plus:8.4f} "
f"[{low:.4f}, {high:.4f}]"
)
if include_map:
map_val = self.samples[idx_map, i]
line += f" MAP={map_val:.4f}"
lines.append(line)
lines.append("=====================================================")
return "\n".join(lines)