Skip to content

bayesian

Bayesian posterior analysis for reflectometry fitting results.

_HAS_ARVIZ = True module-attribute

_POSTERIOR_PAIR_MIN_SAMPLE_COUNT = 2 module-attribute

_POSTERIOR_PAIR_COVARIANCE_RANK = 2 module-attribute

_POSTERIOR_PAIR_SCATTER_MAX_POINTS = 1500 module-attribute

_POSTERIOR_PAIR_CONTOUR_GRID_SIZE = 80 module-attribute

_POSTERIOR_PAIR_DENSITY_GRID_SIZE = 256 module-attribute

_POSTERIOR_PAIR_MARGINAL_LINE_COLOR = 'rgb(44, 160, 44)' module-attribute

_POSTERIOR_PAIR_MARGINAL_FILL_COLOR = 'rgba(44, 160, 44, 0.22)' module-attribute

_POSTERIOR_PAIR_SCATTER_COLOR = 'rgba(140, 140, 140, 0.22)' module-attribute

_POSTERIOR_HISTOGRAM_FILL_COLOR = 'rgba(120, 120, 120, 0.38)' module-attribute

_POSTERIOR_HISTOGRAM_LINE_COLOR = 'rgba(120, 120, 120, 0.24)' module-attribute

_POSTERIOR_INTERVAL_95_FILL_COLOR = 'rgba(214, 39, 40, 0.14)' module-attribute

_POSTERIOR_MEDIAN_LINE_COLOR = 'rgb(80, 80, 80)' module-attribute

_POSTERIOR_POINT_ESTIMATE_LINE_COLOR = 'rgb(214, 39, 40)' module-attribute

_POSTERIOR_CONTOUR_FILL_COLORSCALE = [[0.0, 'rgba(224, 233, 255, 0.62)'], [0.35, 'rgba(183, 203, 255, 0.70)'], [0.6, 'rgba(138, 169, 252, 0.78)'], [0.82, 'rgba(96, 131, 242, 0.84)'], [1.0, 'rgba(58, 86, 224, 0.90)']] module-attribute

_POSTERIOR_NEGATIVE_CONTOUR_FILL_COLORSCALE = [[0.0, 'rgba(255, 224, 224, 0.62)'], [0.35, 'rgba(250, 188, 188, 0.70)'], [0.6, 'rgba(245, 148, 148, 0.78)'], [0.82, 'rgba(237, 104, 104, 0.84)'], [1.0, 'rgba(215, 48, 39, 0.90)']] module-attribute

_POSTERIOR_CONTOUR_LINE_COLORSCALE = [[0.0, 'rgba(183, 203, 255, 0.94)'], [0.35, 'rgba(183, 203, 255, 0.94)'], [0.35, 'rgba(138, 169, 252, 0.95)'], [0.6, 'rgba(138, 169, 252, 0.95)'], [0.6, 'rgba(96, 131, 242, 0.96)'], [0.82, 'rgba(96, 131, 242, 0.96)'], [0.82, 'rgba(58, 86, 224, 0.98)'], [1.0, 'rgba(58, 86, 224, 0.98)']] module-attribute

_POSTERIOR_NEGATIVE_CONTOUR_LINE_COLORSCALE = [[0.0, 'rgba(250, 188, 188, 0.94)'], [0.35, 'rgba(250, 188, 188, 0.94)'], [0.35, 'rgba(245, 148, 148, 0.95)'], [0.6, 'rgba(245, 148, 148, 0.95)'], [0.6, 'rgba(237, 104, 104, 0.96)'], [0.82, 'rgba(237, 104, 104, 0.96)'], [0.82, 'rgba(215, 48, 39, 0.98)'], [1.0, 'rgba(215, 48, 39, 0.98)']] module-attribute

_SIDECAR_SCHEMA_VERSION = 1 module-attribute

PosteriorResults

Container for Bayesian posterior samples with analysis methods.

:param draws: Posterior samples, shape (n_samples, n_params). :type draws: np.ndarray :param param_names: Parameter names (one per column of draws). :type param_names: list[str] :param logp: Log-posterior values, shape (n_samples,), or None. :type logp: np.ndarray | None :param sampler_state: Raw sampler state object (e.g. BUMPS DreamState), or None. :type sampler_state: Any | None

Source code in src/easyreflectometry/analysis/bayesian.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
class PosteriorResults:
    """Container for Bayesian posterior samples with analysis methods.

    :param draws: Posterior samples, shape ``(n_samples, n_params)``.
    :type draws: np.ndarray
    :param param_names: Parameter names (one per column of ``draws``).
    :type param_names: list[str]
    :param logp: Log-posterior values, shape ``(n_samples,)``, or ``None``.
    :type logp: np.ndarray | None
    :param sampler_state: Raw sampler state object (e.g. BUMPS ``DreamState``), or ``None``.
    :type sampler_state: Any | None
    """

    def __init__(
        self,
        draws: np.ndarray,
        param_names: list[str],
        logp: np.ndarray | None = None,
        sampler_state: Any | None = None,
    ):
        self.draws = np.asarray(draws)
        self.param_names = list(param_names)
        self.logp = np.asarray(logp) if logp is not None else None
        self.sampler_state = sampler_state

    def __repr__(self) -> str:
        n_samples, n_params = self.draws.shape
        return f'PosteriorResults(n_samples={n_samples}, n_params={n_params}, param_names={self.param_names})'

    def summary(self) -> str:
        """Return a formatted summary table with mean, sd, and equal-tailed 95% credible interval for each parameter.

        :return: Formatted summary table as a string.
        :rtype: str
        """
        return posterior_summary(self.draws, self.param_names)

    def corner(self) -> Any:
        """Return the parameter-correlation corner plot as a Plotly Figure.

        Requires the ``plotly`` library.

        :return: Plotly Figure.
        """
        return plot_corner(self.draws, self.param_names)

    def trace(self, **kwargs) -> None:
        """Plot MCMC trace plot.

        Requires the ``arviz`` library.

        :param kwargs: Additional keyword arguments passed to ``arviz.plot_trace``.
        """
        plot_trace(self.draws, self.param_names, **kwargs)

    def distribution(self) -> Any:
        """Return the per-parameter marginal posterior distributions as a Plotly Figure.

        Each panel overlays the posterior histogram, a smooth KDE marginal, the
        95% credible interval, the median, and the best posterior sample (when
        ``logp`` is available). Requires the ``plotly`` library.

        :return: Plotly Figure.
        """
        return plot_distribution(self.draws, self.param_names, logp=self.logp, return_figure=True)

    def credible_interval(self, alpha: float = 0.95) -> dict:
        """Compute equal-tailed credible intervals for each parameter.

        :param alpha: Credible interval width (e.g. 0.95 for 95%).
        :type alpha: float
        :return: Dictionary mapping parameter name to ``(lower, upper)``.
        :rtype: dict
        """
        return credible_intervals(self.draws, self.param_names, alpha=alpha)

    def save(self, path: str) -> None:
        """Persist this posterior trace to disk.

        Convenience wrapper around :func:`save_posterior`.

        :param path: File path prefix (see :func:`save_posterior`).
        :type path: str
        """
        save_posterior(self, path)

    def gelman_rubin(self) -> dict | None:
        """Compute the Gelman-Rubin R-hat convergence diagnostic.

        Requires the ``arviz`` library and posterior draws with at least
        two chains, i.e. shape ``(n_chains, n_draws, n_params)`` with
        ``n_chains >= 2``. R-hat is undefined for a single chain.

        :return: Dictionary mapping parameter name to R-hat value, or ``None``
            if ``arviz`` is not available.
        :rtype: dict | None
        :raises ValueError: If ``self.draws`` does not contain at least two
            chains.
        """
        if not _HAS_ARVIZ:
            warnings.warn(
                'The ``arviz`` library is required for Gelman-Rubin R-hat. Install it with ``pip install arviz``.',
                UserWarning,
            )
            return None
        if self.draws.ndim < 3 or self.draws.shape[0] < 2:
            raise ValueError(
                'Gelman-Rubin R-hat requires posterior draws with at least 2 chains '
                '(shape ``(n_chains, n_draws, n_params)`` with ``n_chains >= 2``).'
            )
        data = _to_arviz_data(self.draws, self.param_names)
        rhat = _arviz.rhat(data)
        return {name: float(rhat[name].values) for name in self.param_names}

summary()

Return a formatted summary table with mean, sd, and equal-tailed 95% credible interval for each parameter.

:return: Formatted summary table as a string. :rtype: str

Source code in src/easyreflectometry/analysis/bayesian.py
126
127
128
129
130
131
132
def summary(self) -> str:
    """Return a formatted summary table with mean, sd, and equal-tailed 95% credible interval for each parameter.

    :return: Formatted summary table as a string.
    :rtype: str
    """
    return posterior_summary(self.draws, self.param_names)

corner()

Return the parameter-correlation corner plot as a Plotly Figure.

Requires the plotly library.

:return: Plotly Figure.

Source code in src/easyreflectometry/analysis/bayesian.py
134
135
136
137
138
139
140
141
def corner(self) -> Any:
    """Return the parameter-correlation corner plot as a Plotly Figure.

    Requires the ``plotly`` library.

    :return: Plotly Figure.
    """
    return plot_corner(self.draws, self.param_names)

trace(**kwargs)

Plot MCMC trace plot.

Requires the arviz library.

:param kwargs: Additional keyword arguments passed to arviz.plot_trace.

Source code in src/easyreflectometry/analysis/bayesian.py
143
144
145
146
147
148
149
150
def trace(self, **kwargs) -> None:
    """Plot MCMC trace plot.

    Requires the ``arviz`` library.

    :param kwargs: Additional keyword arguments passed to ``arviz.plot_trace``.
    """
    plot_trace(self.draws, self.param_names, **kwargs)

distribution()

Return the per-parameter marginal posterior distributions as a Plotly Figure.

Each panel overlays the posterior histogram, a smooth KDE marginal, the 95% credible interval, the median, and the best posterior sample (when logp is available). Requires the plotly library.

:return: Plotly Figure.

Source code in src/easyreflectometry/analysis/bayesian.py
152
153
154
155
156
157
158
159
160
161
def distribution(self) -> Any:
    """Return the per-parameter marginal posterior distributions as a Plotly Figure.

    Each panel overlays the posterior histogram, a smooth KDE marginal, the
    95% credible interval, the median, and the best posterior sample (when
    ``logp`` is available). Requires the ``plotly`` library.

    :return: Plotly Figure.
    """
    return plot_distribution(self.draws, self.param_names, logp=self.logp, return_figure=True)

credible_interval(alpha=0.95)

Compute equal-tailed credible intervals for each parameter.

:param alpha: Credible interval width (e.g. 0.95 for 95%). :type alpha: float :return: Dictionary mapping parameter name to (lower, upper). :rtype: dict

Source code in src/easyreflectometry/analysis/bayesian.py
163
164
165
166
167
168
169
170
171
def credible_interval(self, alpha: float = 0.95) -> dict:
    """Compute equal-tailed credible intervals for each parameter.

    :param alpha: Credible interval width (e.g. 0.95 for 95%).
    :type alpha: float
    :return: Dictionary mapping parameter name to ``(lower, upper)``.
    :rtype: dict
    """
    return credible_intervals(self.draws, self.param_names, alpha=alpha)

save(path)

Persist this posterior trace to disk.

Convenience wrapper around :func:save_posterior.

:param path: File path prefix (see :func:save_posterior). :type path: str

Source code in src/easyreflectometry/analysis/bayesian.py
173
174
175
176
177
178
179
180
181
def save(self, path: str) -> None:
    """Persist this posterior trace to disk.

    Convenience wrapper around :func:`save_posterior`.

    :param path: File path prefix (see :func:`save_posterior`).
    :type path: str
    """
    save_posterior(self, path)

gelman_rubin()

Compute the Gelman-Rubin R-hat convergence diagnostic.

Requires the arviz library and posterior draws with at least two chains, i.e. shape (n_chains, n_draws, n_params) with n_chains >= 2. R-hat is undefined for a single chain.

:return: Dictionary mapping parameter name to R-hat value, or None if arviz is not available. :rtype: dict | None :raises ValueError: If self.draws does not contain at least two chains.

Source code in src/easyreflectometry/analysis/bayesian.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def gelman_rubin(self) -> dict | None:
    """Compute the Gelman-Rubin R-hat convergence diagnostic.

    Requires the ``arviz`` library and posterior draws with at least
    two chains, i.e. shape ``(n_chains, n_draws, n_params)`` with
    ``n_chains >= 2``. R-hat is undefined for a single chain.

    :return: Dictionary mapping parameter name to R-hat value, or ``None``
        if ``arviz`` is not available.
    :rtype: dict | None
    :raises ValueError: If ``self.draws`` does not contain at least two
        chains.
    """
    if not _HAS_ARVIZ:
        warnings.warn(
            'The ``arviz`` library is required for Gelman-Rubin R-hat. Install it with ``pip install arviz``.',
            UserWarning,
        )
        return None
    if self.draws.ndim < 3 or self.draws.shape[0] < 2:
        raise ValueError(
            'Gelman-Rubin R-hat requires posterior draws with at least 2 chains '
            '(shape ``(n_chains, n_draws, n_params)`` with ``n_chains >= 2``).'
        )
    data = _to_arviz_data(self.draws, self.param_names)
    rhat = _arviz.rhat(data)
    return {name: float(rhat[name].values) for name in self.param_names}

_require_arviz()

Source code in src/easyreflectometry/analysis/bayesian.py
23
24
25
def _require_arviz():
    if not _HAS_ARVIZ:
        raise ImportError('The ``arviz`` library is required for trace plots and R-hat. Install it with ``pip install arviz``.')

_require_plotly()

Source code in src/easyreflectometry/analysis/bayesian.py
28
29
30
31
32
33
34
def _require_plotly():
    try:
        import plotly  # noqa: F401
    except ImportError as exc:
        raise ImportError(
            'The ``plotly`` library is required for posterior plots. Install it with ``pip install plotly``.'
        ) from exc

_wrap_pair_label(name, max_len=16)

Insert <br> line breaks so long parameter labels don't overlap.

Dotted names (e.g. layer1.thickness) break on dots; plain names word-wrap on spaces at roughly max_len characters per line.

Source code in src/easyreflectometry/analysis/bayesian.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def _wrap_pair_label(name: str, max_len: int = 16) -> str:
    """Insert ``<br>`` line breaks so long parameter labels don't overlap.

    Dotted names (e.g. ``layer1.thickness``) break on dots; plain names
    word-wrap on spaces at roughly *max_len* characters per line.
    """
    name = name.strip()
    if not name:
        return name
    if '.' in name:
        parts = [p.strip() for p in name.split('.') if p.strip()]
        if parts:
            return '<br>'.join([*(f'{p}.' for p in parts[:-1]), parts[-1]])
    if len(name) <= max_len:
        return name
    words = name.split()
    if len(words) == 1:
        return name
    lines: list[str] = []
    current = ''
    for word in words:
        if current and len(current) + 1 + len(word) > max_len:
            lines.append(current)
            current = word
        else:
            current = f'{current} {word}' if current else word
    if current:
        lines.append(current)
    return '<br>'.join(lines)

_to_arviz_data(draws, param_names)

Convert posterior draws to an arviz InferenceData object.

:param draws: Posterior samples, shape (n_samples, n_params) or (n_chains, n_draws, n_params). :type draws: np.ndarray :param param_names: Parameter names (one per column). :type param_names: list[str] :return: arviz InferenceData object.

Source code in src/easyreflectometry/analysis/bayesian.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def _to_arviz_data(draws: np.ndarray, param_names: list[str]):
    """Convert posterior draws to an arviz InferenceData object.

    :param draws: Posterior samples, shape ``(n_samples, n_params)`` or
        ``(n_chains, n_draws, n_params)``.
    :type draws: np.ndarray
    :param param_names: Parameter names (one per column).
    :type param_names: list[str]
    :return: arviz InferenceData object.
    """
    draws = np.asarray(draws, dtype=np.float64)
    if draws.ndim == 2:
        draws = draws[np.newaxis, ...]  # (1, n_samples, n_params)

    # Build a dict of {param_name: (chain, draw) array}
    posterior_dict = {}
    for i, name in enumerate(param_names):
        posterior_dict[name] = draws[:, :, i]

    # arviz < 1.0 takes the posterior variables as a keyword argument; arviz
    # >= 1.0 removed it in favour of a single {group: {var: array}} mapping.
    # The 1.x call cannot go first: 0.x accepts the mapping without error but
    # misreads it as one variable named 'posterior'.
    try:
        return _arviz.from_dict(posterior=posterior_dict)
    except TypeError:
        return _arviz.from_dict({'posterior': posterior_dict})

posterior_summary(draws, param_names)

Return a formatted summary table with mean, sd, and the equal-tailed 2.5%/97.5% posterior quantiles for each parameter.

The reported interval is the equal-tailed 95% credible interval; it is not a highest-density interval (HDI) and the two coincide only for symmetric unimodal posteriors.

:param draws: Posterior samples, shape (n_samples, n_params). :type draws: np.ndarray :param param_names: Parameter names (one per column). :type param_names: list[str] :return: Formatted summary table as a string. :rtype: str

Source code in src/easyreflectometry/analysis/bayesian.py
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
def posterior_summary(draws: np.ndarray, param_names: list[str]) -> str:
    """Return a formatted summary table with mean, sd, and the equal-tailed
    2.5%/97.5% posterior quantiles for each parameter.

    The reported interval is the equal-tailed 95% credible interval; it is
    not a highest-density interval (HDI) and the two coincide only for
    symmetric unimodal posteriors.

    :param draws: Posterior samples, shape ``(n_samples, n_params)``.
    :type draws: np.ndarray
    :param param_names: Parameter names (one per column).
    :type param_names: list[str]
    :return: Formatted summary table as a string.
    :rtype: str
    """
    draws = np.asarray(draws)
    lines = [f'{"parameter":<30s} {"mean":>10s} {"sd":>10s} {"q2.5%":>10s} {"q97.5%":>10s}']
    for i, name in enumerate(param_names):
        col = draws[:, i]
        lo, hi = np.percentile(col, [2.5, 97.5])
        lines.append(f'{name:<30s} {col.mean():>10.4f} {col.std():>10.4f} {lo:>10.4f} {hi:>10.4f}')
    return '\n'.join(lines)

_posterior_axis_bounds(values)

Return padded (lower, upper) plotting bounds for one posterior axis.

:param values: Posterior samples for a single parameter. :type values: np.ndarray :return: Padded bounds, or None if there are no finite samples. :rtype: tuple[float, float] | None

Source code in src/easyreflectometry/analysis/bayesian.py
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
def _posterior_axis_bounds(values: np.ndarray) -> tuple[float, float] | None:
    """Return padded ``(lower, upper)`` plotting bounds for one posterior axis.

    :param values: Posterior samples for a single parameter.
    :type values: np.ndarray
    :return: Padded bounds, or ``None`` if there are no finite samples.
    :rtype: tuple[float, float] | None
    """
    data = np.asarray(values, dtype=float)
    data = data[np.isfinite(data)]
    if data.size == 0:
        return None
    data_min = float(np.min(data))
    data_max = float(np.max(data))
    data_range = data_max - data_min
    padding = 0.05 * data_range if data_range > 0 else max(abs(data_min), 1.0) * 0.05
    if padding == 0:
        padding = 1e-6
    return data_min - padding, data_max + padding

_posterior_density_curve(values, grid_size=_POSTERIOR_PAIR_DENSITY_GRID_SIZE)

Estimate a 1-D Gaussian-KDE marginal density normalised to unit area.

:param values: Posterior samples for a single parameter. :type values: np.ndarray :param grid_size: Number of grid points at which to evaluate the density. :type grid_size: int :return: (grid, density) arrays, or None if a smooth density could not be estimated (e.g. scipy missing or degenerate samples). :rtype: tuple[np.ndarray, np.ndarray] | None

Source code in src/easyreflectometry/analysis/bayesian.py
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
def _posterior_density_curve(
    values: np.ndarray,
    grid_size: int = _POSTERIOR_PAIR_DENSITY_GRID_SIZE,
) -> tuple[np.ndarray, np.ndarray] | None:
    """Estimate a 1-D Gaussian-KDE marginal density normalised to unit area.

    :param values: Posterior samples for a single parameter.
    :type values: np.ndarray
    :param grid_size: Number of grid points at which to evaluate the density.
    :type grid_size: int
    :return: ``(grid, density)`` arrays, or ``None`` if a smooth density could
        not be estimated (e.g. ``scipy`` missing or degenerate samples).
    :rtype: tuple[np.ndarray, np.ndarray] | None
    """
    try:
        from scipy.stats import gaussian_kde
    except ImportError:
        return None

    data = np.asarray(values, dtype=float)
    data = data[np.isfinite(data)]
    if data.size < _POSTERIOR_PAIR_MIN_SAMPLE_COUNT:
        return None

    bounds = _posterior_axis_bounds(data)
    if bounds is None:
        return None
    grid = np.linspace(bounds[0], bounds[1], num=grid_size)

    if np.allclose(data, data[0]):
        bandwidth = max(abs(data[0]) * 0.01, 1e-6)
        density = np.exp(-0.5 * ((grid - data[0]) / bandwidth) ** 2)
        density /= bandwidth * np.sqrt(2.0 * np.pi)
    else:
        try:
            density = np.asarray(gaussian_kde(data)(grid), dtype=float)
        except (np.linalg.LinAlgError, ValueError):
            return None

    area = np.trapezoid(density, grid)
    if area <= 0:
        return None
    return grid, density / area

_posterior_density_surface(x_values, y_values, grid_size=_POSTERIOR_PAIR_CONTOUR_GRID_SIZE)

Estimate a 2-D Gaussian-KDE density surface for one pair panel.

:param x_values: Posterior samples for the x-axis parameter. :type x_values: np.ndarray :param y_values: Posterior samples for the y-axis parameter. :type y_values: np.ndarray :param grid_size: Number of grid points per axis. :type grid_size: int :return: (x_grid, y_grid, density), or None if a smooth surface could not be estimated (e.g. scipy missing or degenerate samples). :rtype: tuple[np.ndarray, np.ndarray, np.ndarray] | None

Source code in src/easyreflectometry/analysis/bayesian.py
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
def _posterior_density_surface(
    x_values: np.ndarray,
    y_values: np.ndarray,
    grid_size: int = _POSTERIOR_PAIR_CONTOUR_GRID_SIZE,
) -> tuple[np.ndarray, np.ndarray, np.ndarray] | None:
    """Estimate a 2-D Gaussian-KDE density surface for one pair panel.

    :param x_values: Posterior samples for the x-axis parameter.
    :type x_values: np.ndarray
    :param y_values: Posterior samples for the y-axis parameter.
    :type y_values: np.ndarray
    :param grid_size: Number of grid points per axis.
    :type grid_size: int
    :return: ``(x_grid, y_grid, density)``, or ``None`` if a smooth surface
        could not be estimated (e.g. ``scipy`` missing or degenerate samples).
    :rtype: tuple[np.ndarray, np.ndarray, np.ndarray] | None
    """
    try:
        from scipy.stats import gaussian_kde
    except ImportError:
        return None

    x_data = np.asarray(x_values, dtype=float)
    y_data = np.asarray(y_values, dtype=float)
    mask = np.isfinite(x_data) & np.isfinite(y_data)
    x_data = x_data[mask]
    y_data = y_data[mask]
    if x_data.size < _POSTERIOR_PAIR_MIN_SAMPLE_COUNT or y_data.size < _POSTERIOR_PAIR_MIN_SAMPLE_COUNT:
        return None
    if np.allclose(x_data, x_data[0]) and np.allclose(y_data, y_data[0]):
        return None

    pair_data = np.vstack([x_data, y_data])
    covariance = np.cov(pair_data)
    if np.linalg.matrix_rank(covariance) < _POSTERIOR_PAIR_COVARIANCE_RANK:
        return None

    x_bounds = _posterior_axis_bounds(x_data)
    y_bounds = _posterior_axis_bounds(y_data)
    if x_bounds is None or y_bounds is None:
        return None
    x_grid = np.linspace(x_bounds[0], x_bounds[1], num=grid_size)
    y_grid = np.linspace(y_bounds[0], y_bounds[1], num=grid_size)
    mesh_x, mesh_y = np.meshgrid(x_grid, y_grid)
    try:
        kde = gaussian_kde(pair_data)
        density = np.asarray(kde(np.vstack([mesh_x.ravel(), mesh_y.ravel()])), dtype=float)
    except (np.linalg.LinAlgError, ValueError):
        return None
    density = density.reshape(mesh_x.shape)
    if not np.any(np.isfinite(density)):
        return None
    return x_grid, y_grid, density

_posterior_contour_colorscales(x_values, y_values)

Return sign-aware fill and line contour palettes for one pair panel.

Negatively correlated parameter pairs use a red palette; everything else uses blue.

:param x_values: Posterior samples for the x-axis parameter. :type x_values: np.ndarray :param y_values: Posterior samples for the y-axis parameter. :type y_values: np.ndarray :return: (fill_colorscale, line_colorscale). :rtype: tuple[list, list]

Source code in src/easyreflectometry/analysis/bayesian.py
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
def _posterior_contour_colorscales(
    x_values: np.ndarray,
    y_values: np.ndarray,
) -> tuple[list, list]:
    """Return sign-aware fill and line contour palettes for one pair panel.

    Negatively correlated parameter pairs use a red palette; everything else
    uses blue.

    :param x_values: Posterior samples for the x-axis parameter.
    :type x_values: np.ndarray
    :param y_values: Posterior samples for the y-axis parameter.
    :type y_values: np.ndarray
    :return: ``(fill_colorscale, line_colorscale)``.
    :rtype: tuple[list, list]
    """
    x_data = np.asarray(x_values, dtype=float)
    y_data = np.asarray(y_values, dtype=float)
    mask = np.isfinite(x_data) & np.isfinite(y_data)
    if np.count_nonzero(mask) >= _POSTERIOR_PAIR_MIN_SAMPLE_COUNT:
        correlation = float(np.corrcoef(x_data[mask], y_data[mask])[0, 1])
        if np.isfinite(correlation) and correlation < 0:
            return _POSTERIOR_NEGATIVE_CONTOUR_FILL_COLORSCALE, _POSTERIOR_NEGATIVE_CONTOUR_LINE_COLORSCALE
    return _POSTERIOR_CONTOUR_FILL_COLORSCALE, _POSTERIOR_CONTOUR_LINE_COLORSCALE

_add_corner_marginal(fig, go, *, values, row, col, show_legend)

Add a diagonal marginal-density panel (smooth KDE, histogram fallback).

:param fig: The Plotly Figure being built. :param go: The plotly.graph_objects module. :param values: Posterior samples for the diagonal parameter. :type values: np.ndarray :param row: 1-based subplot row. :type row: int :param col: 1-based subplot column. :type col: int :param show_legend: Whether this trace should add the legend entry. :type show_legend: bool

Source code in src/easyreflectometry/analysis/bayesian.py
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
def _add_corner_marginal(
    fig: Any,
    go: Any,
    *,
    values: np.ndarray,
    row: int,
    col: int,
    show_legend: bool,
) -> None:
    """Add a diagonal marginal-density panel (smooth KDE, histogram fallback).

    :param fig: The Plotly Figure being built.
    :param go: The ``plotly.graph_objects`` module.
    :param values: Posterior samples for the diagonal parameter.
    :type values: np.ndarray
    :param row: 1-based subplot row.
    :type row: int
    :param col: 1-based subplot column.
    :type col: int
    :param show_legend: Whether this trace should add the legend entry.
    :type show_legend: bool
    """
    curve = _posterior_density_curve(values)
    if curve is not None:
        grid, density = curve
        fig.add_trace(
            go.Scatter(
                x=grid,
                y=density,
                mode='lines',
                line=dict(color=_POSTERIOR_PAIR_MARGINAL_LINE_COLOR, width=1),
                fill='tozeroy',
                fillcolor=_POSTERIOR_PAIR_MARGINAL_FILL_COLOR,
                name='Marginal density',
                legendgroup='marginal',
                showlegend=show_legend,
                hoverinfo='skip',
            ),
            row=row,
            col=col,
        )
        return
    # scipy unavailable or degenerate samples: fall back to a histogram.
    fig.add_trace(
        go.Histogram(
            x=values,
            nbinsx=40,
            histnorm='probability density',
            marker=dict(
                color=_POSTERIOR_PAIR_MARGINAL_FILL_COLOR,
                line=dict(color=_POSTERIOR_PAIR_MARGINAL_LINE_COLOR, width=1),
            ),
            name='Marginal density',
            legendgroup='marginal',
            showlegend=show_legend,
            hoverinfo='skip',
        ),
        row=row,
        col=col,
    )

_corner_contour_traces(go, x_values, y_values)

Build filled and line 2-D KDE contour traces for one pair panel.

:param go: The plotly.graph_objects module. :param x_values: Posterior samples for the x-axis parameter. :type x_values: np.ndarray :param y_values: Posterior samples for the y-axis parameter. :type y_values: np.ndarray :return: (fill_trace, line_trace), or None if no smooth surface could be estimated. :rtype: tuple[Any, Any] | None

Source code in src/easyreflectometry/analysis/bayesian.py
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
def _corner_contour_traces(
    go: Any,
    x_values: np.ndarray,
    y_values: np.ndarray,
) -> tuple[Any, Any] | None:
    """Build filled and line 2-D KDE contour traces for one pair panel.

    :param go: The ``plotly.graph_objects`` module.
    :param x_values: Posterior samples for the x-axis parameter.
    :type x_values: np.ndarray
    :param y_values: Posterior samples for the y-axis parameter.
    :type y_values: np.ndarray
    :return: ``(fill_trace, line_trace)``, or ``None`` if no smooth surface
        could be estimated.
    :rtype: tuple[Any, Any] | None
    """
    surface = _posterior_density_surface(x_values, y_values)
    if surface is None:
        return None
    x_grid, y_grid, density = surface

    fill_colorscale, line_colorscale = _posterior_contour_colorscales(x_values, y_values)
    density_max = float(np.max(density))
    contour_start = density_max * 0.20
    contour_end = density_max * 0.95
    contour_size = density_max * 0.15

    fill_density = np.array(density, copy=True)
    fill_density[fill_density < contour_start] = np.nan
    fill_trace = go.Contour(
        x=x_grid,
        y=y_grid,
        z=fill_density,
        contours=dict(
            coloring='fill',
            showlabels=False,
            showlines=False,
            start=contour_start,
            end=contour_end,
            size=contour_size,
        ),
        colorscale=fill_colorscale,
        zmin=contour_start,
        zmax=contour_end,
        connectgaps=False,
        hoverinfo='skip',
        showscale=False,
        zorder=1,
    )
    line_trace = go.Contour(
        x=x_grid,
        y=y_grid,
        z=density,
        contours=dict(
            coloring='lines',
            showlabels=False,
            start=contour_start,
            end=contour_end,
            size=contour_size,
        ),
        colorscale=line_colorscale,
        zmin=contour_start,
        zmax=contour_end,
        line=dict(width=0.9),
        hoverinfo='skip',
        showscale=False,
        zorder=2,
    )
    return fill_trace, line_trace

plot_corner(draws, param_names)

Build a parameter-correlation corner plot as a Plotly Figure.

Smooth Gaussian-KDE marginal densities on the diagonal, a posterior scatter overlay with filled 2-D KDE contours on the lower triangle, and a hidden upper triangle. Contours are coloured blue for positively correlated pairs and red for negatively correlated ones. This mirrors the posterior pair plot in easydiffraction. Requires plotly (and scipy for the KDE smoothing; without it the diagonal falls back to a histogram and the contours are omitted).

:param draws: Posterior samples, shape (n_samples, n_params) or (n_chains, n_draws, n_params). :type draws: np.ndarray :param param_names: Parameter names (one per column). :type param_names: list[str] :return: Plotly Figure.

Source code in src/easyreflectometry/analysis/bayesian.py
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
def plot_corner(draws: np.ndarray, param_names: list[str]) -> Any:
    """Build a parameter-correlation corner plot as a Plotly Figure.

    Smooth Gaussian-KDE marginal densities on the diagonal, a posterior
    scatter overlay with filled 2-D KDE contours on the lower triangle, and a
    hidden upper triangle.  Contours are coloured blue for positively
    correlated pairs and red for negatively correlated ones.  This mirrors the
    posterior pair plot in ``easydiffraction``.  Requires ``plotly`` (and
    ``scipy`` for the KDE smoothing; without it the diagonal falls back to a
    histogram and the contours are omitted).

    :param draws: Posterior samples, shape ``(n_samples, n_params)`` or
        ``(n_chains, n_draws, n_params)``.
    :type draws: np.ndarray
    :param param_names: Parameter names (one per column).
    :type param_names: list[str]
    :return: Plotly Figure.
    """
    _require_plotly()
    import plotly.graph_objects as go
    from plotly.subplots import make_subplots

    draws = np.asarray(draws)
    if draws.ndim == 3:
        draws = draws.reshape(-1, draws.shape[-1])
    n_params = len(param_names)

    wrapped_labels = [_wrap_pair_label(name) for name in param_names]

    # Full draws drive the smooth KDE surfaces; the scatter overlay is thinned
    # so large posteriors stay responsive to render and pan.
    n_samples = draws.shape[0]
    if n_samples > _POSTERIOR_PAIR_SCATTER_MAX_POINTS:
        stride = max(1, n_samples // _POSTERIOR_PAIR_SCATTER_MAX_POINTS)
        scatter_draws = draws[::stride]
    else:
        scatter_draws = draws

    fig = make_subplots(
        rows=n_params,
        cols=n_params,
        horizontal_spacing=0.02,
        vertical_spacing=0.02,
    )

    # Track which trace types have already been added to the legend.
    legend_shown = {'marginal': False, 'scatter': False, 'contour': False}

    for row in range(n_params):
        for col in range(n_params):
            r, c = row + 1, col + 1
            if col > row:
                fig.update_xaxes(visible=False, row=r, col=c)
                fig.update_yaxes(visible=False, row=r, col=c)
                continue
            if col == row:
                _add_corner_marginal(
                    fig,
                    go,
                    values=draws[:, row],
                    row=r,
                    col=c,
                    show_legend=not legend_shown['marginal'],
                )
                legend_shown['marginal'] = True
            else:
                fig.add_trace(
                    go.Scatter(
                        x=scatter_draws[:, col],
                        y=scatter_draws[:, row],
                        mode='markers',
                        marker=dict(size=4, color=_POSTERIOR_PAIR_SCATTER_COLOR),
                        name='Posterior samples',
                        legendgroup='scatter',
                        showlegend=not legend_shown['scatter'],
                        hoverinfo='skip',
                        zorder=0,
                    ),
                    row=r,
                    col=c,
                )
                legend_shown['scatter'] = True
                contour_traces = _corner_contour_traces(go, draws[:, col], draws[:, row])
                if contour_traces is not None:
                    fill_trace, line_trace = contour_traces
                    fill_trace.name = 'Posterior contours'
                    fill_trace.legendgroup = 'contour'
                    fill_trace.showlegend = not legend_shown['contour']
                    line_trace.legendgroup = 'contour'
                    line_trace.showlegend = False
                    fig.add_trace(fill_trace, row=r, col=c)
                    fig.add_trace(line_trace, row=r, col=c)
                    legend_shown['contour'] = True

    # Axis labels: outer edges only (bottom row x-axes, leftmost column y-axes,
    # including the top-left diagonal cell so its parameter is identifiable).
    for i, label in enumerate(wrapped_labels):
        fig.update_xaxes(title_text=label, title_font=dict(size=10), row=n_params, col=i + 1)
        fig.update_yaxes(title_text=label, title_font=dict(size=10), row=i + 1, col=1)
    # Diagonal y-axes are probability density — hide their tick labels (except the
    # top-left, where ticks would be the only cue about the density scale).
    for i in range(1, n_params):
        fig.update_yaxes(showticklabels=False, row=i + 1, col=i + 1)

    fig.update_layout(
        height=max(450, 180 * n_params),
        width=max(550, 180 * n_params + 140),
        showlegend=True,
        legend=dict(
            orientation='v',
            yanchor='top',
            y=1.0,
            xanchor='left',
            x=1.02,
            font=dict(size=11),
            itemsizing='constant',
        ),
        plot_bgcolor='white',
        margin=dict(l=80, r=160, t=30, b=60),
    )
    fig.update_xaxes(showgrid=False, zeroline=False, ticks='outside')
    fig.update_yaxes(showgrid=False, zeroline=False, ticks='outside')
    return fig

plot_trace(draws, param_names, return_figure=False, **kwargs)

Plot MCMC trace plot.

When return_figure is True a Plotly Figure is returned instead of being displayed inline; the caller is responsible for rendering it. This requires the plotly package.

:param draws: Posterior samples, shape (n_chains, n_draws, n_params) or (n_draws, n_params). :type draws: np.ndarray :param param_names: Parameter names (one per column). :type param_names: list[str] :param return_figure: Return a Plotly Figure instead of rendering inline. :type return_figure: bool :param kwargs: Additional keyword arguments passed to arviz.plot_trace when return_figure is False. :return: Plotly Figure when return_figure is True, otherwise None.

Source code in src/easyreflectometry/analysis/bayesian.py
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
def plot_trace(draws: np.ndarray, param_names: list[str], return_figure: bool = False, **kwargs) -> Any:
    """Plot MCMC trace plot.

    When *return_figure* is ``True`` a Plotly ``Figure`` is returned instead of
    being displayed inline; the caller is responsible for rendering it.  This
    requires the ``plotly`` package.

    :param draws: Posterior samples, shape ``(n_chains, n_draws, n_params)`` or
        ``(n_draws, n_params)``.
    :type draws: np.ndarray
    :param param_names: Parameter names (one per column).
    :type param_names: list[str]
    :param return_figure: Return a Plotly Figure instead of rendering inline.
    :type return_figure: bool
    :param kwargs: Additional keyword arguments passed to ``arviz.plot_trace``
        when *return_figure* is ``False``.
    :return: Plotly Figure when *return_figure* is ``True``, otherwise ``None``.
    """
    draws = np.asarray(draws)
    if draws.ndim == 2:
        draws = draws[np.newaxis, ...]  # (1, n_draws, n_params)
    # draws shape: (n_chains, n_draws, n_params)

    if return_figure:
        try:
            import plotly.graph_objects as go
            from plotly.subplots import make_subplots
        except ImportError:
            warnings.warn(
                'The ``plotly`` library is required to build the trace figure. Install it with ``pip install plotly``.',
                UserWarning,
                stacklevel=2,
            )
            return None

        n_params = len(param_names)
        n_chains = draws.shape[0]
        fig = make_subplots(
            rows=n_params,
            cols=2,
            column_widths=[0.6, 0.4],
        )
        colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728']
        for i, name in enumerate(param_names):
            row = i + 1
            show_legend = i == 0
            for c in range(n_chains):
                chain_draws = draws[c, :, i]
                color = colors[c % len(colors)]
                fig.add_trace(
                    go.Scatter(
                        y=chain_draws,
                        mode='lines',
                        line=dict(color=color, width=1),
                        name=f'chain {c}',
                        legendgroup=f'chain {c}',
                        showlegend=show_legend,
                    ),
                    row=row,
                    col=1,
                )
                fig.add_trace(
                    go.Histogram(
                        x=chain_draws,
                        marker_color=color,
                        opacity=0.6,
                        name=f'chain {c}',
                        legendgroup=f'chain {c}',
                        showlegend=show_legend,
                        nbinsx=40,
                    ),
                    row=row,
                    col=2,
                )
            fig.update_yaxes(title_text=name, title_font=dict(size=10), row=row, col=1)
            fig.update_xaxes(title_text=name, title_font=dict(size=10), row=row, col=2)
            fig.update_yaxes(title_text='Count', title_font=dict(size=10), row=row, col=2)
        fig.update_xaxes(title_text='Draw index', title_font=dict(size=10), row=n_params, col=1)
        fig.update_layout(
            height=max(300, 200 * n_params),
            barmode='overlay',
            legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1, font=dict(size=10)),
        )
        return fig

    _require_arviz()
    idata = _to_arviz_data(draws, param_names)
    _arviz.plot_trace(idata, var_names=param_names, **kwargs)
    return None

_posterior_marginal_y_range(values, density_curve)

Return a (0, max) y-axis range covering histogram and KDE density.

The range spans the larger of the histogram (probability-density normalised) and smooth-KDE peaks so credible-interval bands and reference lines, which are drawn as full-height traces, reach the top of the panel.

:param values: Posterior samples for a single parameter. :type values: np.ndarray :param density_curve: (grid, density) from :func:_posterior_density_curve, or None. :type density_curve: tuple[np.ndarray, np.ndarray] | None :return: (0.0, padded_max) range, or None if no density is available. :rtype: tuple[float, float] | None

Source code in src/easyreflectometry/analysis/bayesian.py
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
def _posterior_marginal_y_range(
    values: np.ndarray,
    density_curve: tuple[np.ndarray, np.ndarray] | None,
) -> tuple[float, float] | None:
    """Return a ``(0, max)`` y-axis range covering histogram and KDE density.

    The range spans the larger of the histogram (probability-density normalised)
    and smooth-KDE peaks so credible-interval bands and reference lines, which
    are drawn as full-height traces, reach the top of the panel.

    :param values: Posterior samples for a single parameter.
    :type values: np.ndarray
    :param density_curve: ``(grid, density)`` from :func:`_posterior_density_curve`, or ``None``.
    :type density_curve: tuple[np.ndarray, np.ndarray] | None
    :return: ``(0.0, padded_max)`` range, or ``None`` if no density is available.
    :rtype: tuple[float, float] | None
    """
    data = np.asarray(values, dtype=float)
    data = data[np.isfinite(data)]
    maxima: list[float] = []
    if data.size:
        hist, _ = np.histogram(data, bins=40, density=True)
        if hist.size:
            maxima.append(float(np.max(hist)))
    if density_curve is not None:
        maxima.append(float(np.max(density_curve[1])))
    if not maxima:
        return None
    y_max = max(maxima)
    if y_max <= 0:
        return None
    return 0.0, y_max * 1.08

_posterior_interval_band_trace(go, *, x0, x1, y_range, name, color, show_legend)

Return a filled rectangle marking a credible interval.

:param go: The plotly.graph_objects module. :param x0: Lower interval bound. :type x0: float :param x1: Upper interval bound. :type x1: float :param y_range: Panel y-axis range the band should span. :type y_range: tuple[float, float] :param name: Legend/trace name. :type name: str :param color: Fill colour. :type color: str :param show_legend: Whether this trace adds the legend entry. :type show_legend: bool :return: A Plotly Scatter trace.

Source code in src/easyreflectometry/analysis/bayesian.py
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
def _posterior_interval_band_trace(
    go: Any,
    *,
    x0: float,
    x1: float,
    y_range: tuple[float, float],
    name: str,
    color: str,
    show_legend: bool,
) -> Any:
    """Return a filled rectangle marking a credible interval.

    :param go: The ``plotly.graph_objects`` module.
    :param x0: Lower interval bound.
    :type x0: float
    :param x1: Upper interval bound.
    :type x1: float
    :param y_range: Panel y-axis range the band should span.
    :type y_range: tuple[float, float]
    :param name: Legend/trace name.
    :type name: str
    :param color: Fill colour.
    :type color: str
    :param show_legend: Whether this trace adds the legend entry.
    :type show_legend: bool
    :return: A Plotly Scatter trace.
    """
    return go.Scatter(
        x=[x0, x1, x1, x0, x0],
        y=[y_range[0], y_range[0], y_range[1], y_range[1], y_range[0]],
        mode='lines',
        fill='toself',
        fillcolor=color,
        line=dict(color=color, width=0),
        name=name,
        legendgroup=name,
        showlegend=show_legend,
        hoverinfo='skip',
    )

_posterior_reference_line_trace(go, *, x_value, y_range, name, color, dash, show_legend)

Return a vertical reference line for a posterior marginal panel.

:param go: The plotly.graph_objects module. :param x_value: Parameter value at which to draw the line. :type x_value: float :param y_range: Panel y-axis range the line should span. :type y_range: tuple[float, float] :param name: Legend/trace name. :type name: str :param color: Line colour. :type color: str :param dash: Plotly dash style (e.g. 'dash', 'dot'). :type dash: str :param show_legend: Whether this trace adds the legend entry. :type show_legend: bool :return: A Plotly Scatter trace.

Source code in src/easyreflectometry/analysis/bayesian.py
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
def _posterior_reference_line_trace(
    go: Any,
    *,
    x_value: float,
    y_range: tuple[float, float],
    name: str,
    color: str,
    dash: str,
    show_legend: bool,
) -> Any:
    """Return a vertical reference line for a posterior marginal panel.

    :param go: The ``plotly.graph_objects`` module.
    :param x_value: Parameter value at which to draw the line.
    :type x_value: float
    :param y_range: Panel y-axis range the line should span.
    :type y_range: tuple[float, float]
    :param name: Legend/trace name.
    :type name: str
    :param color: Line colour.
    :type color: str
    :param dash: Plotly dash style (e.g. ``'dash'``, ``'dot'``).
    :type dash: str
    :param show_legend: Whether this trace adds the legend entry.
    :type show_legend: bool
    :return: A Plotly Scatter trace.
    """
    return go.Scatter(
        x=[x_value, x_value],
        y=[y_range[0], y_range[1]],
        mode='lines',
        line=dict(color=color, width=2, dash=dash),
        name=name,
        legendgroup=name,
        showlegend=show_legend,
        hovertemplate=f'{name}: %{{x:.4f}}<extra></extra>',
    )

plot_distribution(draws, param_names, logp=None, return_figure=False, **kwargs)

Plot marginal posterior distributions for each parameter.

Each panel overlays, mirroring easydiffraction's project.display.posterior.distribution():

  • the posterior histogram (probability-density normalised),
  • a smooth Gaussian-KDE marginal density curve (requires scipy; omitted if unavailable or the samples are degenerate),
  • a shaded 95% credible interval (equal-tailed 2.5/97.5 percentiles),
  • a dashed median line, and
  • a dotted best posterior sample line when logp is supplied.

When return_figure is True a Plotly Figure is returned.

:param draws: Posterior samples, shape (n_samples, n_params) or (n_chains, n_draws, n_params). :type draws: np.ndarray :param param_names: Parameter names (one per column). :type param_names: list[str] :param logp: Log-posterior values, shape (n_samples,). When given, the draw with the largest value marks the best posterior sample. :type logp: np.ndarray | None :param return_figure: Return a Plotly Figure instead of rendering inline. :type return_figure: bool :param kwargs: Additional keyword arguments (currently unused). :return: Plotly Figure when return_figure is True, otherwise None.

Source code in src/easyreflectometry/analysis/bayesian.py
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
def plot_distribution(
    draws: np.ndarray,
    param_names: list[str],
    logp: np.ndarray | None = None,
    return_figure: bool = False,
    **kwargs,
) -> Any:
    """Plot marginal posterior distributions for each parameter.

    Each panel overlays, mirroring ``easydiffraction``'s
    ``project.display.posterior.distribution()``:

    * the posterior **histogram** (probability-density normalised),
    * a smooth **Gaussian-KDE marginal** density curve (requires ``scipy``;
      omitted if unavailable or the samples are degenerate),
    * a shaded **95% credible interval** (equal-tailed 2.5/97.5 percentiles),
    * a dashed **median** line, and
    * a dotted **best posterior sample** line when *logp* is supplied.

    When *return_figure* is ``True`` a Plotly ``Figure`` is returned.

    :param draws: Posterior samples, shape ``(n_samples, n_params)`` or
        ``(n_chains, n_draws, n_params)``.
    :type draws: np.ndarray
    :param param_names: Parameter names (one per column).
    :type param_names: list[str]
    :param logp: Log-posterior values, shape ``(n_samples,)``. When given, the
        draw with the largest value marks the best posterior sample.
    :type logp: np.ndarray | None
    :param return_figure: Return a Plotly Figure instead of rendering inline.
    :type return_figure: bool
    :param kwargs: Additional keyword arguments (currently unused).
    :return: Plotly Figure when *return_figure* is ``True``, otherwise ``None``.
    """
    draws = np.asarray(draws)
    if draws.ndim == 3:
        draws = draws.reshape(-1, draws.shape[-1])
    # draws shape: (n_samples, n_params)

    best_index: int | None = None
    if logp is not None:
        logp = np.asarray(logp).reshape(-1)
        if logp.size == draws.shape[0] and np.any(np.isfinite(logp)):
            best_index = int(np.nanargmax(logp))

    if not return_figure:
        return None

    try:
        import plotly.graph_objects as go
        from plotly.subplots import make_subplots
    except ImportError:
        warnings.warn(
            'The ``plotly`` library is required to build the distribution figure. Install it with ``pip install plotly``.',
            UserWarning,
            stacklevel=2,
        )
        return None

    n_params = len(param_names)
    n_cols = min(3, n_params)
    n_rows = (n_params + n_cols - 1) // n_cols
    fig = make_subplots(rows=n_rows, cols=n_cols)

    # Each trace type contributes a single shared legend entry.
    legend_shown = {
        'histogram': False,
        'marginal': False,
        'interval': False,
        'median': False,
        'best': False,
    }

    for i, name in enumerate(param_names):
        row = i // n_cols + 1
        col = i % n_cols + 1
        values = draws[:, i]

        density_curve = _posterior_density_curve(values)
        y_range = _posterior_marginal_y_range(values, density_curve)
        lower, upper = (float(v) for v in np.percentile(values, [2.5, 97.5]))

        # Credible-interval band first so it sits behind the density traces.
        if y_range is not None:
            fig.add_trace(
                _posterior_interval_band_trace(
                    go,
                    x0=lower,
                    x1=upper,
                    y_range=y_range,
                    name='95% credible interval',
                    color=_POSTERIOR_INTERVAL_95_FILL_COLOR,
                    show_legend=not legend_shown['interval'],
                ),
                row=row,
                col=col,
            )
            legend_shown['interval'] = True

        fig.add_trace(
            go.Histogram(
                x=values,
                histnorm='probability density',
                marker=dict(
                    color=_POSTERIOR_HISTOGRAM_FILL_COLOR,
                    line=dict(color=_POSTERIOR_HISTOGRAM_LINE_COLOR, width=1),
                ),
                opacity=0.82,
                nbinsx=40,
                name='Posterior histogram',
                legendgroup='histogram',
                showlegend=not legend_shown['histogram'],
                hovertemplate='sample=%{x:.4f}<br>density: %{y:.2f}<extra></extra>',
            ),
            row=row,
            col=col,
        )
        legend_shown['histogram'] = True

        if density_curve is not None:
            grid, density = density_curve
            fig.add_trace(
                go.Scatter(
                    x=grid,
                    y=density,
                    mode='lines',
                    line=dict(color=_POSTERIOR_PAIR_MARGINAL_LINE_COLOR, width=2),
                    fill='tozeroy',
                    fillcolor=_POSTERIOR_PAIR_MARGINAL_FILL_COLOR,
                    name='Marginal density',
                    legendgroup='marginal',
                    showlegend=not legend_shown['marginal'],
                    hovertemplate=f'{name}: %{{x:.4f}}<br>density: %{{y:.4f}}<extra></extra>',
                ),
                row=row,
                col=col,
            )
            legend_shown['marginal'] = True

        if y_range is not None:
            fig.add_trace(
                _posterior_reference_line_trace(
                    go,
                    x_value=float(np.median(values)),
                    y_range=y_range,
                    name='Median',
                    color=_POSTERIOR_MEDIAN_LINE_COLOR,
                    dash='dash',
                    show_legend=not legend_shown['median'],
                ),
                row=row,
                col=col,
            )
            legend_shown['median'] = True
            if best_index is not None:
                fig.add_trace(
                    _posterior_reference_line_trace(
                        go,
                        x_value=float(values[best_index]),
                        y_range=y_range,
                        name='Best posterior sample',
                        color=_POSTERIOR_POINT_ESTIMATE_LINE_COLOR,
                        dash='dot',
                        show_legend=not legend_shown['best'],
                    ),
                    row=row,
                    col=col,
                )
                legend_shown['best'] = True
            fig.update_yaxes(range=list(y_range), row=row, col=col)

        if density_curve is not None:
            fig.update_xaxes(
                range=[float(density_curve[0][0]), float(density_curve[0][-1])],
                row=row,
                col=col,
            )
        fig.update_xaxes(title_text=name, title_font=dict(size=11), row=row, col=col)
        fig.update_yaxes(title_text='Probability density', title_font=dict(size=11), row=row, col=col)

    fig.update_layout(
        height=max(300, 250 * n_rows),
        barmode='overlay',
        showlegend=True,
        legend=dict(font=dict(size=10)),
        plot_bgcolor='white',
    )
    fig.update_xaxes(showgrid=False, zeroline=False, ticks='outside')
    fig.update_yaxes(showgrid=False, zeroline=False, ticks='outside')
    return fig

credible_intervals(draws, param_names, alpha=0.95)

Compute equal-tailed credible intervals for each parameter.

:param draws: Posterior samples, shape (n_samples, n_params). :type draws: np.ndarray :param param_names: Parameter names (one per column). :type param_names: list[str] :param alpha: Credible interval width (e.g. 0.95 for 95%). :type alpha: float :return: Dictionary mapping parameter name to (lower, upper). :rtype: dict

Source code in src/easyreflectometry/analysis/bayesian.py
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
def credible_intervals(
    draws: np.ndarray,
    param_names: list[str],
    alpha: float = 0.95,
) -> dict:
    """Compute equal-tailed credible intervals for each parameter.

    :param draws: Posterior samples, shape ``(n_samples, n_params)``.
    :type draws: np.ndarray
    :param param_names: Parameter names (one per column).
    :type param_names: list[str]
    :param alpha: Credible interval width (e.g. 0.95 for 95%).
    :type alpha: float
    :return: Dictionary mapping parameter name to ``(lower, upper)``.
    :rtype: dict
    """
    draws = np.asarray(draws)
    tail = (1.0 - alpha) / 2.0
    lo_pct = tail * 100
    hi_pct = (1.0 - tail) * 100
    result = {}
    for i, name in enumerate(param_names):
        col = draws[:, i]
        lo, hi = np.percentile(col, [lo_pct, hi_pct])
        result[name] = (float(lo), float(hi))
    return result

_save_parameter_state(model)

Save the current values and errors of all free parameters in a model.

:param model: A reflectometry model with get_parameters(). :return: Dictionary mapping unique_name to (value, error). :rtype: dict

Source code in src/easyreflectometry/analysis/bayesian.py
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
def _save_parameter_state(model) -> dict:
    """Save the current values and errors of all free parameters in a model.

    :param model: A reflectometry model with ``get_parameters()``.
    :return: Dictionary mapping ``unique_name`` to ``(value, error)``.
    :rtype: dict
    """
    state = {}
    for param in model.get_parameters():
        # Dependent parameters (constraints, `Model.total_thickness`) are derived
        # from the others: they cannot be written back, and restoring the
        # parameters they follow already restores them.
        if param.independent:
            state[param.unique_name] = (param.value, param.error)
    return state

_restore_parameter_state(model, state)

Restore parameter values and errors from a saved state.

:param model: A reflectometry model with get_parameters(). :param state: Dictionary mapping unique_name to (value, error).

Source code in src/easyreflectometry/analysis/bayesian.py
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
def _restore_parameter_state(model, state: dict) -> None:
    """Restore parameter values and errors from a saved state.

    :param model: A reflectometry model with ``get_parameters()``.
    :param state: Dictionary mapping ``unique_name`` to ``(value, error)``.
    """
    for param in model.get_parameters():
        if param.unique_name in state:
            param.value = state[param.unique_name][0]
            param.error = state[param.unique_name][1]

_apply_draw(model, draws, param_names, row)

Apply a single posterior draw to the model parameters.

Parameter lookup uses unique_name, matching the BUMPS names after removing the minimizer prefix, which avoids collisions when repeated models or multi-contrast fits contain similarly named parameters.

:param model: A reflectometry model with get_parameters(). :param draws: Posterior samples array. :param param_names: Parameter names matching the columns of draws. :param row: Index of the draw to apply.

Source code in src/easyreflectometry/analysis/bayesian.py
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
def _apply_draw(model, draws: np.ndarray, param_names: list[str], row: int) -> None:
    """Apply a single posterior draw to the model parameters.

    Parameter lookup uses ``unique_name``, matching the BUMPS names after
    removing the minimizer prefix, which avoids collisions when repeated models
    or multi-contrast fits contain similarly named parameters.

    :param model: A reflectometry model with ``get_parameters()``.
    :param draws: Posterior samples array.
    :param param_names: Parameter names matching the columns of ``draws``.
    :param row: Index of the draw to apply.
    """
    param_lookup = {p.unique_name: p for p in model.get_parameters()}
    for j, name in enumerate(param_names):
        if name in param_lookup:
            param_lookup[name].value = float(draws[row, j])

posterior_predictive_reflectivity(draws, param_names, model, q_values, n_samples=200)

Compute the posterior predictive reflectivity with credible intervals.

Parameter values and errors are saved before applying any posterior draw and restored in a finally block, so the model is not left mutated.

:param draws: Posterior samples, shape (n_samples_posterior, n_params). :type draws: np.ndarray :param param_names: Parameter names matching the columns of draws. :type param_names: list[str] :param model: A reflectometry model with interface.fit_func. :param q_values: Q values at which to evaluate reflectivity. :type q_values: np.ndarray :param n_samples: Number of posterior draws to use (last n_samples). :type n_samples: int :return: Tuple of (median, lower_95, upper_95) reflectivity arrays. :rtype: tuple[np.ndarray, np.ndarray, np.ndarray]

Source code in src/easyreflectometry/analysis/bayesian.py
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
def posterior_predictive_reflectivity(
    draws: np.ndarray,
    param_names: list[str],
    model,
    q_values: np.ndarray,
    n_samples: int = 200,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Compute the posterior predictive reflectivity with credible intervals.

    Parameter values and errors are saved before applying any posterior draw
    and restored in a ``finally`` block, so the model is not left mutated.

    :param draws: Posterior samples, shape ``(n_samples_posterior, n_params)``.
    :type draws: np.ndarray
    :param param_names: Parameter names matching the columns of ``draws``.
    :type param_names: list[str]
    :param model: A reflectometry model with ``interface.fit_func``.
    :param q_values: Q values at which to evaluate reflectivity.
    :type q_values: np.ndarray
    :param n_samples: Number of posterior draws to use (last ``n_samples``).
    :type n_samples: int
    :return: Tuple of ``(median, lower_95, upper_95)`` reflectivity arrays.
    :rtype: tuple[np.ndarray, np.ndarray, np.ndarray]
    """
    draws = np.asarray(draws)
    q_values = np.asarray(q_values)

    n_total = draws.shape[0]
    n_use = min(n_samples, n_total)
    sample_indices = range(n_total - n_use, n_total)

    saved_state = _save_parameter_state(model)
    try:
        reflectivity_samples = []
        for i in sample_indices:
            _apply_draw(model, draws, param_names, i)
            r_calc = model.interface.fit_func(q_values, model.unique_name)
            reflectivity_samples.append(np.asarray(r_calc))
    finally:
        _restore_parameter_state(model, saved_state)

    reflectivity_samples = np.array(reflectivity_samples)
    median = np.median(reflectivity_samples, axis=0)
    lower = np.percentile(reflectivity_samples, 2.5, axis=0)
    upper = np.percentile(reflectivity_samples, 97.5, axis=0)
    return median, lower, upper

posterior_predictive_sld_profile(draws, param_names, model, n_samples=200)

Compute the posterior predictive SLD profile with credible intervals.

Parameter values and errors are saved before applying any posterior draw and restored in a finally block, so the model is not left mutated.

:param draws: Posterior samples, shape (n_samples_posterior, n_params). :type draws: np.ndarray :param param_names: Parameter names matching the columns of draws. :type param_names: list[str] :param model: A reflectometry model with interface.sld_profile. :param n_samples: Number of posterior draws to use (last n_samples). :type n_samples: int :return: Tuple of (z, median, lower_95, upper_95) SLD profile arrays. :rtype: tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]

Source code in src/easyreflectometry/analysis/bayesian.py
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
def posterior_predictive_sld_profile(
    draws: np.ndarray,
    param_names: list[str],
    model,
    n_samples: int = 200,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Compute the posterior predictive SLD profile with credible intervals.

    Parameter values and errors are saved before applying any posterior draw
    and restored in a ``finally`` block, so the model is not left mutated.

    :param draws: Posterior samples, shape ``(n_samples_posterior, n_params)``.
    :type draws: np.ndarray
    :param param_names: Parameter names matching the columns of ``draws``.
    :type param_names: list[str]
    :param model: A reflectometry model with ``interface.sld_profile``.
    :param n_samples: Number of posterior draws to use (last ``n_samples``).
    :type n_samples: int
    :return: Tuple of ``(z, median, lower_95, upper_95)`` SLD profile arrays.
    :rtype: tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]
    """
    draws = np.asarray(draws)

    n_total = draws.shape[0]
    n_use = min(n_samples, n_total)
    sample_indices = range(n_total - n_use, n_total)

    saved_state = _save_parameter_state(model)
    try:
        sld_samples = []
        z_shared = None
        for i in sample_indices:
            _apply_draw(model, draws, param_names, i)
            z, sld = model.interface.sld_profile(model.unique_name)
            if z_shared is None:
                z_shared = np.asarray(z)
            sld_samples.append(np.asarray(sld))
    finally:
        _restore_parameter_state(model, saved_state)

    sld_samples = np.array(sld_samples)
    median = np.median(sld_samples, axis=0)
    lower = np.percentile(sld_samples, 2.5, axis=0)
    upper = np.percentile(sld_samples, 97.5, axis=0)
    return z_shared, median, lower, upper

_easyreflectometry_version()

Return the installed easyreflectometry version string.

Source code in src/easyreflectometry/analysis/bayesian.py
1267
1268
1269
1270
1271
1272
1273
1274
def _easyreflectometry_version() -> str:
    """Return the installed easyreflectometry version string."""
    try:
        from importlib.metadata import version as _v

        return _v('easyreflectometry')
    except Exception:
        return 'unknown'

_data_fingerprint(x_list, y_list, w_list)

Return a SHA-256 hex digest of concatenated (x|y|weights), or None.

Source code in src/easyreflectometry/analysis/bayesian.py
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
def _data_fingerprint(
    x_list: list[np.ndarray],
    y_list: list[np.ndarray],
    w_list: list[np.ndarray],
) -> str | None:
    """Return a SHA-256 hex digest of concatenated (x|y|weights), or None."""
    try:
        h = hashlib.sha256()
        for arr in list(x_list) + list(y_list) + list(w_list):
            h.update(np.ascontiguousarray(arr, dtype=np.float64).tobytes())
        return h.hexdigest()
    except Exception:
        return None

save_posterior(results, path)

Persist a sampling trace to disk using BUMPS' native state files.

Writes <path>-*.mc (BUMPS save_state output) plus a sidecar <path>.params.json holding parameter names and metadata so that :func:load_posterior can reconstruct a fully populated :class:PosteriorResults without re-deriving names from the model.

Note that save_state writes multiple files (one per DREAM component: chain, point, and stats). The path argument is a prefix; the actual files will be <path>-chain.mc, <path>-point.mc, and <path>-stats.mc.

:param results: The posterior results to persist. Must have a non-None sampler_state. :type results: PosteriorResults :param path: File path prefix. BUMPS appends its own suffixes. :type path: str :raises ValueError: If results.sampler_state is None. :raises TypeError: If results.sampler_state is not a BUMPS MCMCDraw object.

Source code in src/easyreflectometry/analysis/bayesian.py
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
def save_posterior(results: 'PosteriorResults', path: str) -> None:
    """Persist a sampling trace to disk using BUMPS' native state files.

    Writes ``<path>-*.mc`` (BUMPS ``save_state`` output) plus a sidecar
    ``<path>.params.json`` holding parameter names and metadata so that
    :func:`load_posterior` can reconstruct a fully populated
    :class:`PosteriorResults` without re-deriving names from the model.

    Note that ``save_state`` writes **multiple** files (one per DREAM
    component: chain, point, and stats).  The ``path`` argument is a
    prefix; the actual files will be ``<path>-chain.mc``,
    ``<path>-point.mc``, and ``<path>-stats.mc``.

    :param results: The posterior results to persist.  Must have a
        non-``None`` ``sampler_state``.
    :type results: PosteriorResults
    :param path: File path prefix.  BUMPS appends its own suffixes.
    :type path: str
    :raises ValueError: If ``results.sampler_state`` is ``None``.
    :raises TypeError: If ``results.sampler_state`` is not a BUMPS
        ``MCMCDraw`` object.
    """
    from bumps.dream.state import MCMCDraw
    from bumps.dream.state import save_state

    if results.sampler_state is None:
        raise ValueError(
            'This PosteriorResults has no sampler_state, so the chain '
            'cannot be saved or resumed. Re-run sample() and wrap the '
            "returned dict's 'state' value into PosteriorResults."
        )
    if not isinstance(results.sampler_state, MCMCDraw):
        raise TypeError(
            f'sampler_state must be a BUMPS MCMCDraw object, got '
            f'{type(results.sampler_state).__name__}. Only BUMPS DREAM '
            'traces can be persisted with save_posterior.'
        )

    save_state(results.sampler_state, path)

    # Write the sidecar JSON
    sidecar = {
        'schema_version': _SIDECAR_SCHEMA_VERSION,
        'param_names': results.param_names,
        'easyreflectometry_version': _easyreflectometry_version(),
    }
    with open(f'{path}.params.json', 'w') as f:
        json.dump(sidecar, f, indent=2)

load_posterior(path, skip=0)

Reload a trace saved by :func:save_posterior into a :class:PosteriorResults.

The returned object's sampler_state can be fed back into the core Sampler (via Sampler.load_state(...) / Sampler.extend(...)) to extend the chain.

:param path: File path prefix used in :func:save_posterior. :type path: str :param skip: Discard the first skip saved generations on load, forwarded to bumps.dream.state.load_state(path, skip=skip). Useful for trimming additional burn-in without re-sampling. :type skip: int :return: A fully populated :class:PosteriorResults. :rtype: PosteriorResults

Source code in src/easyreflectometry/analysis/bayesian.py
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
def load_posterior(path: str, skip: int = 0) -> 'PosteriorResults':
    """Reload a trace saved by :func:`save_posterior` into a
    :class:`PosteriorResults`.

    The returned object's ``sampler_state`` can be fed back into the core
    ``Sampler`` (via ``Sampler.load_state(...)`` / ``Sampler.extend(...)``)
    to extend the chain.

    :param path: File path prefix used in :func:`save_posterior`.
    :type path: str
    :param skip: Discard the first ``skip`` saved generations on load,
        forwarded to ``bumps.dream.state.load_state(path, skip=skip)``.
        Useful for trimming additional burn-in without re-sampling.
    :type skip: int
    :return: A fully populated :class:`PosteriorResults`.
    :rtype: PosteriorResults
    """
    from bumps.dream.state import load_state

    state = load_state(path, skip=skip)
    _draw = state.draw()
    draws = _draw.points
    logp = _draw.logp  # .logp is on the Draw object, NOT state.logp

    # Restore param_names: prefer the sidecar; fall back to state.labels
    param_names: list[str] | None = None
    try:
        with open(f'{path}.params.json', 'r') as f:
            sidecar = json.load(f)
        if sidecar.get('schema_version') == _SIDECAR_SCHEMA_VERSION:
            param_names = sidecar.get('param_names')
    except (FileNotFoundError, json.JSONDecodeError, KeyError):
        pass

    if param_names is None:
        # Fallback: strip BUMPS 'p' prefix from state.labels
        param_names = [
            lbl[len(MINIMIZER_PARAMETER_PREFIX) :] if lbl.startswith(MINIMIZER_PARAMETER_PREFIX) else lbl
            for lbl in state.labels
        ]

    return PosteriorResults(
        draws=draws,
        param_names=param_names,
        logp=logp,
        sampler_state=state,
    )