{
 "nbformat": 4,
 "nbformat_minor": 5,
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.11"
  },
  "colab": {
   "provenance": []
  }
 },
 "cells": [
  {
   "cell_type": "markdown",
   "id": "cell-0000",
   "metadata": {},
   "source": [
    "# Time Series Components\n",
    "\n",
    "Decompose a time series into trend, seasonality, and residual — tell additive from multiplicative patterns, and meet stationarity.\n",
    "\n",
    "*Part of the free [Machine Learning](https://ramadnsyh.dev/courses/machine-learning) course by [Muhammad Ramadiansyah](https://ramadnsyh.dev). This notebook is generated from the interactive lesson — [read it online](https://ramadnsyh.dev/courses/machine-learning/time-series-components).*"
   ]
  },
  {
   "cell_type": "code",
   "id": "cell-0001",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "%pip install -q statsmodels"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0002",
   "metadata": {},
   "source": [
    "Every dataset so far treated rows as interchangeable — shuffle them and\n",
    "nothing changes. Time series data breaks that assumption: each row has a\n",
    "timestamp, and the *order* carries information. Monthly airline passengers,\n",
    "daily temperatures, hourly server load — to forecast them you first need to\n",
    "see what they're made of. This lesson teaches classical decomposition:\n",
    "splitting a series into trend, seasonality, and residual, and knowing when\n",
    "those pieces add and when they multiply."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0003",
   "metadata": {},
   "source": [
    "## What makes time series special\n",
    "\n",
    "Two things separate a time series from an ordinary table. First,\n",
    "**order matters**: the value in March 1955 sits between February and April\n",
    "1955, and swapping rows destroys the phenomenon you're studying. Second,\n",
    "**observations are correlated with their own past** — a property called\n",
    "**autocorrelation**. This month's passenger count looks a lot like last\n",
    "month's, and a lot like the same month last year. That correlation is bad\n",
    "news for the i.i.d. assumptions behind standard cross-validation (much more\n",
    "on that next lesson), but it's also the *entire reason forecasting works*:\n",
    "if the past said nothing about the future, there would be nothing to model.\n",
    "\n",
    "The classical view says an observed series `y(t)` is built from a few\n",
    "interpretable components:\n",
    "\n",
    "- **Trend** — the long-term direction of the mean (growth, decline, or flat)\n",
    "- **Seasonality** — a repeating pattern with a *fixed, known period*\n",
    "  (12 months, 7 days, 24 hours)\n",
    "- **Cycles** — longer up-and-down swings *without* a fixed period, like\n",
    "  business cycles; harder to model, often lumped in with trend\n",
    "- **Residual (noise)** — whatever irregular fluctuation is left over\n",
    "\n",
    "Build one yourself. Mix a trend, a seasonal wave, and noise below, then\n",
    "switch to the Decompose view and watch the machine take your recipe apart:"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0004",
   "metadata": {},
   "source": [
    "> 🎛️ **Interactive demo** — this section has a hands-on visualization in the web version of this lesson: [open it here](https://ramadnsyh.dev/courses/machine-learning/time-series-components)."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0005",
   "metadata": {},
   "source": [
    "That round trip — compose, then decompose — is the core idea of the lesson.\n",
    "Real data arrives pre-mixed; decomposition recovers the recipe."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0006",
   "metadata": {},
   "source": [
    "## Additive or multiplicative?\n",
    "\n",
    "The components can combine two ways:\n",
    "\n",
    "- **Additive:** `y(t) = Trend + Seasonality + Residual` — seasonal swings\n",
    "  have roughly the *same size* everywhere, whether the level is high or low.\n",
    "- **Multiplicative:** `y(t) = Trend x Seasonality x Residual` — seasonal\n",
    "  swings are a *percentage* of the level, so they grow as the trend grows.\n",
    "\n",
    "The diagnostic is one glance at the plot: **do the seasonal peaks get taller\n",
    "as the series rises?** Monthly births in New York wiggle by about the same\n",
    "amount in every decade — additive. Classic airline-passenger data shows\n",
    "summer bumps that balloon as air travel grows — multiplicative. Let's\n",
    "generate one of each and see the signature:"
   ]
  },
  {
   "cell_type": "code",
   "id": "cell-0007",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import pandas as pd\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "rng = np.random.default_rng(42)\n",
    "months = pd.date_range(\"2015-01-01\", periods=120, freq=\"MS\")\n",
    "t = np.arange(120)\n",
    "pattern = np.array([-0.9, -1.2, 0.1, 0.0, 0.2, 1.4,\n",
    "                     2.6, 2.4, 0.7, -0.3, -1.5, -0.4])  # 12-month shape\n",
    "\n",
    "trend = 120 + 1.8 * t\n",
    "additive = trend + 18 * pattern[t % 12] + rng.normal(0, 6, 120)\n",
    "multiplicative = trend * (1 + 0.13 * pattern[t % 12]) * rng.normal(1, 0.02, 120)\n",
    "\n",
    "fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(9, 5.5), sharex=True)\n",
    "ax1.plot(months, additive)\n",
    "ax1.set_title(\"Additive: seasonal swings stay the same size\")\n",
    "ax2.plot(months, multiplicative, color=\"tab:orange\")\n",
    "ax2.set_title(\"Multiplicative: swings grow with the level\")\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0008",
   "metadata": {},
   "source": [
    "Same trend, same seasonal shape — but in the bottom panel the peaks fan out\n",
    "like a megaphone. When you see that fan, decompose multiplicatively (or take\n",
    "the logarithm of the series, which turns multiplication into addition and\n",
    "lets you use additive tools)."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0009",
   "metadata": {},
   "source": [
    "## Extracting the trend with moving averages\n",
    "\n",
    "The oldest trend extractor is the **moving average**: replace each point\n",
    "with the mean of a window around it. Choose the window to match the seasonal\n",
    "period — a centered 12-month window on monthly data averages over exactly\n",
    "one full cycle, so the seasonal ups and downs cancel and only the trend\n",
    "survives. In pandas that's one call to `.rolling()`:"
   ]
  },
  {
   "cell_type": "code",
   "id": "cell-0010",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import pandas as pd\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "rng = np.random.default_rng(42)\n",
    "months = pd.date_range(\"2015-01-01\", periods=120, freq=\"MS\")\n",
    "t = np.arange(120)\n",
    "pattern = np.array([-0.9, -1.2, 0.1, 0.0, 0.2, 1.4,\n",
    "                     2.6, 2.4, 0.7, -0.3, -1.5, -0.4])\n",
    "\n",
    "y = pd.Series((120 + 1.8 * t) * (1 + 0.13 * pattern[t % 12])\n",
    "              * rng.normal(1, 0.02, 120), index=months)\n",
    "\n",
    "trend = y.rolling(window=12, center=True).mean()\n",
    "detrended = y / trend                       # multiplicative: divide out\n",
    "seasonal = detrended.groupby(detrended.index.month).mean()\n",
    "\n",
    "fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(9, 5.5))\n",
    "ax1.plot(y.index, y, label=\"observed\", alpha=0.7)\n",
    "ax1.plot(trend.index, trend, label=\"12-month rolling mean\", lw=2.5)\n",
    "ax1.legend(); ax1.set_title(\"Moving average recovers the trend\")\n",
    "ax2.bar(seasonal.index, seasonal - 1)\n",
    "ax2.set_title(\"Average seasonal factor per month (relative to trend)\")\n",
    "ax2.set_xlabel(\"month\")\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0011",
   "metadata": {},
   "source": [
    "That second panel is a hand-rolled seasonal component: divide the series by\n",
    "its trend, then average the leftover ratio month by month. July sits about\n",
    "30% above trend, February about 15% below — the recipe recovered. (For an\n",
    "additive series you'd *subtract* the trend instead of dividing, and average\n",
    "the differences.) Whatever remains after removing both trend and seasonality\n",
    "is the **residual**, and eyeballing it is a quality check: leftover pattern\n",
    "in the residual means your decomposition missed something."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0012",
   "metadata": {},
   "source": [
    "## Decomposition in one line: statsmodels\n",
    "\n",
    "`seasonal_decompose` from statsmodels automates the whole procedure —\n",
    "moving-average trend, per-period seasonal averages, residual. It doesn't run\n",
    "in the browser, so drop this in the downloaded notebook or Colab (the CSV\n",
    "is the classic 1949–1960 airline-passenger series):"
   ]
  },
  {
   "cell_type": "code",
   "id": "cell-0013",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "from statsmodels.tsa.seasonal import seasonal_decompose\n",
    "\n",
    "url = \"https://raw.githubusercontent.com/jbrownlee/Datasets/master/airline-passengers.csv\"\n",
    "df = pd.read_csv(url, parse_dates=[\"Month\"], index_col=\"Month\")\n",
    "\n",
    "result = seasonal_decompose(df[\"Passengers\"], model=\"multiplicative\", period=12)\n",
    "fig = result.plot()\n",
    "fig.set_size_inches(10, 7)\n",
    "\n",
    "# The pieces are pandas Series you can reuse:\n",
    "# result.trend, result.seasonal, result.resid, result.observed"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0014",
   "metadata": {},
   "source": [
    "Try `model=\"additive\"` on the same data and inspect `result.resid`: the\n",
    "residual inherits a fan shape, because the additive model can't absorb the\n",
    "growing swings. A residual that still shows structure is the model telling\n",
    "you it's the wrong model."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0015",
   "metadata": {},
   "source": [
    "> **Autocorrelation plots**\n",
    "> \n",
    "> statsmodels also provides `plot_acf(df[\"Passengers\"], lags=50)` — the\n",
    "> autocorrelation function. A seasonal series shows spikes at lags 12, 24,\n",
    "> 36...; white noise shows nothing beyond lag 0. It's the standard second\n",
    "> plot to make after the series itself."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0016",
   "metadata": {},
   "source": [
    "## Stationarity and differencing\n",
    "\n",
    "A series is **stationary** when its statistical properties — mean, variance,\n",
    "autocorrelation — don't depend on *when* you look: no trend, no seasonality,\n",
    "just consistent fluctuation around a stable level. Many classical models\n",
    "(the ARIMA family, next lesson) require it, and almost no interesting raw\n",
    "series has it. The standard fix is **differencing**: model the *changes*\n",
    "`y(t) - y(t-1)` instead of the levels — differencing removes a trend the\n",
    "same way velocity removes position. Seasonal differencing, `y(t) - y(t-12)`\n",
    "for monthly data, removes a stable seasonal pattern the same way. When\n",
    "eyeballing isn't enough, the **Augmented Dickey-Fuller test**\n",
    "(`from statsmodels.tsa.stattools import adfuller`) gives a p-value: below\n",
    "0.05 you can treat the series as stationary; above it, difference and test\n",
    "again. The airline series fails the test raw and passes after one round of\n",
    "regular plus seasonal differencing — bookkeeping that ARIMA's `d` parameter\n",
    "does for you automatically."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0017",
   "metadata": {},
   "source": [
    "### 🏋️ Exercise — Decompose a series by hand\n",
    "\n",
    "Generate a synthetic **additive** monthly series over 8 years: trend\n",
    "`50 + 0.5*t`, a fixed 12-value seasonal pattern of your choosing, and\n",
    "Gaussian noise with standard deviation 5. Recover all three components by\n",
    "hand — rolling mean for the trend, monthly group-averages of the detrended\n",
    "series for the seasonality, and the leftover as residual — and plot the four\n",
    "panels like `seasonal_decompose` would. Does your residual's standard\n",
    "deviation match the noise you injected?"
   ]
  },
  {
   "cell_type": "code",
   "id": "cell-0018",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "# Your solution here"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0019",
   "metadata": {},
   "source": [
    "<details><summary>✅ Show solution</summary>\n",
    "\n",
    "```python\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "rng = np.random.default_rng(7)\n",
    "months = pd.date_range(\"2016-01-01\", periods=96, freq=\"MS\")\n",
    "t = np.arange(96)\n",
    "pattern = np.array([-10, -12, -3, 2, 8, 14, 18, 16, 6, -2, -11, -8])\n",
    "\n",
    "y = pd.Series(50 + 0.5 * t + pattern[t % 12] + rng.normal(0, 5, 96),\n",
    "              index=months)\n",
    "\n",
    "trend = y.rolling(window=12, center=True).mean()\n",
    "detrended = y - trend                              # additive: subtract\n",
    "seasonal_shape = detrended.groupby(detrended.index.month).mean()\n",
    "seasonal = pd.Series(y.index.month.map(seasonal_shape), index=y.index)\n",
    "resid = y - trend - seasonal\n",
    "\n",
    "fig, axes = plt.subplots(4, 1, figsize=(9, 8), sharex=True)\n",
    "for ax, (name, s) in zip(axes, [(\"observed\", y), (\"trend\", trend),\n",
    "                                (\"seasonal\", seasonal), (\"residual\", resid)]):\n",
    "    ax.plot(s.index, s)\n",
    "    ax.set_ylabel(name)\n",
    "plt.tight_layout()\n",
    "plt.show()\n",
    "\n",
    "print(f\"residual std: {resid.std():.2f}  (injected noise std was 5)\")\n",
    "```\n",
    "\n",
    "</details>"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cell-0020",
   "metadata": {},
   "source": [
    "Next up: turning the components into forecasts — baselines, lag features,\n",
    "Holt-Winters, and a first honest look at ARIMA."
   ]
  }
 ]
}