Skip to main content
Open llms.txtCopy tools

Naive And Seasonal Naive

Naive and seasonal naive models are the first baselines to run for forecasting. They are intentionally simple and make leakage problems easier to spot.

Interactive Example

Seasonal naive panel forecast

Runs seasonal_naive against a bundled route-demand sample.

Ready to run in this page.

When To Use

Use NaiveForecaster when the next value should be compared against the last observed value. Use SeasonalNaiveForecaster when the series has a stable cycle, such as hourly demand with season_length=24 or daily demand with season_length=7.

ModelBehavior
NaiveForecasterRepeats the most recent observed value for each future horizon.
SeasonalNaiveForecaster(season_length)Repeats values from the most recent completed seasonal cycle.

Use both baselines before moving to ARIMA, ETS, Theta, Kalman, or lagged CartoBoost models. The naive baseline compares against the latest known value. The seasonal naive baseline compares against the prior cycle, which is often the stronger control for recurring demand.

NaiveForecaster supports ForecastFrame.from_pandas(..., allow_missing_targets=True) by fitting on observed target rows and forecasting from the latest timestamp in the input frame. SeasonalNaiveForecaster still requires observed finite targets at every regular step because its season_length indexes previous rows in a complete seasonal cycle.

Scientific Role

These models are not weak because they are simple; they are the control group. They encode two clear hypotheses:

HypothesisModelWhat a scientist learns
Demand persists from the most recent observation.NaiveWhether short-horizon inertia explains the target.
Demand repeats by a fixed cycle.Seasonal naiveWhether the calendar phase explains the target without learned parameters.

Choose naive or seasonal naive when you need an auditable baseline, a leakage check, or a minimum bar for a richer model. A model that does not clear seasonal naive on recurring hourly demand may only be restating the cycle with more machinery.

Assumptions And Failure Modes

Naive assumes the level is locally stable over the forecast horizon. It fails when demand is moving into or out of a peak, when a disruption shifts the level, or when the last point is an outlier.

Seasonal naive assumes the last completed cycle is representative of the next cycle. It fails when the same point in the prior cycle is not comparable because of holidays, weather, event schedules, or a real regime change in the series. If a series does not contain a complete configured cycle, fitting fails explicitly; the season length is never shortened to make the model run. A season length that is numerically feasible but does not match the data cadence still produces the wrong comparison, so verify both the declared frequency and cycle definition.

Python Example

from cartoboost.forecasting import NaiveForecaster, SeasonalNaiveForecaster

hourly_demand = [42, 38, 35, 31, 44, 67, 91, 105, 98, 86, 73, 69]

last_value = NaiveForecaster().fit(hourly_demand)
last_cycle = SeasonalNaiveForecaster(season_length=6).fit(hourly_demand)

print(last_value.predict(3).predictions())
print(last_cycle.predict(3).predictions())

Interpret the result directly:

Output patternMeaningTypical next step
Naive and seasonal naive are close.The latest observation is already a good short-horizon summary.Compare against Kalman or ETS before adding many lag features.
Seasonal naive has much lower error.Hour-of-day or day-of-week repetition dominates.Keep the seasonal baseline in every validation table.
Naive has lower error than seasonal naive.The recent level shifted away from the prior cycle.Check for events, holidays, weather disruption, or zone-level regime changes.
Both baselines miss the same periods.Repeated calendar cycles are not enough.Add exogenous features, graph features, or a lagged CartoBoost model.

Pickup-Zone Panel Example

from cartoboost.forecasting import ForecastFrame, SeasonalNaiveForecaster

frame = ForecastFrame.from_pandas(
hourly_zone_demand,
timestamp_col="timestamp",
target_col="demand",
series_id_col="zone_id",
freq="h",
)

model = SeasonalNaiveForecaster(season_length=24)
model.fit(frame)
forecast = model.predict(12)

for row in forecast.predictions()[:5]:
print(row)

ForecastFrame keeps each zone_id separate. For a 24-hour seasonal naive model, the next forecast for zone 132 uses zone 132 from 24 hours ago; it does not borrow observations from zone 236 or any other panel.

Visual Example

Run the committed visualization example:

uv run python examples/forecasting/naive_seasonal_visualization.py

It writes target/examples/naive_seasonal_visualization.png and prints a JSON summary with rows, panels, train horizon, forecast horizon, MAE, RMSE, and the seasonal-naive RMSE delta versus naive. The example generates deterministic panel demand, so it does not download data or write tracked benchmark artifacts.

The plot compares three lines:

  • observed hourly counts,
  • the flat naive forecast from the last observed hour,
  • the seasonal naive forecast from the previous daily cycle.

The core plotting pattern is:

from pathlib import Path

import matplotlib.pyplot as plt
import pandas as pd
from cartoboost.forecasting import ForecastFrame, NaiveForecaster, SeasonalNaiveForecaster

train = hourly_zone_demand.groupby("zone_id", sort=False).head(96)
frame = ForecastFrame.from_pandas(
train,
timestamp_col="timestamp",
target_col="demand",
series_id_col="zone_id",
freq="h",
)

naive = NaiveForecaster().fit(frame).predict(24).predictions()
seasonal = SeasonalNaiveForecaster(season_length=24).fit(frame).predict(24).predictions()

naive_forecast = pd.DataFrame(
naive,
columns=["zone_id", "timestamp", "horizon", "model", "prediction"],
)
seasonal_forecast = pd.DataFrame(
seasonal,
columns=["zone_id", "timestamp", "horizon", "model", "prediction"],
)

zone_id = "132"
observed = hourly_zone_demand[hourly_zone_demand["zone_id"] == zone_id]
naive_zone = naive_forecast[naive_forecast["zone_id"] == zone_id]
seasonal_zone = seasonal_forecast[seasonal_forecast["zone_id"] == zone_id]

plt.plot(observed["timestamp"], observed["demand"], label="observed counts")
plt.plot(naive_zone["timestamp"], naive_zone["prediction"], label="naive")
plt.plot(seasonal_zone["timestamp"], seasonal_zone["prediction"], label="seasonal naive")
plt.xlabel("timestamp")
plt.ylabel("count")
plt.legend()

Path("target/examples").mkdir(parents=True, exist_ok=True)
plt.savefig("target/examples/naive_seasonal_demand.png", dpi=160)

Interpretation:

Visual patternMeaningTypical next step
Naive is a horizontal line.This is expected: it repeats the last observed value.Use it as a leakage and horizon-alignment smoke test.
Seasonal naive follows the prior day's shape.The daily profile is stable enough to forecast from the last cycle.Compare complex models against this, not only against naive.
Seasonal naive is shifted above or below actuals.The daily shape is useful but the level moved.Try Kalman, ETS, or lagged features that can adjust level.
Seasonal naive gets rush hours wrong.The previous cycle did not capture the current rush-hour pattern.Add holiday/event/weather features or validate separate zone groups.

Parameters

ParameterApplies toNotes
season_lengthSeasonalNaiveForecasterRequired positive integer. Use 24 for hourly daily seasonality and 168 for hourly weekly seasonality.
prediction_interval_levelsBoth wrappersValidated as values between 0 and 1; interval support depends on the model output.

Choosing season_length

Match season_length to the row spacing in the ForecastFrame.

Data frequencyTypical questionCommon season_length
Hourly countsDoes this hour behave like the same hour yesterday?24
Hourly countsDoes this hour behave like the same hour last week?168
Daily countsDoes this date behave like the same weekday last week?7
15-minute countsDoes this interval behave like the same interval yesterday?96

Do not use 24 for daily data or 7 for hourly data unless the rows have been aggregated to that cadence. A wrong season length can look plausible in a plot while comparing the wrong historical period.

Backtest Guidance

Score these baselines with the same split, horizon, and aggregation used for candidate models:

from cartoboost.forecasting import (
NaiveForecaster,
RollingOriginBacktester,
RollingOriginSplitter,
SeasonalNaiveForecaster,
)

splitter = RollingOriginSplitter(horizon=24, step=24, min_train_size=72)
backtester = RollingOriginBacktester(splitter=splitter)

naive_result = backtester.evaluate(NaiveForecaster(), frame)
seasonal_result = backtester.evaluate(SeasonalNaiveForecaster(season_length=24), frame)

Keep the seasonal naive score in model-selection reports for hourly demand. If a richer model only clears naive, it may only be learning the daily cycle rather than adding useful zone, graph, weather, or calendar signal.

Validation

Seasonal naive is the minimum meaningful baseline for strongly seasonal demand. If a more complex model does not clear seasonal naive under rolling-origin backtests, inspect feature leakage, horizon alignment, and whether the model is overfitting repeated panels.

These models do not learn trend, holiday effects, disruption, zone spillover, or graph structure. That limitation is useful: when they perform well, the repeated cycle is strong; when they fail, the residuals show where richer forecasting models need to explain the system.

Limitations

  • Naive methods cannot anticipate trend changes, events, or spatial spillover.
  • Seasonal naive needs a correct frequency and enough history for the chosen period.
  • Missing timestamps must be resolved before lag alignment is meaningful.
  • Strong baseline performance means added complexity must earn its cost.