Skip to content

Commit 924aa6f

Browse files
authored
Mark forecasting tasks complete (#31)
1 parent d8895bb commit 924aa6f

5 files changed

Lines changed: 109 additions & 0 deletions

File tree

TODO.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,12 @@ After completing a milestone, create a pull request with your changes for review
207207
- [x] Add global theme toggle stored in session state
208208
- [x] Update tests to verify theme CSS output
209209

210+
## PR21: Forecasting Models
211+
212+
- [x] Create utils/time_series.py with ARIMA and naive forecast functions
213+
- [x] Add UI controls on Time Series page for model selection and forecast horizon
214+
- [x] Write tests for forecasting utilities
215+
210216
## Notes for Development
211217

212218
- Create comprehensive commit messages that clearly describe changes

pages/time_series.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import streamlit as st
88

99
from utils import ui, viz
10+
from utils import time_series as ts
1011
from utils.logging import configure_logging
1112

1213
configure_logging()
@@ -38,6 +39,12 @@ def main() -> None:
3839
period = st.number_input(
3940
"Seasonal Period", min_value=2, value=2, step=1, key="ts_period"
4041
)
42+
model_choice = st.selectbox(
43+
"Forecast Model", ["Naive", "ARIMA"], key="ts_model"
44+
)
45+
horizon = st.number_input(
46+
"Forecast Horizon", min_value=1, value=5, step=1, key="ts_horizon"
47+
)
4148

4249
if st.button("Generate Plots"):
4350
ts_fig = viz.time_series_plot(df, time_col, value_col, title="Time Series")
@@ -52,6 +59,20 @@ def main() -> None:
5259
mime=f"image/{export_fmt}",
5360
)
5461

62+
series = df.set_index(time_col)[value_col]
63+
if model_choice == "ARIMA":
64+
try:
65+
forecast = ts.arima_forecast(series, steps=horizon)
66+
except ImportError:
67+
st.error("statsmodels is required for ARIMA forecasting.")
68+
forecast = None
69+
else:
70+
forecast = ts.naive_forecast(series, steps=horizon)
71+
72+
if forecast is not None:
73+
st.subheader("Forecast")
74+
st.write(forecast.to_frame(name="forecast"))
75+
5576
dec_fig = viz.decomposition_plot(
5677
df.set_index(time_col)[value_col], period=period, title="Decomposition"
5778
)

tests/test_pages.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@ def test_time_series_page_contents():
112112
assert "decomposition_plot" in content
113113

114114

115+
def test_time_series_page_forecast_widgets():
116+
with open("pages/time_series.py", "r", encoding="utf-8") as f:
117+
content = f.read()
118+
assert "Forecast Model" in content
119+
assert "Forecast Horizon" in content
120+
121+
115122
def test_datetime_cols_persist_after_transforms():
116123
import streamlit as st
117124
from utils import transform, eda

tests/test_time_series_utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import pandas as pd
2+
import pytest
3+
4+
from utils import time_series
5+
6+
7+
def sample_series():
8+
return pd.Series(
9+
range(5), index=pd.date_range("2021-01-01", periods=5, freq="D")
10+
)
11+
12+
13+
def test_naive_forecast_extends_index():
14+
series = sample_series()
15+
forecast = time_series.naive_forecast(series, steps=3)
16+
assert len(forecast) == 3
17+
assert forecast.iloc[0] == series.iloc[-1]
18+
assert forecast.index[0] == series.index[-1] + series.index.freq
19+
20+
21+
def test_arima_forecast_runs_or_errors():
22+
series = sample_series()
23+
try:
24+
fc = time_series.arima_forecast(series, steps=2)
25+
assert len(fc) == 2
26+
except ImportError:
27+
pytest.skip("statsmodels not available")
28+
29+

utils/time_series.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Time series forecasting utilities."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Tuple
6+
7+
import pandas as pd
8+
9+
10+
def _extend_index(index: pd.Index, steps: int) -> pd.Index:
11+
"""Return an extended index for forecast values."""
12+
if isinstance(index, pd.DatetimeIndex) and index.freq is not None:
13+
start = index[-1] + index.freq
14+
return pd.date_range(start, periods=steps, freq=index.freq)
15+
return pd.RangeIndex(index[-1] + 1, index[-1] + 1 + steps)
16+
17+
18+
def naive_forecast(series: pd.Series, steps: int = 1) -> pd.Series:
19+
"""Forecast future values using the last observed value."""
20+
last = series.iloc[-1]
21+
index = _extend_index(series.index, steps)
22+
return pd.Series([last] * steps, index=index, name="naive_forecast")
23+
24+
25+
def arima_forecast(
26+
series: pd.Series,
27+
*,
28+
order: Tuple[int, int, int] = (1, 1, 0),
29+
steps: int = 1,
30+
) -> pd.Series:
31+
"""Forecast future values using an ARIMA model.
32+
33+
Requires the ``statsmodels`` package. If it is not installed an
34+
``ImportError`` is raised.
35+
"""
36+
try:
37+
from statsmodels.tsa.arima.model import ARIMA
38+
except Exception as exc: # pragma: no cover - optional dependency
39+
raise ImportError("statsmodels is required for ARIMA forecasting") from exc
40+
41+
model = ARIMA(series, order=order)
42+
fitted = model.fit()
43+
forecast = fitted.forecast(steps=steps)
44+
index = _extend_index(series.index, steps)
45+
forecast.index = index
46+
return forecast

0 commit comments

Comments
 (0)