"""
Posterior statistics and diagnostics.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, cast
import arviz as az
import jax
import jax.numpy as jnp
import numpy as np
import pandas as pd
from numpy.typing import ArrayLike
from liesel.__version__ import __version__
from liesel.goose.engine import ErrorLog, SamplingResults
from liesel.goose.epoch import EpochType
from liesel.goose.pytree import slice_leaves, stack_leaves
from liesel.goose.types import Position, PositionInput, TransitionInfo
from liesel.option import Option
class ErrorSummaryForOneCode(NamedTuple):
error_code: int
error_msg: str
count_per_chain: np.ndarray
count_per_chain_posterior: None
ErrorSummary = dict[str, dict[int, ErrorSummaryForOneCode]]
"""
See docstring of ``_make_error_summary``.
"""
def _make_error_summary(
error_log: ErrorLog,
posterior_error_log: Option[ErrorLog],
) -> ErrorSummary:
"""
Creates an error summary from the error log.
The returned value looks like this::
{
kernel_identifier: {
error_code: (error_code, error_msg, count, count_in_posterior),
error_code: (error_code, error_msg, count, count_in_posterior),
...
},
...
}
The ``error_msg`` is the empty string if the kernel class is not supplied in the
``error_log``.
"""
error_summary = {}
for kel in error_log.values():
counter_dict: dict[int, np.ndarray] = {}
# calculate the overall counts
ec_unique = np.unique(kel.error_codes)
for ec in ec_unique:
if ec == 0:
continue
occurences_per_chain = np.sum(kel.error_codes == ec, axis=1)
counter_dict[ec] = occurences_per_chain
krnl_summary: dict[int, ErrorSummaryForOneCode] = {}
for key, count in counter_dict.items():
ec = key
error_msg = (
kel.kernel_cls.unwrap().error_book[ec]
if kel.kernel_cls.is_some()
else ""
)
krnl_summary[ec] = ErrorSummaryForOneCode(ec, error_msg, count, None)
# calculate the counts in the posterior
if posterior_error_log.is_some():
posterior_error_log_unwrapped = posterior_error_log.unwrap()
kel_post = posterior_error_log_unwrapped[kel.kernel_ident]
for ec in ec_unique:
if ec == 0:
continue
occurences_per_chain = np.sum(kel_post.error_codes == ec, axis=1)
krnl_summary[ec] = krnl_summary[ec]._replace(
count_per_chain_posterior=occurences_per_chain
)
error_summary[kel.kernel_ident] = krnl_summary
return error_summary
SummaryQuantities = Literal[
"mean",
"sd",
"var",
"quantiles",
"hdi",
"ess_bulk",
"ess_tail",
"rhat",
"mcse_mean",
"mcse_sd",
]
summary_quantities: Sequence[SummaryQuantities] = (
"mean",
"sd",
"var",
"quantiles",
"hdi",
"ess_bulk",
"ess_tail",
"rhat",
"mcse_mean",
"mcse_sd",
)
def _summarize_acceptance_probabilities(
transition_infos: Mapping[str, TransitionInfo], phase: str
) -> list[dict[str, Any]]:
data = []
for k, tinfo in transition_infos.items():
ap = jnp.asarray(tinfo.acceptance_prob)
pm = jnp.asarray(tinfo.position_moved)
chains, _ = ap.shape
for c in range(chains):
chain = {"kernel": k, "phase": phase, "chain": c}
chain["acceptance_probability"] = float(ap[c, ...].mean())
position_moved = jnp.mean(pm[c, ...])
chain["position_moved"] = position_moved
if float(position_moved) > 1.0 and int(position_moved.round()) == 99:
chain["position_moved"] = jnp.nan
data.append(chain)
return data
def summarize_acceptance_probabilities(
results: SamplingResults,
) -> list[dict[str, Any]]:
warmup = _summarize_acceptance_probabilities(
results.get_warmup_transition_infos(), "warmup"
)
posterior = _summarize_acceptance_probabilities(
results.get_posterior_transition_infos(), "posterior"
)
return warmup + posterior
[docs]
class Summary:
"""
Posterior summary and diagnostics for :class:`~liesel.goose.SamplingResults`.
Offers two main use cases:
1. View an overall summary by printing a summary instance, including a summary table
of the posterior samples and a summary of sampling errors.
2. Programmatically access summary statistics via
``quantities[quantity_name][var_name]``. Please refer to the documentation of the
attribute :attr:`~liesel.goose.Summary.quantities` for details.
Additionally, the summary object can be turned into a :class:`~pandas.DataFrame`
using :meth:`~liesel.goose.Summary.to_dataframe`.
If ``per_chain=False``, statistics are computed over all posterior chains and
draws. If ``per_chain=True``, each chain is summarized separately.
The low-level computations for HDIs, effective sample sizes, R-hat, and Monte
Carlo standard errors are delegated to `ArviZ <https://python.arviz.org/>`_.
By default, the summary contains the following statistics:
- ``mean``: Posterior mean.
- ``sd``: Posterior standard deviation.
- ``var``: Posterior variance.
- ``quantiles``: Posterior quantiles at the probabilities given by
``quantiles``. These are stored as ``"quantile"`` in
:attr:`~liesel.goose.Summary.quantities`
and become columns named ``q_<probability>`` in
:meth:`~liesel.goose.Summary.to_dataframe`.
- ``hdi``: Highest density interval with probability mass ``hdi_prob``. This is
the narrowest posterior interval reported by ArviZ at that probability level.
In :meth:`~liesel.goose.Summary.to_dataframe`, it becomes ``hdi_low`` and
``hdi_high``.
- ``ess_bulk``: Bulk effective sample size, a diagnostic for Monte Carlo
precision in the central part of the posterior distribution.
- ``ess_tail``: Tail effective sample size, a diagnostic for Monte Carlo
precision in the posterior tails.
- ``rhat``: Rank-normalized split R-hat, a between-chain convergence diagnostic.
Values close to 1 indicate better agreement between chains. This statistic is
only computed when more than one chain is summarized together.
- ``mcse_mean``: Monte Carlo standard error of the posterior mean.
- ``mcse_sd``: Monte Carlo standard error of the posterior standard deviation.
Use ``which`` to compute only a subset of these statistics.
Parameters
----------
results
The sampling results to summarize.
additional_chain
Can be supplied to add more parameters to the summary output. Must be a position
chain which matches chain and time dimension of the posterior chain as returned
by :meth:`~liesel.goose.SamplingResults.get_posterior_samples`.
quantiles
Posterior quantile probabilities to compute when ``"quantiles"`` is included
in ``which``.
hdi_prob
Posterior probability mass of the highest density interval to compute when
``"hdi"`` is included in ``which``.
selected, deselected
Allow to get a summary only for a subset of the position keys.
per_chain
If *True*, the summary is calculated on a per-chain basis. Certain measures like
``rhat`` are not available if ``per_chain`` is *True*.
which
Names of the summary statistics to compute. Supported values are ``"mean"``,
``"sd"``, ``"var"``, ``"quantiles"``, ``"hdi"``, ``"ess_bulk"``,
``"ess_tail"``, ``"rhat"``, ``"mcse_mean"``, and ``"mcse_sd"``.
Notes
-----
This class is still considered experimental. The API may still undergo larger
changes.
"""
per_chain: bool
"""
Whether results are summarized for individual chains (*True*), or aggregated
over chains (*False*).
"""
quantities: dict[str, dict[str, np.ndarray]]
"""
Dict of summarizing quantities.
Let ``summary`` be a :class:`~liesel.goose.Summary` instance. The hierarchy is::
q = summary.quantities["quantity_name"]["parameter_name"]
Available quantity names are ``"mean"``, ``"sd"``, ``"var"``, ``"quantile"``,
``"hdi"``, ``"ess_bulk"``, ``"ess_tail"``, ``"rhat"``, ``"mcse_mean"``, and
``"mcse_sd"``, depending on the ``which`` argument. Note that ``which`` uses
``"quantiles"`` to request quantiles, while :attr:`~liesel.goose.Summary.quantities`
stores the
result under ``"quantile"``.
The extracted object is an ``np.ndarray``. If ``per_chain=True``, the arrays for
the ``"quantile"`` and ``"hdi"`` quantities have the following dimensions:
1. First index refers to the chain
2. Second index refers to the quantile/interval
3. Third and subsequent indices refer to individual parameters.
If ``per_chain=True``, the arrays for the other quantities have the dimensions:
1. First index refers to the chain
2. Second and subsequent indices refer to individual parameters.
If ``per_chain=False``, the first index is removed for all quantities.
"""
config: dict
"""
A dictionary of config settings for this summary object. Should NOT be changed
after initialization; such changes have no effect on the computed summary values.
"""
sample_info: dict
"""
Dictionary of meta-information about the mcmc samples used to create this summary
object.
Contains ``num_chains``, ``sample_size_per_chain``, and ``warmup_size_per_chain``.
"""
error_summary: ErrorSummary
"""
Contains error information for each kernel.
"""
kernels_by_pos_key: dict[str, str]
"""
A dict, linking parameter names (the keys) to the kernel identifier (the values).
The identifier refers to the kernel that was used to sample the respective
parameter.
"""
liesel_version: str
"""
The specific version of Liesel used to produce the results.
"""
if TYPE_CHECKING:
pos_keys_by_kernels_df: pd.DataFrame
"""Table associating position keys with sampling kernels."""
def __init__(
self,
results: SamplingResults,
additional_chain: PositionInput | None = None,
quantiles: Sequence[float] = (0.05, 0.5, 0.95),
hdi_prob: float = 0.9,
selected: list[str] | None = None,
deselected: list[str] | None = None,
per_chain: bool = False,
which: Sequence[SummaryQuantities] = summary_quantities,
):
if not which:
raise ValueError(
f"Argument 'which' must not be empty. "
f"Supported keys are: {summary_quantities}"
)
for _which_key in which:
if _which_key not in summary_quantities:
raise ValueError(
f"Key {which} in 'which' is not supported. "
f"Supported keys are: {summary_quantities}"
)
posterior_chain = results.get_posterior_samples()
if additional_chain:
for k, v in additional_chain.items():
posterior_chain[k] = v
if selected:
posterior_chain = Position(
{
key: value
for key, value in posterior_chain.items()
if key in selected
}
)
if deselected is not None:
for key in deselected:
del posterior_chain[key]
# get some general infos on the sampling
param_chain = next(iter(posterior_chain.values()))
epochs = results.positions.get_epochs()
warmup_size = np.sum(
[
int(epoch.duration / epoch.thinning)
for epoch in epochs
if epoch.type.is_warmup(epoch.type)
]
)
thinning_warmup = np.unique(
[epoch.thinning for epoch in epochs if epoch.type.is_warmup(epoch.type)]
)
thinning_posterior = np.unique(
[epoch.thinning for epoch in epochs if epoch.type is EpochType.POSTERIOR]
)
sample_info = {
"num_chains": param_chain.shape[0],
"sample_size_per_chain": param_chain.shape[1],
"warmup_size_per_chain": warmup_size,
"thinning_warmup": thinning_warmup.squeeze(),
"thinning_posterior": thinning_posterior.squeeze(),
}
# convert everything to numpy array
for key in posterior_chain:
posterior_chain[key] = np.asarray(posterior_chain[key])
# calculate quantiles either per chain and merge the results or all at once
single_chain_summaries = []
if per_chain:
for chain_idx in range(sample_info["num_chains"]):
single_chain = slice_leaves(
posterior_chain, jnp.s_[None, chain_idx, ...]
)
qdict = _create_quantity_dict(single_chain, quantiles, hdi_prob, which)
single_chain_summaries.append(qdict)
quantities = stack_leaves(single_chain_summaries, axis=0)
else:
quantities = _create_quantity_dict(
posterior_chain, quantiles, hdi_prob, which
)
config = {
"quantiles": quantiles,
"hdi_prob": hdi_prob,
"chains_merged": not per_chain,
}
error_summary = _make_error_summary(
results.get_error_log(False).unwrap(), results.get_error_log(True)
)
pos_keys_by_kernels = []
for k, v in results.get_pos_keys_by_kernels().items():
pos_keys_by_kernels.append({"kernel": k, "positions": ", ".join(v)})
if len(pos_keys_by_kernels) == 1:
posdf = pd.DataFrame(pos_keys_by_kernels, index=pd.Index([0]))
else:
posdf = pd.DataFrame(pos_keys_by_kernels)
posdf = posdf.set_index(["kernel"])
self._which = which
self.per_chain = per_chain
self.quantities = quantities
self.config = config
self.sample_info = sample_info
self.error_summary = error_summary
self.pos_keys_by_kernels_df = posdf
self._acceptance_prob_summary = summarize_acceptance_probabilities(results)
self.kernels_by_pos_key = results.get_kernels_by_pos_key()
self.liesel_version = __version__
[docs]
def to_dataframe(self) -> pd.DataFrame:
"""Turns Summary object into a :class:`~pandas.DataFrame` object."""
# don't change the original data
quants = self.quantities.copy()
# make new entries for the quantiles
if self.per_chain:
if "quantiles" in self._which:
for i, q in enumerate(self.config["quantiles"]):
quants[f"q_{q}"] = {
k: v[:, i, ...] for k, v in quants["quantile"].items()
}
if "hdi" in self._which:
quants["hdi_low"] = {k: v[:, 0, ...] for k, v in quants["hdi"].items()}
quants["hdi_high"] = {k: v[:, 1, ...] for k, v in quants["hdi"].items()}
else:
if "quantiles" in self._which:
for i, q in enumerate(self.config["quantiles"]):
quants[f"q_{q}"] = {
k: v[i, ...] for k, v in quants["quantile"].items()
}
if "hdi" in self._which:
quants["hdi_low"] = {k: v[0, ...] for k, v in quants["hdi"].items()}
quants["hdi_high"] = {k: v[1, ...] for k, v in quants["hdi"].items()}
# remove the old entries
if "hdi" in self._which:
del quants["hdi"]
if "quantiles" in self._which:
del quants["quantile"]
# create one row per entry
df_dict = {}
first_quant = next(iter(quants.values()))
for var in first_quant:
it = np.nditer(first_quant[var], flags=["multi_index"])
for _ in it:
var_fqn = (
var if len(it.multi_index) == 0 else f"{var}{list(it.multi_index)}"
)
quant_per_elem: dict[str, Any] = {}
quant_per_elem["variable"] = var
quant_per_elem["kernel"] = self.kernels_by_pos_key.get(var, "-")
if self.config["chains_merged"]:
quant_per_elem["var_index"] = it.multi_index
quant_per_elem["sample_size"] = (
self.sample_info["sample_size_per_chain"]
* self.sample_info["num_chains"]
)
else:
quant_per_elem["chain_index"] = it.multi_index[0]
quant_per_elem["var_index"] = it.multi_index[1:]
quant_per_elem["sample_size"] = self.sample_info[
"sample_size_per_chain"
]
for quant_name, quant_dict in quants.items():
quant_per_elem[quant_name] = quant_dict[var][it.multi_index]
# convert jax.Arrays (scalar) to floats so that pandas treats them
# correctly
for key, val in quant_per_elem.items():
if isinstance(val, jax.Array):
# value should be a scalar
assert val.shape == ()
# replace dict element with value casted to float32
quant_per_elem[key] = float(val)
df_dict[var_fqn] = quant_per_elem
# convert to dataframe and use varname as index
df = pd.DataFrame.from_dict(df_dict, orient="index")
df = df.reset_index()
df = df.rename(columns={"index": "var_fqn"})
df = df.set_index("variable")
return df
[docs]
def aggregate_diagnostics(
self,
by: Literal[
"min/max", "mean", "median", "std", "var", "min", "max"
] = "min/max",
) -> pd.DataFrame:
"""
Aggregates effective sample sizes (ESS) and rhat.
Parameters
----------
by
How to aggregate. The three current options are:
- ``"min/max"``: Aggregate ESS by taking the minimum ESS per parameter
block, and the rhat by taking the maximum rhat per parameter block. This
corresponds to a worst-case summary.
- ``"mean"``: Aggregate ESS and rhat by averaging inside parameter blocks.
- ``"median"``: Aggregate ESS and rhat by taking the median inside parameter
blocks.
- ``"std"``: Aggregate ESS and rhat by taking the standard deviation inside
parameter blocks.
- ``"var"``: Aggregate ESS and rhat by taking the variance inside parameter
blocks.
- ``"min"``: Aggregate ESS and rhat by taking the minimum inside parameter
blocks.
- ``"max"``: Aggregate ESS and rhat by taking the maximum inside parameter
blocks.
Notes
------
If :attr:`~liesel.goose.Summary.per_chain` is ``True``, rhat cannot be computed
and there is not
present in the output dataframe.
"""
df = self.to_dataframe()
df.index.name = "parameter"
if by == "min/max":
by_ess = "min"
by_rhat = "max"
else:
by_ess = by
by_rhat = by
if self.per_chain:
diagnostics = (
df.loc[:, ["chain_index", "ess_bulk", "ess_tail"]]
.groupby(["parameter", "chain_index"])
.agg(
ess_bulk=("ess_bulk", by_ess),
ess_tail=("ess_tail", by_ess),
)
)
else:
diagnostics = (
df.loc[:, ["rhat", "ess_bulk", "ess_tail"]]
.groupby("parameter")
.agg(
ess_bulk=("ess_bulk", by_ess),
ess_tail=("ess_tail", by_ess),
rhat=("rhat", by_rhat),
)
)
if by == "min/max":
diagnostics["aggregated_by"] = "min (ess); max (rhat)"
else:
diagnostics["aggregated_by"] = by
return diagnostics
def _param_df(self):
df = self.to_dataframe()
df.index.name = "parameter"
df = df.rename(columns={"var_index": "index"})
df = df.set_index("index", append=True)
qtls = [f"q_{qtl}" for qtl in self.config["quantiles"]]
cols = (
["kernel", "mean", "sd"]
+ qtls
+ ["sample_size", "ess_bulk", "ess_tail", "rhat"]
)
cols = [col for col in cols if col in df.columns]
df = df[cols]
return df
[docs]
def acceptance_prob_df(self) -> pd.DataFrame:
"""Returns an overview of acceptance probabilities as a dataframe."""
apdf = pd.DataFrame(self._acceptance_prob_summary)
apdf = apdf.set_index(["kernel", "phase", "chain"])
apdf = apdf.join(self.pos_keys_by_kernels_df, on="kernel")
apdf = apdf.reset_index().set_index(["kernel", "positions", "phase", "chain"])
if not self.per_chain:
apdf = apdf.groupby(level=["kernel", "positions", "phase"]).agg(
{"acceptance_probability": "mean", "position_moved": "mean"}
)
return apdf
[docs]
def error_df(self, per_chain: bool = False) -> pd.DataFrame:
"""
Returns an overview of the errors recorded during sampling as a dataframe.
"""
return self._error_df(per_chain=per_chain)
def _error_df(self, per_chain: bool = False) -> pd.DataFrame:
# fmt: off
error_summaries = {k: v for k, v in self.error_summary.items() if v}
if error_summaries:
df = pd.concat({
kernel: pd.DataFrame.from_dict(code_summary, orient="index")
for kernel, code_summary in error_summaries.items()
})
else:
return pd.DataFrame()
# fmt: on
df = df.reset_index(level=1, drop=True)
df["error_code"] = df["error_code"].astype(int)
df = df.set_index(["error_code", "error_msg"], append=True)
df.index.names = ["kernel", "error_code", "error_msg"]
# fmt: off
df = df.rename(columns={
"count_per_chain": "total",
"count_per_chain_posterior": "posterior",
})
# fmt: on
df = df.explode(["total", "posterior"])
df["warmup"] = df["total"] - df["posterior"]
df = df.drop(columns="total")
df = df.melt(
value_vars=["warmup", "posterior"],
var_name="phase",
value_name="count",
ignore_index=False,
)
df["phase"] = pd.Categorical(df["phase"], categories=["warmup", "posterior"])
df = df.set_index("phase", append=True)
df["chain"] = df.groupby(level=[0, 1, 2, 3], observed=True).cumcount()
df = df.set_index("chain", append=True)
df = df.sort_index()
df["sample_size"] = None
warmup_size = self.sample_info["warmup_size_per_chain"]
posterior_size = self.sample_info["sample_size_per_chain"]
df.loc[pd.IndexSlice[:, :, :, "warmup"], "sample_size"] = warmup_size
df.loc[pd.IndexSlice[:, :, :, "posterior"], "sample_size"] = posterior_size
df["thinning"] = None
warmup_thinning = self.sample_info["thinning_warmup"]
posterior_thinning = self.sample_info["thinning_posterior"]
df.loc[pd.IndexSlice[:, :, :, "warmup"], "thinning"] = warmup_thinning
df.loc[pd.IndexSlice[:, :, :, "posterior"], "thinning"] = posterior_thinning
df["sample_size_total"] = df["sample_size"] * df["thinning"]
df["relative"] = df["count"] / df["sample_size_total"]
df = (
df.join(self.pos_keys_by_kernels_df, on="kernel")
.reset_index()
.set_index(["kernel", "positions", "error_code", "error_msg", "phase"])
)
# df = df.drop(columns="sample_size")
if not per_chain:
df = df.groupby(level=[0, 1, 2, 3, 4], observed=True)
df = df.aggregate(
{
"count": "sum",
"relative": "mean",
"sample_size": "sum",
"sample_size_total": "sum",
}
)
df = df.sort_index()
# re-order columns
cols = ["count", "sample_size", "sample_size_total", "relative"]
return df[cols]
def __repr__(self):
param_df = self._param_df()
error_df = self._error_df()
txt = "Parameter summary:\n\n" + repr(param_df)
if not error_df.empty:
txt += "\n\nError summary:\n\n" + repr(error_df)
return txt
def _repr_html_(self):
param_df = self._param_df()
apdf = self.acceptance_prob_df()
error_df = self._error_df()
html = "\n<p><strong>Parameter summary:</strong></p>\n" + param_df.to_html()
html += "\n<p><strong>Acceptance probabilities:</strong></p>\n" + apdf.to_html()
if not error_df.empty:
html += "\n<p><strong>Error summary:</strong></p>\n" + error_df.to_html()
html += "\n"
return html
def _repr_markdown_(self):
param_df = self._param_df()
apdf = self.acceptance_prob_df()
error_df = self._error_df()
try:
param_md = param_df.to_markdown()
apdf_md = apdf.to_markdown()
error_md = error_df.to_markdown()
except ImportError:
param_md = f"```\n{param_df!r}\n```"
apdf_md = f"```\n{apdf!r}\n```"
error_md = f"```\n{error_df!r}\n```"
md = "\n\n**Parameter summary:**\n\n" + param_md
md += "\n\n**Acceptance probabilities:**\n\n" + apdf_md
if not error_df.empty:
md += "\n\n**Error summary:**\n\n" + error_md
md += "\n\n"
return md
def __str__(self):
return str(self.to_dataframe())
def _create_quantity_dict(
chain: Position,
quantiles: Sequence[float],
hdi_prob: float,
which: Sequence[SummaryQuantities] = summary_quantities,
) -> dict[str, dict[str, np.ndarray]]:
azchain = az.from_dict({"posterior": chain})["posterior"].dataset
quantities = {}
# calculate quantities
if "mean" in which:
quantities["mean"] = azchain.mean(dim=["chain", "draw"])
if "var" in which:
quantities["var"] = azchain.var(dim=["chain", "draw"])
if "sd" in which:
quantities["sd"] = azchain.std(dim=["chain", "draw"])
if "quantiles" in which:
quantities["quantile"] = azchain.quantile(q=quantiles, dim=["chain", "draw"])
if "hdi" in which:
quantities["hdi"] = az.hdi(azchain, prob=hdi_prob)
if "ess_bulk" in which:
quantities["ess_bulk"] = az.ess(azchain, method="bulk")
if "ess_tail" in which:
quantities["ess_tail"] = az.ess(azchain, method="tail")
if "mcse_mean" in which:
quantities["mcse_mean"] = az.mcse(azchain, method="mean")
if "mcse_sd" in which:
quantities["mcse_sd"] = az.mcse(azchain, method="sd")
if "rhat" in which and azchain.sizes["chain"] > 1:
quantities["rhat"] = az.rhat(azchain)
# convert to simple dict[str, np.ndarray]
for key, val in quantities.items():
quantity = {}
for k, v in val.data_vars.items():
if key == "hdi":
remaining_dims = [dim for dim in v.dims if dim != "ci_bound"]
v = v.transpose("ci_bound", *remaining_dims)
quantity[k] = v.values
quantities[key] = quantity
return quantities
[docs]
class SamplesSummary:
"""
Posterior summary and diagnostics for a dictionary of sample arrays.
Accepts read-only mappings and does not modify the supplied mapping or its arrays.
See :class:`~liesel.goose.Summary` for the full description of the computed
statistics, their
interpretation, the ``quantities`` layout, and the behavior of ``quantiles``,
``hdi_prob``, ``per_chain``, and ``which``. This class computes the same
sample-based statistics as :class:`~liesel.goose.Summary`, but takes a plain
dictionary of
sample arrays instead of a :class:`~liesel.goose.SamplingResults` object and does
not include
sampling-error or acceptance-probability diagnostics.
Offers two main use cases:
1. The summary object can be turned into a :class:`~pandas.DataFrame`
using :meth:`~liesel.goose.SamplesSummary.to_dataframe`.
2. Programmatically access summary statistics via
``quantities[quantity_name][var_name]``. Please refer to the documentation of the
attribute :attr:`~liesel.goose.SamplesSummary.quantities` for details.
Parameters
----------
samples
The dictionary of samples to summarize. Each array is expected to have leading
dimensions ``(nchains, ndraws, ...)``.
quantiles
Posterior quantile probabilities to compute when ``"quantiles"`` is included
in ``which``.
hdi_prob
Posterior probability mass of the highest density interval to compute when
``"hdi"`` is included in ``which``.
selected, deselected
Allow to get a summary only for a subset of the position keys.
per_chain
If *True*, the summary is calculated on a per-chain basis. Certain measures like
``rhat`` are not available if ``per_chain`` is *True*.
which
Names of the summary statistics to compute. Supported values are the same as
for :class:`~liesel.goose.Summary`.
Notes
-----
This class is still considered experimental. The API may still undergo larger
changes.
"""
config: dict
"""Configuration used to compute and format the summaries."""
if TYPE_CHECKING:
per_chain: bool
"""Whether summaries are computed separately for each chain."""
quantities: dict
"""Computed summary quantities for the selected samples."""
sample_info: dict
"""Metadata describing the chains and posterior draws."""
def __init__(
self,
samples: Mapping[str, ArrayLike],
quantiles: Sequence[float] = (0.05, 0.5, 0.95),
hdi_prob: float = 0.9,
selected: list[str] | None = None,
deselected: list[str] | None = None,
per_chain: bool = False,
which: Sequence[SummaryQuantities] = summary_quantities,
):
if not which:
raise ValueError(
f"Argument 'which' must not be empty. "
f"Supported keys are: {summary_quantities}"
)
for _which_key in which:
if _which_key not in summary_quantities:
raise ValueError(
f"Key {which} in 'which' is not supported. "
f"Supported keys are: {summary_quantities}"
)
posterior_chain = Position(dict(samples))
if selected:
posterior_chain = Position(
{
key: value
for key, value in posterior_chain.items()
if key in selected
}
)
if deselected is not None:
for key in deselected:
del posterior_chain[key]
# get some general infos on the sampling
param_chain = np.asarray(next(iter(posterior_chain.values())))
sample_info = {
"num_chains": param_chain.shape[0],
"sample_size_per_chain": param_chain.shape[1],
}
# convert everything to numpy array
for key in posterior_chain:
posterior_chain[key] = np.asarray(posterior_chain[key])
# calculate quantiles either per chain and merge the results or all at once
single_chain_summaries = []
if per_chain:
for chain_idx in range(sample_info["num_chains"]):
single_chain = slice_leaves(
posterior_chain, jnp.s_[None, chain_idx, ...]
)
qdict = _create_quantity_dict(single_chain, quantiles, hdi_prob, which)
single_chain_summaries.append(qdict)
quantities = stack_leaves(single_chain_summaries, axis=0)
else:
quantities = _create_quantity_dict(
posterior_chain, quantiles, hdi_prob, which
)
config = {
"quantiles": quantiles,
"hdi_prob": hdi_prob,
"chains_merged": not per_chain,
}
self._which = which
self.per_chain = per_chain
self.quantities = quantities
self.config = config
self.sample_info = sample_info
[docs]
@classmethod
def from_array(
cls,
a: ArrayLike,
quantiles: Sequence[float] = (0.05, 0.5, 0.95),
hdi_prob: float = 0.9,
selected: list[str] | None = None,
deselected: list[str] | None = None,
per_chain: bool = False,
name: str = "v",
which: Sequence[SummaryQuantities] = summary_quantities,
) -> SamplesSummary:
"""
Initializes the summary from an array of samples.
Parameters
----------
a
The array of samples to summarize. Expected to have leading dimensions
``(nchains, ndraws, ...)``.
quantiles
Posterior quantile probabilities to compute when ``"quantiles"`` is
included in ``which``.
hdi_prob
Posterior probability mass of the highest density interval to compute when
``"hdi"`` is included in ``which``.
selected, deselected
Allow to get a summary only for a subset of the position keys.
per_chain
If *True*, the summary is calculated on a per-chain basis. Certain \
measures like ``rhat`` are not available if ``per_chain`` is *True*.
name
Variable name to use for labelling in
:meth:`~liesel.goose.SamplesSummary.to_dataframe`.
which
Names of the summary statistics to compute. Supported values are the same
as for :class:`~liesel.goose.Summary`.
"""
samples = {name: a}
return cls(samples, quantiles, hdi_prob, selected, deselected, per_chain, which)
[docs]
def to_dataframe(self) -> pd.DataFrame:
"""Turns SamplesSummary object into a :class:`~pandas.DataFrame` object."""
# don't change the original data
quants = self.quantities.copy()
# make new entries for the quantiles
if self.per_chain:
if "quantiles" in self._which:
for i, q in enumerate(self.config["quantiles"]):
quants[f"q_{q}"] = {
k: v[:, i, ...] for k, v in quants["quantile"].items()
}
if "hdi" in self._which:
quants["hdi_low"] = {k: v[:, 0, ...] for k, v in quants["hdi"].items()}
quants["hdi_high"] = {k: v[:, 1, ...] for k, v in quants["hdi"].items()}
else:
if "quantiles" in self._which:
for i, q in enumerate(self.config["quantiles"]):
quants[f"q_{q}"] = {
k: v[i, ...] for k, v in quants["quantile"].items()
}
if "hdi" in self._which:
quants["hdi_low"] = {k: v[0, ...] for k, v in quants["hdi"].items()}
quants["hdi_high"] = {k: v[1, ...] for k, v in quants["hdi"].items()}
# remove the old entries
if "hdi" in self._which:
del quants["hdi"]
if "quantiles" in self._which:
del quants["quantile"]
# create one row per entry
df_dict = {}
first_quant = next(iter(quants.values()))
for var in first_quant:
it = np.nditer(first_quant[var], flags=["multi_index"])
for _ in it:
var_fqn = (
var if len(it.multi_index) == 0 else f"{var}{list(it.multi_index)}"
)
quant_per_elem: dict[str, Any] = {}
quant_per_elem["variable"] = var
if self.config["chains_merged"]:
quant_per_elem["var_index"] = it.multi_index
quant_per_elem["sample_size"] = (
self.sample_info["sample_size_per_chain"]
* self.sample_info["num_chains"]
)
else:
quant_per_elem["chain_index"] = it.multi_index[0]
quant_per_elem["var_index"] = it.multi_index[1:]
quant_per_elem["sample_size"] = self.sample_info[
"sample_size_per_chain"
]
for quant_name, quant_dict in quants.items():
quant_per_elem[quant_name] = quant_dict[var][it.multi_index]
# convert jax.Arrays (scalar) to floats so that pandas treats them
# correctly
for key, val in quant_per_elem.items():
if isinstance(val, jax.Array):
# value should be a scalar
assert val.shape == ()
# replace dict element with value casted to float32
quant_per_elem[key] = float(val)
df_dict[var_fqn] = quant_per_elem
# convert to dataframe and use varname as index
df = pd.DataFrame.from_dict(df_dict, orient="index")
df = df.reset_index()
df = df.rename(columns={"index": "var_fqn"})
df = df.set_index("variable")
return df
def _param_df(self):
df = self.to_dataframe()
df.index.name = "parameter"
df = df.rename(columns={"var_index": "index"})
df = df.set_index("index", append=True)
qtls = [f"q_{qtl}" for qtl in self.config["quantiles"]]
cols = (
["kernel", "mean", "sd"]
+ qtls
+ ["sample_size", "ess_bulk", "ess_tail", "rhat"]
)
cols = [col for col in cols if col in df.columns]
df = df[cols]
return df
[docs]
def aggregate_diagnostics(
self,
by: Literal[
"min/max", "mean", "median", "std", "var", "min", "max"
] = "min/max",
) -> pd.DataFrame:
"""
Aggregates effective sample sizes (ESS) and rhat.
Parameters
----------
by
How to aggregate. The three current options are:
- ``"min/max"``: Aggregate ESS by taking the minimum ESS per parameter
block, and the rhat by taking the maximum rhat per parameter block. This
corresponds to a worst-case summary.
- ``"mean"``: Aggregate ESS and rhat by averaging inside parameter blocks.
- ``"median"``: Aggregate ESS and rhat by taking the median inside parameter
blocks.
- ``"std"``: Aggregate ESS and rhat by taking the standard deviation inside
parameter blocks.
- ``"var"``: Aggregate ESS and rhat by taking the variance inside parameter
blocks.
- ``"min"``: Aggregate ESS and rhat by taking the minimum inside parameter
blocks.
- ``"max"``: Aggregate ESS and rhat by taking the maximum inside parameter
blocks.
Notes
------
If :attr:`~liesel.goose.SamplesSummary.per_chain` is ``True``, rhat cannot be
computed and there is not
present in the output dataframe.
"""
df = self.to_dataframe()
df.index.name = "parameter"
if by == "min/max":
by_ess = "min"
by_rhat = "max"
else:
by_ess = by
by_rhat = by
if self.per_chain:
diagnostics = (
df.loc[:, ["chain_index", "ess_bulk", "ess_tail"]]
.groupby(["parameter", "chain_index"])
.agg(
ess_bulk=("ess_bulk", by_ess),
ess_tail=("ess_tail", by_ess),
)
)
else:
diagnostics = (
df.loc[:, ["rhat", "ess_bulk", "ess_tail"]]
.groupby("parameter")
.agg(
ess_bulk=("ess_bulk", by_ess),
ess_tail=("ess_tail", by_ess),
rhat=("rhat", by_rhat),
)
)
if by == "min/max":
diagnostics["aggregated_by"] = "min (ess); max (rhat)"
else:
diagnostics["aggregated_by"] = by
return diagnostics
def concatenate_arrays_in_dict(
x: Mapping[str, jax.typing.ArrayLike], n_leading_axes: int = 2
) -> jax.Array:
"""
Concatenates all arrays in the supplied dictionary into a single array.
Returns
-------
An array of dimension ``(leading1, leading1, nobs)``, where ``nobs`` is the total
number of elements in the non-leading axes of the dictionary values. As the default
case, we expect the output to have shape ``(nchains, nsamples, nobs)``.
"""
flat_arrays = []
for v in x.values():
# assumed to have shape (s, c, ...)
v = jnp.atleast_3d(jnp.asarray(v))
vshape = v.shape[:n_leading_axes] + (-1,)
flat_arrays.append(jnp.reshape(v, vshape))
out_array = jnp.concatenate(flat_arrays, axis=-1)
return out_array
class _LieselELPDData(az.ELPDData):
"""An ArviZ LOO result with Liesel's legacy attribute aliases."""
elpd_loo: float
p_loo: float
def _apply_loo_scale(
result: az.ELPDData,
scale: Literal["log", "negative_log", "deviance"],
) -> _LieselELPDData:
if scale != "log":
multiplier = -1 if scale == "negative_log" else -2
result.elpd = multiplier * result.elpd
result.se = abs(multiplier) * result.se
result.elpd_i = multiplier * result.elpd_i
result.scale = scale
liesel_result = cast(_LieselELPDData, result)
liesel_result.elpd_loo = result.elpd
liesel_result.p_loo = result.p
return liesel_result
[docs]
def loo(
lpp: Mapping[str, jax.typing.ArrayLike] | jax.typing.ArrayLike,
samples: Mapping[str, jax.typing.ArrayLike] | None = None,
reff: float | None = None,
scale: Literal["log", "negative_log", "deviance"] = "log",
) -> _LieselELPDData:
"""
Compute Pareto-smoothed importance sampling leave-one-out cross-validation
(PSIS-LOO-CV) statistic via ArviZ.
Parameters
----------
lpp
Dictionary or array of pointwise log probability evaluations.
If passed as a dictionary, each value is expected to have shape
``(nchains, ndraws, ...)``.
If passed as an array, it is assumed to have shape ``(nchains, ndraws, n)``.
samples
Retained for backwards compatibility and ignored.
reff
Relative MCMC efficiency, ess / n i.e. number of effective samples divided
by the number of actual samples. Computed from the likelihood values by
default. For independent draws, pass ``1.0``.
scale
Output scale. The options are:
- ``log``: (default) log probability scale.
- ``negative_log``: ``-1 * log``
- ``deviance``: ``-2 * log``
A higher log probability (or a lower deviance or negative log_score)
indicates a model with better predictive accuracy.
References
----------
- Computations are carried out via ArviZ: https://python.arviz.org/en/stable/
- Theoretical background: Vehtari, A., Gelman, A., & Gabry, J. (2017). Practical
Bayesian model evaluation using leave-one-out cross-validation and WAIC.
Statistics and Computing, 27(5), 1413–1432.
https://doi.org/10.1007/s11222-016-9696-4
"""
if isinstance(lpp, Mapping):
lpp_by_variable = cast(Mapping[str, jax.typing.ArrayLike], lpp)
lpp_array = concatenate_arrays_in_dict(lpp_by_variable)
else:
lpp_array = jnp.asarray(lpp)
lpp_array = np.asarray(lpp_array)
idat = az.from_dict({"log_likelihood": {"observed": lpp_array}})
if reff is None:
nsamples = lpp_array.shape[0] * lpp_array.shape[1]
shift = lpp_array.max(axis=(0, 1), keepdims=True)
likelihood = np.exp(lpp_array - shift)
reff = float(np.mean(az.ess(likelihood, method="mean") / nsamples))
# now we assume reff is not None
return _apply_loo_scale(az.loo(idat, reff=reff), scale)