Forecast Electricity Demand with SARIMAX

Build hourly electricity forecasts with seasonal baselines and SARIMAX.

Introduction

30 Second Summary

Electricity use follows a daily rhythm. Weather can shift that rhythm.

In this project, you will build a browser-based Google Colab notebook that forecasts seven days of electricity demand for Zone 1 of Tetouan City. You will compare a weekly seasonal-naive baseline with SARIMAX before choosing the model you would trust for planning.

What You'll Build

Your finished notebook reveals a week of unseen electricity demand beside the forecast selected for weekly planning.

By the end of this project, you'll have:

  • A visible hourly demand chart that lets you spot repeating peaks across the final 14 days of public power readings.
  • A leak-free seven-day chronological holdout that compares actual demand with a seasonal-naive forecast. Its MAE exposes typical error. Its RMSE gives extra weight to large misses.
  • A SARIMAX forecast with a 95% prediction interval across the unseen week. An operating plan names the lowest-MAE champion. A synthetic planning penalty exposes the impact of under-forecasting. A daily error chart reveals when the retraining rule activates. The seasonal baseline remains ready as a fallback.
  • Secret Mission: Add hourly temperature to SARIMAX to test whether weather improves the same holdout. You'll identify the future temperature forecast required to operate it honestly.

Are there any prerequisites?

Comfort with pandas plus basic machine learning is useful. No forecasting experience or local Python installation is required.

Before We Start

Before any forecasting begins, commit to using the seasonal-naive forecast as the benchmark that SARIMAX must beat. You will also define the operational decision supported by your seven-day electricity-demand forecast. Include why forecast error matters for that decision.

Set Up Your Colab Environment

A demand forecast only earns trust when its environment can be recreated. Local Windows differences can change results before modeling begins.

In this step, Google Colab gives you a browser-based runtime with no local Python installation. You will pin Python 3.12.13 plus every forecasting package before creating the setup source.

In this step, get ready to:
  • Create a named Google Colab notebook on runtime 2026.07.
  • Install the exact forecasting package versions.
  • Produce a setup source that prints Setup complete.
Create the pinned notebook

A Colab runtime controls the Python environment behind your notebook. Selecting runtime 2026.07 pins the notebook to Python 3.12.13.

  • Visit Google Colab in your browser.
  • Sign in with your Google account if prompted.
  • Create a blank notebook from the Colab start page.
  • Rename the notebook from the title field at the top to electricity-demand-forecasting.ipynb.

You should see electricity-demand-forecasting.ipynb as the notebook title.

  • Open the Runtime menu in the notebook toolbar.
  • Select Change runtime type.
  • Select 2026.07 from the Runtime Version dropdown.
  • Apply the runtime selection.
  • Return to Change runtime type to confirm the Runtime Version field shows 2026.07.

Your notebook now uses the pinned Colab runtime with Python 3.12.13.

Prevent package drift

Pinned package versions keep future releases from changing this notebook's behavior. The installation cell places the same tested library versions into your current runtime.

  • Add a blank code cell at the top of electricity-demand-forecasting.ipynb.
  • Install the pinned dependencies by running this command in the first code cell:
%pip install pandas==3.0.6 matplotlib==3.11.2 statsmodels==0.15.0 ucimlrepo==0.0.7

What does this command install?

  • The pandas library shapes the raw readings into a time series.
  • Matplotlib creates the demand charts.
  • The statsmodels library provides the SARIMAX forecasting class.
  • The ucimlrepo package fetches the public dataset from UCI.
  • Wait for the installation cell to finish.
  • Confirm the cell finishes without an installation error.

A completed installation cell means the pinned packages are available to the current runtime.

Did the installation stop?

  • Check that the installation command matches the code block exactly.
  • Confirm the notebook still uses runtime 2026.07.
  • Rerun the installation cell after reconnecting the runtime.

Help me fix the pinned package installation in Google Colab.

Build and verify the setup source

The notebook cell is your runnable workspace. The matching forecast_demand.py file records the cumulative Python source as the notebook grows.

  • Select the folder-shaped file pane in the left sidebar.
  • Use the pane's new-file control to create forecast_demand.py.

You should see forecast_demand.py listed in the file pane.

  • Open forecast_demand.py from the file pane.
  • Add the forecasting imports by pasting this block into the file:
import pandas as pd
import matplotlib.pyplot as plt
from statsmodels.tsa.statespace.sarimax import SARIMAX
from ucimlrepo import fetch_ucirepo

What do these imports provide?

  • The pd alias gives later cells access to pandas data tools.
  • The plt alias provides the plotting interface.
  • SARIMAX provides the forecasting model class.
  • fetch_ucirepo provides the dataset retrieval function.
  • Add a second blank code cell beneath the installation cell.
  • Copy the import block into the second code cell.
  • Run the second code cell.

The second cell should finish without an import error.

Are the imports failing?

  • Confirm the package installation cell finished before the import cell ran.
  • Check each import against the code block.
  • Reconnect the runtime if Colab disconnected during installation.

Help me troubleshoot a failing import in my Colab setup cell.

  • Add the shared constants plus the success check beneath the imports in forecast_demand.py by pasting this block:
TARGET = "Zone 1 Power Consumption"
TEST_HOURS = 24 * 7
MODEL_TRAIN_HOURS = 24 * 56
UNDER_FORECAST_PENALTY = 2.0
OVER_FORECAST_PENALTY = 0.5


print("Setup complete")

What do these settings control?

  • TARGET stores the exact demand column used throughout the project.
  • TEST_HOURS represents the seven-day forecast horizon.
  • MODEL_TRAIN_HOURS represents the latest 56 days used for model fitting.
  • UNDER_FORECAST_PENALTY plus OVER_FORECAST_PENALTY store the synthetic planning scenario values.
  • The final print() call gives the setup cell a visible success signal.
  • Save forecast_demand.py from the file editor.
  • Add the same constants plus the success check beneath the imports in the second code cell.

Before you run the completed setup cell, what message do you expect it to print?

  • Run the completed setup cell.

You should see Setup complete below the cell. That's your environment locked in for the forecasting work ahead.

Missing the setup message?

  • Confirm print("Setup complete") appears at the bottom of the setup cell.
  • Check that the cell ran after you added the success check.
  • Look above the missing message for an import error that stopped execution.

Help me find why my Colab setup cell does not print Setup complete.

✔️ Awesome, I've got everything!

Your setup cell and forecast_demand.py now contain the same imports plus shared settings.

ⓧ I'd like to double check the full code

import pandas as pd
import matplotlib.pyplot as plt
from statsmodels.tsa.statespace.sarimax import SARIMAX
from ucimlrepo import fetch_ucirepo

TARGET = "Zone 1 Power Consumption"
TEST_HOURS = 24 * 7
MODEL_TRAIN_HOURS = 24 * 56
UNDER_FORECAST_PENALTY = 2.0
OVER_FORECAST_PENALTY = 0.5


print("Setup complete")

Your forecasting environment is reproducible. Next, you will load the UCI readings and turn them into a visible hourly demand pattern.

Create an Hourly Demand Series

Your forecasting workspace in Google Colab is ready. The next job is to shape the raw readings into data that a forecasting model can use.

Ten-minute readings make the operational daily cycle hard to see. Hourly resampling creates a regular time series for forecasting.

In this step, get ready to:
  • Load the official UCI electricity data.
  • Convert ten-minute readings into a regular hourly demand series.
  • Visualize the final 14 days of Zone 1 demand.
Fetch the UCI dataset

The UCI Machine Learning Repository provides the source data for this forecast. Printing its official columns first confirms that the expected demand field is available before any transformation.

  • In the forecast_demand.py cell from earlier, find the setup confirmation near the bottom.
  • Add the dataset-loading block directly below the setup confirmation by copying this code:
dataset = fetch_ucirepo(id=849)
raw = dataset.data.original
print("Official columns:")
print(list(raw.columns))

What does this code do?

  • The fetch_ucirepo() call downloads dataset 849 from UCI.
  • The dataset.data.original attribute provides every source column in one DataFrame.
  • Printing raw.columns lets you verify the source schema before changing it.
  • Save forecast_demand.py.
  • Run the forecast_demand.py cell in Google Colab.

You should see Official columns: followed by the source fields. The list includes DateTime plus Zone 1 Power Consumption.

That first data check is complete. You now know the forecasting target exists in the downloaded dataset.

Dataset not loading?

  • Confirm that the pinned runtime is still connected before running the cell again.
  • Check that the dataset call uses the ID 849.
  • Retry the cell if the data request was interrupted by a temporary network problem.

Ask for help with the download if it still fails: Help me troubleshoot why fetch_ucirepo cannot load UCI dataset 849 in Google Colab.

Build a format-aware timestamp parser

Date strings can place the month or day first. This parser tries several documented layouts so the notebook does not assume which layout the source stored.

  • Find the blank space above the setup confirmation in forecast_demand.py.
  • Add the timestamp parser in that space by copying this code:
def parse_timestamps(values):
    candidate_formats = (
        "%m/%d/%Y %H:%M",
        "%d/%m/%Y %H:%M",
        "%Y-%m-%d %H:%M:%S",
        "%Y-%m-%d %H:%M",
    )
    for candidate_format in candidate_formats:
        try:
            return pd.to_datetime(values, format=candidate_format)
        except (TypeError, ValueError):
            continue
    raise ValueError("DateTime values did not match the supported candidate formats.")

How does the parser work?

  • The candidate_formats tuple holds month-first, day-first, and ISO-style layouts.
  • The loop returns as soon as pd.to_datetime() successfully parses the values.
  • The final error stops the transformation if every supported layout fails.
  • Save forecast_demand.py.
  • Confirm that parse_timestamps() appears above the setup confirmation.

Seeing a parser syntax problem?

  • Check that every format remains inside the candidate_formats parentheses.
  • Match the indentation of the loop with the reference code.
  • Confirm that the final error line sits outside the loop.

Use this prompt to inspect the function structure: Help me compare my parse_timestamps function with the expected indentation and control flow.

Resample and chart hourly demand

A datetime index gives pandas the time boundaries needed for hourly grouping. Taking the mean produces one Zone 1 demand value for each retained hour.

  • Find the code that prints the official columns in forecast_demand.py.
  • Add the hourly transformation directly below it by copying this code:
raw["DateTime"] = parse_timestamps(raw["DateTime"])
hourly = (
    raw.set_index("DateTime")[[TARGET]]
    .resample("h")
    .mean()
    .dropna()
)
demand = hourly[TARGET]

print(f"Hourly observations: {len(demand)}")
print(f"Missing hourly values: {int(demand.isna().sum())}")

What does this transformation do?

  • The parser converts DateTime strings into values that pandas can use as a time index.
  • The resample("h").mean() chain combines the ten-minute readings into hourly averages.
  • The dropna() call removes incomplete hourly rows from the forecasting series.
  • The final checks report the retained observation count plus the remaining missing-value count.
  • Save forecast_demand.py.
  • Run the forecast_demand.py cell in Google Colab.

You should see an hourly observation count. The missing hourly value count should be 0.

That is the transformation locked in. Your demand series now has one clean value for each retained hour.

Seeing missing hourly values?

  • Confirm that DateTime becomes the index before resampling.
  • Check that the resampling chain includes dropna().
  • Verify that TARGET still contains the exact Zone 1 column name.

Use this prompt to trace the transformation: Help me find why my hourly demand series still reports missing values.

The final slice focuses the chart on two weeks of demand. Matplotlib turns that slice into a line where short-term cycles are visible.

  • Add the chart block below the missing-value checks by copying this code:
recent = demand.iloc[-24 * 14 :]
print("Final 14 days of hourly demand")
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(recent.index, recent.values, label="Zone 1 hourly demand")
ax.legend()
fig.tight_layout()
plt.show()

What does the chart show?

  • The recent slice keeps the final 14 days of hourly observations.
  • The line plot places time on the horizontal axis.
  • The demand values form the vertical movement of the line.
  • The compact figure makes repeated peaks and troughs easier to compare.
  • Save forecast_demand.py.

Before you run the completed cell, where do you expect the demand line to repeat its peaks and troughs?

  • Run the forecast_demand.py cell in Google Colab.

You should see the hourly observation count with 0 missing values. Below it, the final 14-day chart should show repeating rises and falls in Zone 1 demand.

You have turned raw ten-minute readings into a visible hourly pattern. The forecasting dataset is now regular enough for an honest time-based benchmark.

Chart missing or empty?

  • Confirm that demand is created before the recent slice.
  • Check that plt.show() remains at the end of the chart block.
  • Verify that the plotted values come from recent.values.

Use this prompt to diagnose the plot: Help me troubleshoot why my final 14-day Zone 1 demand chart is missing or empty.

✔️ Awesome, I've got everything!

Your saved file now loads the UCI data. It also creates a clean hourly series and displays the final 14-day demand chart.

ⓧ I'd like to double check the full code

import pandas as pd
import matplotlib.pyplot as plt
from statsmodels.tsa.statespace.sarimax import SARIMAX
from ucimlrepo import fetch_ucirepo

TARGET = "Zone 1 Power Consumption"
TEST_HOURS = 24 * 7
MODEL_TRAIN_HOURS = 24 * 56
UNDER_FORECAST_PENALTY = 2.0
OVER_FORECAST_PENALTY = 0.5


def parse_timestamps(values):
    candidate_formats = (
        "%m/%d/%Y %H:%M",
        "%d/%m/%Y %H:%M",
        "%Y-%m-%d %H:%M:%S",
        "%Y-%m-%d %H:%M",
    )
    for candidate_format in candidate_formats:
        try:
            return pd.to_datetime(values, format=candidate_format)
        except (TypeError, ValueError):
            continue
    raise ValueError("DateTime values did not match the supported candidate formats.")


print("Setup complete")

dataset = fetch_ucirepo(id=849)
raw = dataset.data.original
print("Official columns:")
print(list(raw.columns))

raw["DateTime"] = parse_timestamps(raw["DateTime"])
hourly = (
    raw.set_index("DateTime")[[TARGET]]
    .resample("h")
    .mean()
    .dropna()
)
demand = hourly[TARGET]

print(f"Hourly observations: {len(demand)}")
print(f"Missing hourly values: {int(demand.isna().sum())}")

recent = demand.iloc[-24 * 14 :]
print("Final 14 days of hourly demand")
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(recent.index, recent.values, label="Zone 1 hourly demand")
ax.legend()
fig.tight_layout()
plt.show()

How to use this reference

Compare the reference with your saved forecast_demand.py from top to bottom. Pay close attention to indentation inside parse_timestamps() plus the order of the transformation and chart blocks.

Your hourly series now exposes the repeating rhythm of Zone 1 demand. Next, you will turn that rhythm into a weekly seasonal benchmark and test it against an unseen seven-day holdout.

Benchmark the Seasonal Baseline

Your hourly Zone 1 chart now shows a repeating weekly rhythm. A credible forecasting model needs to beat that visible pattern before it earns a place in the pipeline.

A seasonal-naive forecast copies demand from the same hour one week earlier. You will evaluate that simple forecast on a chronological holdout that protects the final 168 hours from data leakage.

In this step, get ready to:
  • Define reusable MAE and RMSE calculations.
  • Reserve the final 168 hourly observations for testing.
  • Compare actual demand with the copied weekly pattern.
Define fair error measures

Mean absolute error measures the average size of each miss. Root mean squared error gives larger misses more influence on the final score.

  • Insert a new code cell directly below the cell that defines parse_timestamps.
  • Create the reusable scoring function in that cell by pasting this code:
def metrics(actual, predicted):
    error = actual - predicted
    return {
        "MAE": error.abs().mean(),
        "RMSE": ((error * error).mean()) ** 0.5,
    }

What does this function measure?

  • The error series stores actual demand minus predicted demand for every hour.
  • The MAE value averages the absolute size of those hourly errors.
  • The RMSE value squares each error before averaging. Large misses receive more weight.
  • Run the scoring-function cell.

You should see the cell finish without an exception. Its execution indicator confirms that metrics() is ready for the baseline.

Scoring function not loading?

  • Check that every line inside metrics() uses the same indentation.
  • Confirm that the closing brace aligns with the opening return statement.

Ask for help with the function structure: Help me find the syntax or indentation problem in my metrics function.

Test the copied weekly pattern

The holdout represents the unseen future that the forecast must predict. Keeping those 168 hours out of train makes the comparison honest.

  • Insert a new code cell directly below the scoring-function cell.
  • Evaluate the weekly baseline on the untouched holdout by pasting this code:
train = demand.iloc[:-TEST_HOURS]
test = demand.iloc[-TEST_HOURS:]
seasonal_naive = demand.shift(TEST_HOURS).iloc[-TEST_HOURS:]

naive_metrics = metrics(test, seasonal_naive)
print("Seasonal-naive metrics:")
print(naive_metrics)

How does the benchmark stay honest?

  • The train slice stops before the final TEST_HOURS observations.
  • The test slice contains the untouched seven-day future.
  • The demand.shift(TEST_HOURS) expression moves the previous week's values into the matching forecast hours.
  • The naive_metrics dictionary stores both scores for later model comparison.

Before you run the cell, consider whether copying last week can match every hour in the unseen week.

  • Run the baseline evaluation cell.

Good work. Your weekly benchmark now has honest holdout scores under MAE and RMSE.

You should see Seasonal-naive metrics: followed by a dictionary containing numeric MAE and RMSE values.

Baseline metrics missing?

  • Run the earlier cells that create demand before running the baseline cell.
  • Run the scoring-function cell so metrics() exists in the current runtime.
  • Confirm that TEST_HOURS still equals 24 * 7.

Ask for help with the missing state: Help me diagnose why my seasonal-naive metrics cell does not print MAE and RMSE.

Reveal the baseline's missed peaks

A pair of scores summarizes forecast error across the week. A line chart reveals when the copied pattern diverges from actual demand.

  • Insert a new code cell directly below the baseline evaluation cell.
  • Plot actual demand beside the weekly baseline by pasting this code:
print("Actual demand versus the copied previous-week pattern")
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(test.index, test.values, label="Actual")
ax.plot(seasonal_naive.index, seasonal_naive.values, label="Seasonal naive")
ax.legend()
fig.tight_layout()
plt.show()

What does this chart compare?

  • The Actual line shows the untouched demand values from the seven-day holdout.
  • The Seasonal naive line shows demand copied from the same hours one week earlier.
  • Both lines use the same timestamps. Their vertical gaps expose the baseline's hourly forecast errors.

Before you run the cell, consider where a fixed weekly copy is most likely to separate from actual demand.

  • Run the comparison chart cell.

You should see actual demand and the seasonal-naive forecast across the same seven-day holdout. The lines follow a similar rhythm but separate around changing peaks and troughs.

That visible gap is the intended shortfall. The fixed copy cannot adapt to changing dynamics or provide a model-based uncertainty interval.

Chart not displaying both lines?

  • Run the baseline evaluation cell so test and seasonal_naive exist.
  • Check that both ax.plot lines remain inside the chart cell.
  • Confirm that the final line still calls plt.show().

Ask for help with the plot state: Help me troubleshoot why my actual and seasonal-naive lines are missing from the chart.

✔️ Awesome, I've got everything!

Your holdout scores and comparison chart confirm that the seasonal baseline is ready for a fair model comparison.

ⓧ I'd like to double check the full code

import pandas as pd
import matplotlib.pyplot as plt
from statsmodels.tsa.statespace.sarimax import SARIMAX
from ucimlrepo import fetch_ucirepo

TARGET = "Zone 1 Power Consumption"
TEST_HOURS = 24 * 7
MODEL_TRAIN_HOURS = 24 * 56
UNDER_FORECAST_PENALTY = 2.0
OVER_FORECAST_PENALTY = 0.5


def parse_timestamps(values):
    candidate_formats = (
        "%m/%d/%Y %H:%M",
        "%d/%m/%Y %H:%M",
        "%Y-%m-%d %H:%M:%S",
        "%Y-%m-%d %H:%M",
    )
    for candidate_format in candidate_formats:
        try:
            return pd.to_datetime(values, format=candidate_format)
        except (TypeError, ValueError):
            continue
    raise ValueError("DateTime values did not match the supported candidate formats.")


def metrics(actual, predicted):
    error = actual - predicted
    return {
        "MAE": error.abs().mean(),
        "RMSE": ((error * error).mean()) ** 0.5,
    }


print("Setup complete")

dataset = fetch_ucirepo(id=849)
raw = dataset.data.original
print("Official columns:")
print(list(raw.columns))

raw["DateTime"] = parse_timestamps(raw["DateTime"])
hourly = (
    raw.set_index("DateTime")[[TARGET]]
    .resample("h")
    .mean()
    .dropna()
)
demand = hourly[TARGET]

print(f"Hourly observations: {len(demand)}")
print(f"Missing hourly values: {int(demand.isna().sum())}")

recent = demand.iloc[-24 * 14 :]
print("Final 14 days of hourly demand")
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(recent.index, recent.values, label="Zone 1 hourly demand")
ax.legend()
fig.tight_layout()
plt.show()

train = demand.iloc[:-TEST_HOURS]
test = demand.iloc[-TEST_HOURS:]
seasonal_naive = demand.shift(TEST_HOURS).iloc[-TEST_HOURS:]

naive_metrics = metrics(test, seasonal_naive)
print("Seasonal-naive metrics:")
print(naive_metrics)

print("Actual demand versus the copied previous-week pattern")
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(test.index, test.values, label="Actual")
ax.plot(seasonal_naive.index, seasonal_naive.values, label="Seasonal naive")
ax.legend()
fig.tight_layout()
plt.show()

Your seasonal-naive forecast is now an honest benchmark with visible strengths and weaknesses. Next up, you will give SARIMAX the chance to earn its place by modeling daily structure and forecast uncertainty.

Fit a SARIMAX Forecast

Your seasonal-naive baseline now gives a more capable model a fair target to beat. Its copied weekly pattern can miss changing peaks.

In this step, you will fit a compact SARIMAX model to the latest 56 training days. The model captures daily seasonal structure without touching the seven-day holdout.

In this step, get ready to:
  • Fit a daily-seasonal model to the final 56 training days.
  • Evaluate the 168-hour forecast with MAE and RMSE.
  • Visualize the forecast inside a shaded 95% prediction interval.
Fit the daily-seasonal model

The baseline repeats an old pattern. The new model learns relationships between hourly observations plus a repeating 24-hour cycle.

  • Add a new code cell directly below the baseline chart cell.
  • Paste the model-fitting code below into the new cell:
model_train = train.iloc[-MODEL_TRAIN_HOURS:]
model = SARIMAX(
    model_train,
    order=(1, 0, 0),
    seasonal_order=(1, 1, 0, 24),
    enforce_stationarity=True,
    enforce_invertibility=True,
)
result = model.fit(disp=False, maxiter=100)
print(f"SARIMAX converged: {result.mle_retvals.get('converged', False)}")

What does this code do?

  • The model_train slice keeps only the final 56 training days. The untouched holdout remains outside model fitting.
  • The non-seasonal order adds one autoregressive term so the model can learn short-range autocorrelation.
  • The seasonal order applies daily seasonal differencing plus one seasonal autoregressive term. The value 24 represents the number of hourly observations in each daily cycle.
  • The stationarity and invertibility settings keep the fitted parameters within stable model constraints.
  • The fit allows up to 100 optimizer iterations. The final line exposes the optimizer's convergence flag through mle_retvals.
  • Run the cell using the play button on its left.

You will see SARIMAX converged: followed by the optimizer's Boolean result.

Good progress: the model fit has produced an optimizer status you can inspect.

Did the model fail to fit?

  • Confirm the earlier setup cell imported SARIMAX before this cell runs.
  • Confirm train exists by rerunning the earlier data and baseline cells in order.
  • Rerun the cell once if the convergence result is False.

Help me diagnose my SARIMAX fitting or convergence problem.

Forecast the untouched holdout

A successful fit summarizes patterns from the training window. Out-of-sample forecasting tests whether those learned patterns transfer to the future observations held back earlier.

  • Add a new code cell directly below the model-fitting cell.
  • Paste the forecasting and evaluation code below into the new cell:
forecast_result = result.get_forecast(steps=len(test))
sarimax_forecast = forecast_result.predicted_mean
prediction_interval = forecast_result.conf_int(alpha=0.05)
sarimax_metrics = metrics(test, sarimax_forecast)

print("SARIMAX metrics:")
print(sarimax_metrics)

How is the forecast evaluated?

  • The get_forecast call generates one prediction for every hour in test.
  • The predicted_mean values become the model's point forecast.
  • The confidence interval uses alpha=0.05 to retain a central 95% interval around each prediction.
  • The existing metrics() function scores the forecast against the same holdout used for the seasonal baseline.
  • Run the cell using the play button on its left.

You will see SARIMAX metrics: followed by the model's MAE and RMSE values.

Your SARIMAX forecast now has scores from the same future week as the baseline. That shared holdout makes the later comparison fair.

Missing the SARIMAX metrics?

  • Confirm the model-fitting cell completed before this forecasting cell runs.
  • Confirm test still contains the final 168 hourly observations.
  • Rerun the earlier cell that defines metrics() if the function is unavailable.

Help me troubleshoot why my SARIMAX forecast or metrics are missing.

Plot the forecast interval

A point forecast shows one estimated path. A 95% interval shows how uncertainty changes across the same seven-day horizon.

  • Add a new code cell directly below the metrics cell.
  • Paste the forecast visualization code below into the new cell:
print("SARIMAX forecast with 95% prediction interval")
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(test.index, test.values, label="Actual")
ax.plot(sarimax_forecast.index, sarimax_forecast.values, label="SARIMAX")
ax.fill_between(
    prediction_interval.index,
    prediction_interval.iloc[:, 0],
    prediction_interval.iloc[:, 1],
    alpha=0.2,
    label="95% prediction interval",
)
ax.legend()
fig.tight_layout()
plt.show()

What does this chart show?

  • The first line represents the actual demand from the untouched holdout.
  • The second line represents the SARIMAX point forecast for the same timestamps.
  • The shaded area spans the lower and upper interval bounds stored in prediction_interval.
  • The transparency value keeps both lines visible through the interval shading.

Before you run this cell, where do you expect the actual demand peaks to sit relative to the shaded interval?

  • Run the cell using the play button on its left.

You will see the actual demand beside the SARIMAX forecast across all 168 holdout hours. A shaded 95% prediction interval will surround the model forecast.

That is the uncertainty layer working: your notebook now shows both the model's expected path and the range around it.

Is the interval missing?

  • Confirm the forecasting cell created prediction_interval before the chart cell runs.
  • Confirm both interval columns remain aligned with the forecast index.
  • Rerun the earlier setup cell if the plotting import is unavailable.

Help me troubleshoot why my SARIMAX prediction interval is not visible.

✔️ Awesome, I've got everything!

Your model-fitting, forecasting, and visualization cells now match the complete project code for this step.

ⓧ I'd like to double check the full code

import pandas as pd
import matplotlib.pyplot as plt
from statsmodels.tsa.statespace.sarimax import SARIMAX
from ucimlrepo import fetch_ucirepo

TARGET = "Zone 1 Power Consumption"
TEST_HOURS = 24 * 7
MODEL_TRAIN_HOURS = 24 * 56
UNDER_FORECAST_PENALTY = 2.0
OVER_FORECAST_PENALTY = 0.5


def parse_timestamps(values):
    candidate_formats = (
        "%m/%d/%Y %H:%M",
        "%d/%m/%Y %H:%M",
        "%Y-%m-%d %H:%M:%S",
        "%Y-%m-%d %H:%M",
    )
    for candidate_format in candidate_formats:
        try:
            return pd.to_datetime(values, format=candidate_format)
        except (TypeError, ValueError):
            continue
    raise ValueError("DateTime values did not match the supported candidate formats.")


def metrics(actual, predicted):
    error = actual - predicted
    return {
        "MAE": error.abs().mean(),
        "RMSE": ((error * error).mean()) ** 0.5,
    }


print("Setup complete")

dataset = fetch_ucirepo(id=849)
raw = dataset.data.original
print("Official columns:")
print(list(raw.columns))

raw["DateTime"] = parse_timestamps(raw["DateTime"])
hourly = (
    raw.set_index("DateTime")[[TARGET]]
    .resample("h")
    .mean()
    .dropna()
)
demand = hourly[TARGET]

print(f"Hourly observations: {len(demand)}")
print(f"Missing hourly values: {int(demand.isna().sum())}")

recent = demand.iloc[-24 * 14 :]
print("Final 14 days of hourly demand")
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(recent.index, recent.values, label="Zone 1 hourly demand")
ax.legend()
fig.tight_layout()
plt.show()

train = demand.iloc[:-TEST_HOURS]
test = demand.iloc[-TEST_HOURS:]
seasonal_naive = demand.shift(TEST_HOURS).iloc[-TEST_HOURS:]

naive_metrics = metrics(test, seasonal_naive)
print("Seasonal-naive metrics:")
print(naive_metrics)

print("Actual demand versus the copied previous-week pattern")
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(test.index, test.values, label="Actual")
ax.plot(seasonal_naive.index, seasonal_naive.values, label="Seasonal naive")
ax.legend()
fig.tight_layout()
plt.show()

model_train = train.iloc[-MODEL_TRAIN_HOURS:]
model = SARIMAX(
    model_train,
    order=(1, 0, 0),
    seasonal_order=(1, 1, 0, 24),
    enforce_stationarity=True,
    enforce_invertibility=True,
)
result = model.fit(disp=False, maxiter=100)
print(f"SARIMAX converged: {result.mle_retvals.get('converged', False)}")

forecast_result = result.get_forecast(steps=len(test))
sarimax_forecast = forecast_result.predicted_mean
prediction_interval = forecast_result.conf_int(alpha=0.05)
sarimax_metrics = metrics(test, sarimax_forecast)

print("SARIMAX metrics:")
print(sarimax_metrics)

print("SARIMAX forecast with 95% prediction interval")
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(test.index, test.values, label="Actual")
ax.plot(sarimax_forecast.index, sarimax_forecast.values, label="SARIMAX")
ax.fill_between(
    prediction_interval.index,
    prediction_interval.iloc[:, 0],
    prediction_interval.iloc[:, 1],
    alpha=0.2,
    label="95% prediction interval",
)
ax.legend()
fig.tight_layout()
plt.show()

Your SARIMAX model now has an optimizer result, holdout metrics, and a visible uncertainty interval. Next, you will compare both forecasts and define the operating rules for the model you choose.

Choose Operating Rules

Your Google Colab notebook now compares a SARIMAX forecast with the same chronological holdout used by the seasonal-naive forecast.

Accuracy alone cannot turn that result into an operating system. An asymmetric planning penalty captures unequal consequences.

A fallback protects weekly delivery. Forecast monitoring tells you when retraining is due.

In this step, get ready to:
  • Select the lowest-MAE model from a two-model scorecard.
  • Measure each model with a synthetic asymmetric planning penalty.
  • Define monitoring rules for fallback behavior and retraining.
Compare the holdout scorecard

Mean absolute error measures the typical size of a forecast miss. The scorecard uses holdout MAE to choose a champion without assuming the more complex model wins.

  • Add the scorecard directly below the SARIMAX chart by pasting this code into a new cell:
scores = {
    "Seasonal naive": naive_metrics,
    "SARIMAX": sarimax_metrics,
}
comparison = pd.DataFrame(
    [
        {"Model": "Seasonal naive", **scores["Seasonal naive"]},
        {"Model": "SARIMAX", **scores["SARIMAX"]},
    ]
).set_index("Model")
champion = min(scores, key=lambda model_name: scores[model_name]["MAE"])

print("Model scorecard:")
print(comparison)
print(f"Champion by holdout MAE: {champion}")

How Champion Selection Works

  • The scores dictionary keeps both holdout metric sets under their model names.
  • The comparison table places MAE beside RMSE for a direct comparison.
  • The min selection chooses the model with the lowest holdout MAE.
  • The selection rule lets either model become champion.
  • Run the scorecard cell.

You should see Model scorecard: above two rows of metrics. You should also see Champion by holdout MAE: followed by the winning model.

Good work. Your notebook now chooses its champion from unseen data instead of model complexity.

Scorecard Cell Failing?

  • Confirm the earlier cells created naive_metrics and sarimax_metrics.
  • Check that each metric dictionary contains the exact MAE and RMSE keys.

Help me fix my two-model scorecard in Colab.

Measure asymmetric planning cost

An under-forecast can carry a different consequence from an over-forecast. This synthetic scenario assigns 2.0 penalty points to each under-forecast unit.

Each over-forecast unit receives 0.5 penalty points. The final result stays unitless because the dataset does not publish a physical unit for the target.

  • Return to the helper cell that defines metrics.
  • Add planning_cost directly below metrics by pasting this code:
def planning_cost(actual, predicted, under_penalty, over_penalty):
    error = actual - predicted
    under_forecast = error.clip(lower=0).sum()
    over_forecast = (-error.clip(upper=0)).sum()
    return under_forecast * under_penalty + over_forecast * over_penalty

How Planning Cost Separates Errors

  • Positive values in error represent demand that the forecast underestimated.
  • The first clip keeps those under-forecast amounts.
  • The second clip isolates over-forecast amounts before making them positive.
  • The return value applies a separate penalty to each error direction.
  • Run the updated helper cell.

The helper cell should finish without an exception. The planning_cost function is now available to later cells.

Helper Cell Showing an Error?

  • Check that planning_cost sits outside the metrics function.
  • Match the indentation shown in the code block.
  • Confirm every parameter in the function definition appears with the same spelling in the return statement.

Help me debug the planning_cost helper.

  • Calculate both synthetic penalties by running this code in a new cell below the scorecard:
naive_cost = planning_cost(
    test,
    seasonal_naive,
    UNDER_FORECAST_PENALTY,
    OVER_FORECAST_PENALTY,
)
sarimax_cost = planning_cost(
    test,
    sarimax_forecast,
    UNDER_FORECAST_PENALTY,
    OVER_FORECAST_PENALTY,
)

print("Synthetic planning penalties, in unitless scenario points:")
print(f"Seasonal naive: {naive_cost:.2f}")
print(f"SARIMAX: {sarimax_cost:.2f}")

How the Penalties Are Applied

  • Both forecasts are measured against the identical test holdout.
  • Both calls use UNDER_FORECAST_PENALTY and OVER_FORECAST_PENALTY.
  • The printed values use two decimal places to make the comparison readable.
  • A lower score represents less synthetic planning cost under this authored scenario.
  • Run the penalty cell.

You should see Synthetic planning penalties, in unitless scenario points: followed by one value for each model.

The consequence-aware comparison is working. You can now discuss error direction without inventing currency or physical units.

Penalty Values Missing?

  • Run the updated helper cell before the penalty cell.
  • Confirm the earlier workflow created test and both forecast objects.
  • Check that the penalty constant names match their definitions near the top of the notebook.

Help me troubleshoot missing planning penalty values.

Create monitoring and fallback rules

A champion can lose accuracy as demand patterns change. Daily absolute error provides a monitoring signal that can trigger retraining.

  • Add the monitoring chart below the penalty output by pasting this code into a new cell:
sarimax_error = test - sarimax_forecast
daily_mae = sarimax_error.abs().resample("D").mean()
alert_threshold = naive_metrics["MAE"] * 1.25

print("Daily SARIMAX MAE monitoring")
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(daily_mae.index, daily_mae.values, label="Daily SARIMAX MAE")
ax.axhline(
    alert_threshold,
    color="red",
    linestyle="--",
    label="Alert threshold",
)
ax.legend()
fig.tight_layout()
plt.show()

How Monitoring Becomes a Signal

  • The sarimax_error series compares actual demand with the SARIMAX point forecast.
  • The daily resampling turns hourly absolute errors into one MAE value per day.
  • The alert_threshold equals 125% of baseline MAE.
  • The horizontal line makes threshold breaches visible on the chart.

Before you run this cell, do you expect every daily SARIMAX MAE value to remain below the alert threshold?

  • Run the monitoring cell.

You should see Daily SARIMAX MAE monitoring above a chart. The chart should contain a daily error line and a red dashed alert threshold.

Your monitoring view is live. Each point now answers whether that day stayed within the authored error limit.

Monitoring Chart Missing?

  • Confirm the SARIMAX cell created sarimax_forecast with the same datetime index as test.
  • Check that the resampling rule uses the exact uppercase D value.
  • Run the baseline metrics cell before calculating alert_threshold.

Help me fix the daily SARIMAX MAE chart.

The operating policy uses a weekly 168-hour batch forecast. It keeps the seasonal-naive method available when model fitting fails.

Retraining begins after two consecutive daily threshold breaches. This avoids reacting to one isolated bad day.

  • Add the operating policy below the monitoring chart by pasting this code into a new cell:
print("Operating rules:")
print("- Generate one 168-hour batch forecast each week.")
print("- Fall back to the weekly seasonal-naive forecast if model fitting fails.")
print(
    "- Retrain after daily MAE exceeds the authored alert threshold "
    "for two consecutive days."
)

How These Rules Protect Delivery

  • The batch rule defines when the next seven days of predictions are generated.
  • The seasonal-naive fallback keeps a forecast available if SARIMAX fitting fails.
  • The retraining rule waits for two consecutive breaches before responding.
  • Together these rules turn model evaluation into a repeatable operating decision.

Before you run this final cell, which conditions should trigger fallback behavior and retraining?

  • Run the operating policy cell.

You should see Operating rules: followed by the weekly forecast schedule. You should also see the fallback and retraining conditions.

Operating Rules Cut Off?

  • Check that both quoted parts of the retraining message remain inside the same print call.
  • Match the opening and closing parentheses shown in the code block.
  • Run the cell again after correcting any mismatched quotation marks.

Help me fix the printed operating rules.

Before you run the complete notebook, which model do you expect the holdout MAE rule to select as champion?

  • Run every notebook cell from top to bottom.

You should see the two-model scorecard and its selected champion. You should also see both synthetic penalties, the monitoring chart, and all three operating rules.

That completes the operational layer. Your notebook now selects a model, measures asymmetric consequences, monitors performance, and defines recovery behavior.

✔️ Awesome, I've got everything!

Your electricity-demand-forecasting.ipynb notebook is fully runnable. Keep the scorecard, planning penalties, monitoring chart, and operating rules visible in the final outputs.

ⓧ I'd like to double check the full code

import pandas as pd
import matplotlib.pyplot as plt
from statsmodels.tsa.statespace.sarimax import SARIMAX
from ucimlrepo import fetch_ucirepo

TARGET = "Zone 1 Power Consumption"
TEST_HOURS = 24 * 7
MODEL_TRAIN_HOURS = 24 * 56
UNDER_FORECAST_PENALTY = 2.0
OVER_FORECAST_PENALTY = 0.5


def parse_timestamps(values):
    candidate_formats = (
        "%m/%d/%Y %H:%M",
        "%d/%m/%Y %H:%M",
        "%Y-%m-%d %H:%M:%S",
        "%Y-%m-%d %H:%M",
    )
    for candidate_format in candidate_formats:
        try:
            return pd.to_datetime(values, format=candidate_format)
        except (TypeError, ValueError):
            continue
    raise ValueError("DateTime values did not match the supported candidate formats.")


def metrics(actual, predicted):
    error = actual - predicted
    return {
        "MAE": error.abs().mean(),
        "RMSE": ((error * error).mean()) ** 0.5,
    }


def planning_cost(actual, predicted, under_penalty, over_penalty):
    error = actual - predicted
    under_forecast = error.clip(lower=0).sum()
    over_forecast = (-error.clip(upper=0)).sum()
    return under_forecast * under_penalty + over_forecast * over_penalty


print("Setup complete")

dataset = fetch_ucirepo(id=849)
raw = dataset.data.original
print("Official columns:")
print(list(raw.columns))

raw["DateTime"] = parse_timestamps(raw["DateTime"])
hourly = (
    raw.set_index("DateTime")[[TARGET]]
    .resample("h")
    .mean()
    .dropna()
)
demand = hourly[TARGET]

print(f"Hourly observations: {len(demand)}")
print(f"Missing hourly values: {int(demand.isna().sum())}")

recent = demand.iloc[-24 * 14 :]
print("Final 14 days of hourly demand")
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(recent.index, recent.values, label="Zone 1 hourly demand")
ax.legend()
fig.tight_layout()
plt.show()

train = demand.iloc[:-TEST_HOURS]
test = demand.iloc[-TEST_HOURS:]
seasonal_naive = demand.shift(TEST_HOURS).iloc[-TEST_HOURS:]

naive_metrics = metrics(test, seasonal_naive)
print("Seasonal-naive metrics:")
print(naive_metrics)

print("Actual demand versus the copied previous-week pattern")
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(test.index, test.values, label="Actual")
ax.plot(seasonal_naive.index, seasonal_naive.values, label="Seasonal naive")
ax.legend()
fig.tight_layout()
plt.show()

model_train = train.iloc[-MODEL_TRAIN_HOURS:]
model = SARIMAX(
    model_train,
    order=(1, 0, 0),
    seasonal_order=(1, 1, 0, 24),
    enforce_stationarity=True,
    enforce_invertibility=True,
)
result = model.fit(disp=False, maxiter=100)
print(f"SARIMAX converged: {result.mle_retvals.get('converged', False)}")

forecast_result = result.get_forecast(steps=len(test))
sarimax_forecast = forecast_result.predicted_mean
prediction_interval = forecast_result.conf_int(alpha=0.05)
sarimax_metrics = metrics(test, sarimax_forecast)

print("SARIMAX metrics:")
print(sarimax_metrics)

print("SARIMAX forecast with 95% prediction interval")
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(test.index, test.values, label="Actual")
ax.plot(sarimax_forecast.index, sarimax_forecast.values, label="SARIMAX")
ax.fill_between(
    prediction_interval.index,
    prediction_interval.iloc[:, 0],
    prediction_interval.iloc[:, 1],
    alpha=0.2,
    label="95% prediction interval",
)
ax.legend()
fig.tight_layout()
plt.show()

scores = {
    "Seasonal naive": naive_metrics,
    "SARIMAX": sarimax_metrics,
}
comparison = pd.DataFrame(
    [
        {"Model": "Seasonal naive", **scores["Seasonal naive"]},
        {"Model": "SARIMAX", **scores["SARIMAX"]},
    ]
).set_index("Model")
champion = min(scores, key=lambda model_name: scores[model_name]["MAE"])

print("Model scorecard:")
print(comparison)
print(f"Champion by holdout MAE: {champion}")

naive_cost = planning_cost(
    test,
    seasonal_naive,
    UNDER_FORECAST_PENALTY,
    OVER_FORECAST_PENALTY,
)
sarimax_cost = planning_cost(
    test,
    sarimax_forecast,
    UNDER_FORECAST_PENALTY,
    OVER_FORECAST_PENALTY,
)

print("Synthetic planning penalties, in unitless scenario points:")
print(f"Seasonal naive: {naive_cost:.2f}")
print(f"SARIMAX: {sarimax_cost:.2f}")

sarimax_error = test - sarimax_forecast
daily_mae = sarimax_error.abs().resample("D").mean()
alert_threshold = naive_metrics["MAE"] * 1.25

print("Daily SARIMAX MAE monitoring")
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(daily_mae.index, daily_mae.values, label="Daily SARIMAX MAE")
ax.axhline(
    alert_threshold,
    color="red",
    linestyle="--",
    label="Alert threshold",
)
ax.legend()
fig.tight_layout()
plt.show()

print("Operating rules:")
print("- Generate one 168-hour batch forecast each week.")
print("- Fall back to the weekly seasonal-naive forecast if model fitting fails.")
print(
    "- Retrain after daily MAE exceeds the authored alert threshold "
    "for two consecutive days."
)

How to Use This Reference

Compare this cumulative file with the cells in your notebook. The ordering keeps helper functions available before the scorecard and planning calculations run.

Secret mission

Secret Mission: Add Temperature as an External Signal

The demand-only model cannot respond to weather-related variation. Add hourly temperature to the same SARIMAX structure. Compare all three models against the same holdout while documenting the future-weather dependency that deployment introduces.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your forecasting notebook, pause the current session, or remove the project. This project uses free Google Colab compute with no ongoing cost.

Resources you used:

  • The electricity-demand-forecasting.ipynb notebook stored in Google Drive.
  • The temporary Colab runtime state from this session.

Keep everything running

No action is needed. Choose this option if you want to keep your completed forecasting notebook available for future experiments.

  • Keep electricity-demand-forecasting.ipynb in Google Drive.
  • Rerun the notebook from its first cell whenever you want to rebuild the temporary runtime state.

Pause - I'll come back to this later

Disconnect the active runtime to end the current compute session. Your notebook remains saved in Google Drive.

  • Use the runtime controls in Colab to disconnect the active runtime.
  • Close the browser tab after Colab confirms the disconnection.
  • Leave electricity-demand-forecasting.ipynb in Google Drive for your next session.

Delete - I don't want to use this again

Remove the saved notebook if you do not want to keep this project. This is the only option that removes your reusable forecasting work, so pause here if you want to retain a copy.

  • Return to the Google Colab tab from earlier.
  • Use the runtime controls in Colab to disconnect the active runtime.
  • Close the Colab browser tab.
  • Go to Google Drive in a new browser tab.
  • Locate electricity-demand-forecasting.ipynb in your file list.
  • Move the notebook to trash with the file's context menu.
  • Confirm electricity-demand-forecasting.ipynb no longer appears among your active Drive files.
  • Leave the disconnected runtime closed so its temporary state can expire.

Nice Work!

Nice Work!

You did it! Your reproducible Google Colab notebook now turns raw electricity readings into an operational seven-day demand forecast.

What you learned:

  • Built an hourly demand series from ten-minute readings in the UCI Machine Learning Repository. Reserved the final seven days as a leak-free chronological holdout.
  • Benchmarked a weekly seasonal-naive forecast against a compact SARIMAX forecast. Evaluated both models with MAE plus RMSE. Displayed a 95% prediction interval around the SARIMAX forecast.
  • Selected a champion model using untouched holdout results. Converted forecast errors into an asymmetric planning penalty without inventing target units. Defined a seasonal-naive fallback. Added a retraining trigger based on two consecutive threshold breaches. Visualized daily error through forecast monitoring.
  • Secret Mission: Tested temperature as an exogenous signal against both univariate forecasts. Identified that a deployed 168-hour forecast requires aligned future temperature forecasts.

Ready to quiz yourself?