02-decompose

notebooks/02-decompose.py

#0 jc.step

Decompose — trend, seasonality, residuals

Loads the Parquet artifact from 01-explore.py, splits the series into three components using classical additive decomposition:

[ y_t = T_t + S_t + R_t ]

where the trend T is a centered 7-day rolling mean, the seasonality S is the average detrended value by weekday, and the residual R is whatever's left. All three are persisted as artifacts.

#1 loaded jc.load ok 389 ms
import pandas as pd

import jellycell.api as jc

df: pd.DataFrame = jc.load("artifacts/daily.parquet")
df = df.set_index("date")
print(df.head())
                 value
date                  
2025-01-01  100.914151
2025-01-02   96.934993
2025-01-03  102.361244
2025-01-04  107.986529
2025-01-05   99.366675
#2 trend deps: loaded ok 5 ms
trend = df["value"].rolling(7, center=True).mean()
print(f"trend span: {trend.first_valid_index().date()} \u2013 {trend.last_valid_index().date()}")
trend span: 2025-01-04 – 2025-12-28
#3 seasonality deps: trend ok 8 ms
detrended = df["value"] - trend
by_dow = detrended.groupby(detrended.index.dayofweek).mean()
seasonality = pd.Series(by_dow.loc[df.index.dayofweek].values, index=df.index)
print("mean seasonal effect by weekday:")
print(
    pd.DataFrame(
        {"dow": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], "effect": by_dow.round(2).values}
    ).to_string(index=False)
)
mean seasonal effect by weekday:
dow  effect
Mon   -3.29
Tue   -0.93
Wed   -0.93
Thu   -0.83
Fri   -1.11
Sat    4.28
Sun    2.80
#4 residuals deps: trend seasonality ok 4 ms
residuals = df["value"] - trend - seasonality
print(f"residual std: {residuals.std():.3f}")
print(f"residual mean: {residuals.mean():.3f} (should be near 0)")
residual std: 2.618
residual mean: -0.000 (should be near 0)
#5 persist deps: trend seasonality residuals ok 8 ms
decomposed = pd.DataFrame(
    {
        "observed": df["value"],
        "trend": trend,
        "seasonal": seasonality,
        "residual": residuals,
    }
).reset_index()
jc.save(decomposed, "artifacts/decomposed.parquet")
PosixPath('/Users/blaise/Desktop/blaise-oss/jellycell/examples/timeseries/artifacts/decomposed.parquet')
#6 decomp_plot jc.figure deps: persist ok 390 ms
import matplotlib.pyplot as plt

fig, axes = plt.subplots(4, 1, figsize=(10, 9), sharex=True)
plots = [
    ("observed", df["value"], "#1a1a1a"),
    ("trend", trend, "#4f46e5"),
    ("seasonal", seasonality, "#0891b2"),
    ("residual", residuals, "#dc2626"),
]
for ax, (label, series, color) in zip(axes, plots, strict=True):
    ax.plot(series.index, series.values, linewidth=1.1, color=color)
    ax.set_ylabel(label)
    ax.grid(alpha=0.3)
axes[0].set_title("Additive decomposition")
axes[-1].set_xlabel("Date")
fig.tight_layout()
jc.figure(path="artifacts/decomposition.png", fig=fig)
PosixPath('/Users/blaise/Desktop/blaise-oss/jellycell/examples/timeseries/artifacts/decomposition.png')
<Figure size 1000x900 with 4 Axes>