Source code for tseda.visualization.decomposition_plots

"""
Decomposition visualisation plots.

Functions
---------
plot_decomposition        — 4-panel observed/trend/seasonal/residual
plot_strength_radar       — radar chart of decomposition quality metrics
plot_residual_diagnostics — residual distribution + ACF
"""
from __future__ import annotations

from typing import Optional, Tuple

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
from matplotlib.axes import Axes

from tseda.decomposition.classical import DecompositionResult
from tseda.visualization.base import PALETTE, _make_fig_ax, _set_title

__all__ = ["plot_decomposition", "plot_strength_radar", "plot_residual_diagnostics"]


[docs] def plot_decomposition( result: DecompositionResult, *, figsize: Optional[Tuple[float, float]] = None, title: Optional[str] = None, ) -> Figure: """Four-panel decomposition plot (observed / trend / seasonal / residual). Parameters ---------- result : DecompositionResult figsize, title : optional Returns ------- matplotlib.figure.Figure """ fig, axes = plt.subplots(4, 1, figsize=figsize or (12, 10), sharex=True) idx = result.original.index panels = [ (result.original.values, "Observed", PALETTE["dark"]), (result.trend.values, "Trend", PALETTE["trend"]), (result.seasonal.values, "Seasonal", PALETTE["seasonal"]), (result.residual.values, "Residual", PALETTE["neutral"]), ] for ax, (vals, label, color) in zip(axes, panels): ax.plot(idx, vals, color=color, linewidth=1.0) ax.set_ylabel(label, fontsize=9) if label == "Residual": ax.axhline(0, color=PALETTE["anomaly"], linewidth=0.8, linestyle="--", alpha=0.6) axes[-1].set_xlabel("Time") fig.suptitle( title or f"Decomposition ({result.method}, {result.model})", ) fig.tight_layout() return fig
[docs] def plot_strength_radar( result: DecompositionResult, *, ax: Optional[Axes] = None, title: Optional[str] = None, figsize: Optional[Tuple[float, float]] = None, ) -> Figure: """Radar chart of four decomposition strength metrics. Metrics (all in [0, 1]): * Trend strength * Seasonal strength * Signal (1 − residual variance / total variance) * Smoothness (trend variance / original variance) Parameters ---------- result : DecompositionResult ax, title, figsize : optional Returns ------- matplotlib.figure.Figure """ res_vals = result.residual.values orig_vals = result.original.values trend_vals = result.trend.values var_orig = float(np.nanvar(orig_vals)) var_res = float(np.nanvar(res_vals)) var_trend = float(np.nanvar(trend_vals)) signal = float(np.clip(1.0 - var_res / max(var_orig, 1e-15), 0.0, 1.0)) smoothness = float(np.clip(var_trend / max(var_orig, 1e-15), 0.0, 1.0)) labels = ["Trend\nstrength", "Seasonal\nstrength", "Signal", "Smoothness"] values = [ float(np.clip(result.strength_trend, 0.0, 1.0)), float(np.clip(result.strength_seasonal, 0.0, 1.0)), signal, smoothness, ] n = len(labels) angles = np.linspace(0, 2 * np.pi, n, endpoint=False).tolist() values_closed = values + [values[0]] angles_closed = angles + [angles[0]] if ax is None: fig = plt.figure(figsize=figsize or (6, 6)) ax = fig.add_subplot(111, polar=True) else: fig = ax.get_figure() ax.plot(angles_closed, values_closed, color=PALETTE["accent"], linewidth=2, marker="o", markersize=6) ax.fill(angles_closed, values_closed, color=PALETTE["accent"], alpha=0.25) ax.set_xticks(angles) ax.set_xticklabels(labels, fontsize=9) ax.set_ylim(0, 1) ax.set_yticks([0.25, 0.5, 0.75, 1.0]) ax.set_yticklabels(["0.25", "0.5", "0.75", "1.0"], fontsize=7) ax.set_title(title or "Decomposition strength radar", pad=20) fig.tight_layout() return fig
[docs] def plot_residual_diagnostics( result: DecompositionResult, *, figsize: Optional[Tuple[float, float]] = None, title: Optional[str] = None, ) -> Figure: """Two-panel residual diagnostics: histogram+KDE (left) and ACF (right). Parameters ---------- result : DecompositionResult figsize, title : optional Returns ------- matplotlib.figure.Figure """ from scipy.stats import gaussian_kde from tseda.core.timeseries import TimeSeries from tseda.statistics.autocorrelation import AutocorrelationAnalyzer fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize or (12, 4)) res = result.residual.values[~np.isnan(result.residual.values)] n = len(res) # Panel 1: histogram + KDE if n > 0: ax1.hist(res, bins=min(30, n // 3 + 1), density=True, color=PALETTE["neutral"], alpha=0.5, edgecolor="white") if n >= 3: x_grid = np.linspace(res.min(), res.max(), 200) kde = gaussian_kde(res) ax1.plot(x_grid, kde(x_grid), color=PALETTE["dark"], linewidth=2) ax1.axvline(0, color=PALETTE["anomaly"], linewidth=1.0, linestyle="--") ax1.set_xlabel("Residual value") ax1.set_ylabel("Density") ax1.set_title("Residual distribution") # Panel 2: ACF if n >= 10: ts_res = TimeSeries( result.residual.values, index=result.residual.index, ) acf_r = AutocorrelationAnalyzer().analyze(ts_res, lags=min(40, n // 2 - 1)) lags = acf_r.lags ci = float(acf_r.conf_upper[1]) ax2.axhline(0, color=PALETTE["neutral"], linewidth=0.8) ax2.axhline(ci, color=PALETTE["accent"], linewidth=1.0, linestyle="--", alpha=0.8) ax2.axhline(-ci, color=PALETTE["accent"], linewidth=1.0, linestyle="--", alpha=0.8) colors = [ PALETTE["anomaly"] if abs(v) > ci else PALETTE["dark"] for v in acf_r.acf ] for k, (v, c) in enumerate(zip(acf_r.acf, colors)): ax2.vlines(k, 0, v, colors=c, linewidth=1.2) ax2.plot(k, v, "o", color=c, markersize=3) else: ax2.text(0.5, 0.5, "Too few residuals for ACF", transform=ax2.transAxes, ha="center") ax2.set_xlabel("Lag") ax2.set_ylabel("ACF") ax2.set_title("Residual ACF") fig.suptitle(title or f"Residual diagnostics ({result.method})") fig.tight_layout() return fig