Skip to content

Commit 5bfc16b

Browse files
committed
1. updated docstring indentation
2. updated generate forecast utility 3. updated how mode=JAX is specified 4. renamed simple model to Newtonian model 5. added explanations to exogenous data duplicating and mirroring 6. added note on how the newtonian model differs from typical parameteric models
1 parent 931b30d commit 5bfc16b

File tree

2 files changed

+233
-445
lines changed

2 files changed

+233
-445
lines changed

examples/case_studies/ssm_hurricane_tracking.ipynb

Lines changed: 148 additions & 350 deletions
Large diffs are not rendered by default.

examples/case_studies/ssm_hurricane_tracking.myst.md

Lines changed: 85 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,9 @@ We wrote the equation for $P_{t\|t}$ above using Joseph form, which is more nume
7979
```{code-cell} ipython3
8080
# Import libraries
8181
import re
82+
import warnings
8283
83-
from typing import Optional
84+
warnings.filterwarnings("ignore", message="The RandomType SharedVariables", category=UserWarning)
8485
8586
import arviz as az
8687
import arviz.labels as azl
@@ -120,8 +121,8 @@ def ellipse_covariance(covariance: np.ndarray) -> np.ndarray:
120121
121122
Parameters
122123
----------
123-
covariance : ndarray
124-
The estimated covariance matrix
124+
covariance : ndarray
125+
The estimated covariance matrix
125126
126127
Returns
127128
-------
@@ -158,14 +159,14 @@ def plot_hurricane_path(
158159
159160
Parameters
160161
----------
161-
data : DataFrame
162-
dataframe containing the actual values
163-
posterior_mean : ndarray
164-
The posterior mean of the estimated distributions
165-
predicted_covariance : ndarray
166-
The predicted covariance matrices at each time point
167-
uncertainty_index : int
168-
When to start drawing the uncertainty on the map (due to huge uncertainty in the begining of the process)
162+
data : DataFrame
163+
dataframe containing the actual values
164+
posterior_mean : ndarray
165+
The posterior mean of the estimated distributions
166+
predicted_covariance : ndarray
167+
The predicted covariance matrices at each time point
168+
uncertainty_index : int
169+
When to start drawing the uncertainty on the map (due to huge uncertainty in the begining of the process)
169170
170171
Returns
171172
-------
@@ -241,27 +242,27 @@ def generate_period_forecasts(
241242
ssm_model: PyMCStateSpace,
242243
inference_data: az.InferenceData,
243244
data: pl.DataFrame,
244-
exogenous_data_name: Optional[str] = None,
245-
padded_exogenous_data: Optional[pl.DataFrame | np.ndarray] = None,
245+
exogenous_data_name: str | None = None,
246+
padded_exogenous_data: pl.DataFrame | np.ndarray | None = None,
246247
periods: int = 4,
247248
) -> tuple[np.ndarray, np.ndarray]:
248249
"""
249250
Produces an n-period ahead forecast.
250251
251252
Parameters
252253
----------
253-
ssm_model : PyMCStateSpace
254-
The statespace model you want to forecast with
255-
inference_data : InferenceData
256-
The fitted model trace
257-
data : DataFrame
258-
The actual data used for fitting
259-
exogenous_data_name : str | None
260-
The key/name you used to define your exogenous data
261-
padded_exogenous_data : DataFrame | ndarray | None
262-
The exogenous data padded for the number of forecasts you expect to make
263-
periods : int
264-
The number of periods to forecast
254+
ssm_model : PyMCStateSpace
255+
The statespace model you want to forecast with
256+
inference_data : InferenceData
257+
The fitted model trace
258+
data : DataFrame
259+
The actual data used for fitting
260+
exogenous_data_name : str | None
261+
The key/name you used to define your exogenous data
262+
padded_exogenous_data : DataFrame | ndarray | None
263+
The exogenous data padded for the number of forecasts you expect to make
264+
periods : int
265+
The number of periods to forecast
265266
266267
Returns
267268
-------
@@ -271,44 +272,27 @@ def generate_period_forecasts(
271272
posterior predictive variance-covariance matrix
272273
273274
"""
274-
if padded_exogenous_data is not None:
275-
276-
period_forecasts = []
277-
if isinstance(padded_exogenous_data, pl.DataFrame):
278-
for start in np.arange(0, inference_data.constant_data.time.shape[0], periods):
279-
f = ssm_model.forecast(
280-
inference_data,
281-
start=start,
282-
periods=periods,
283-
filter_output="smoothed",
284-
progressbar=False,
285-
scenario={
286-
exogenous_data_name: padded_exogenous_data.slice(start, periods).to_numpy()
287-
},
288-
)
289-
period_forecasts.append(f)
290-
else:
291-
for start in np.arange(0, inference_data.constant_data.time.shape[0], periods):
292-
f = ssm_model.forecast(
293-
inference_data,
294-
start=start,
295-
periods=periods,
296-
filter_output="smoothed",
297-
progressbar=False,
298-
scenario={exogenous_data_name: padded_exogenous_data[start : start + periods]},
299-
)
300-
period_forecasts.append(f)
301-
else:
302-
period_forecasts = []
303-
for start in np.arange(0, inference_data.constant_data.time.shape[0], periods):
304-
f = ssm_model.forecast(
305-
inference_data,
306-
start=start,
307-
periods=periods,
308-
filter_output="smoothed",
309-
progressbar=False,
310-
)
311-
period_forecasts.append(f)
275+
n_t = inference_data.constant_data.time.shape[0]
276+
time_steps = np.arange(0, n_t, periods)
277+
period_forecasts = []
278+
279+
for start in time_steps:
280+
scenario = None
281+
if padded_exogenous_data is not None:
282+
if isinstance(padded_exogenous_data, pl.DataFrame):
283+
exog_slice = padded_exogenous_data.slice(start, periods).to_numpy()
284+
else:
285+
exog_slice = padded_exogenous_data[start : start + periods]
286+
scenario = {exogenous_data_name: exog_slice}
287+
f = ssm_model.forecast(
288+
inference_data,
289+
start=start,
290+
periods=periods,
291+
filter_output="smoothed",
292+
progressbar=False,
293+
scenario=scenario,
294+
)
295+
period_forecasts.append(f)
312296
313297
# Combine forecasts
314298
forecasts = xr.combine_by_coords(period_forecasts, combine_attrs="drop_conflicts")
@@ -757,13 +741,14 @@ We are going to use the `PyMC` `StateSpace` module in the `pymc-extras` package
757741
We will set deterministic values for both the initial values $x_{0}$ and $P_{0}$. Therefore, in this simplest model, we will only estimate one parameter $\sigma_{a}^{2}$
758742

759743
```{code-cell} ipython3
760-
class SimpleSSM(PyMCStateSpace):
761-
def __init__(self):
744+
class NewtonianSSM(PyMCStateSpace):
745+
def __init__(self, mode=None):
762746
k_states = 6 # number of states (x, y, vx, vy, ax, ay)
763747
k_posdef = 6 # number of shocks (size of the process innovations covariance matrix Q)
764748
k_endog = 2 # number of observed states (we only observe x and y)
749+
mode = mode
765750
766-
super().__init__(k_endog=k_endog, k_states=k_states, k_posdef=k_posdef)
751+
super().__init__(k_endog=k_endog, k_states=k_states, k_posdef=k_posdef, mode=mode)
767752
768753
def make_symbolic_graph(self):
769754
delta_t = 6.0 # The amount of time between observations 6 hours in our case
@@ -881,41 +866,46 @@ class SimpleSSM(PyMCStateSpace):
881866
```
882867

883868
```{code-cell} ipython3
884-
s_ssm = SimpleSSM()
869+
n_ssm = NewtonianSSM(mode="JAX")
885870
```
886871

887872
```{code-cell} ipython3
888-
with pm.Model(coords=s_ssm.coords) as simple:
873+
with pm.Model(coords=n_ssm.coords) as newtonian:
889874
x0 = pm.Deterministic("x0", pt.as_tensor([-49, 16, 0.0, 0.0, 0.0, 0.0]), dims="state")
890875
P0 = pt.eye(6) * 1e-3
891876
P0 = pm.Deterministic("P0", P0, dims=("state", "state_aux"))
892877
893878
acceleration_innovations = pm.Gamma("acceleration_innovations", 0.1, 5, shape=(1,))
894879
895-
s_ssm.build_statespace_graph(
880+
n_ssm.build_statespace_graph(
896881
data=fiona_df.select("longitude", "latitude").to_numpy(),
897-
mode="JAX",
898882
save_kalman_filter_outputs_in_idata=True,
899883
)
900-
simple_idata = pm.sample(
884+
newtonian_idata = pm.sample(
901885
nuts_sampler="nutpie", nuts_sampler_kwargs={"backend": "jax", "gradient_backend": "jax"}
902886
)
903887
```
904888

905889
```{code-cell} ipython3
906-
az.summary(simple_idata, var_names="acceleration_innovations", kind="stats")
890+
az.summary(newtonian_idata, var_names="acceleration_innovations", kind="stats")
907891
```
908892

909893
```{code-cell} ipython3
910-
predicted_covs = simple_idata.posterior["predicted_covariance"].mean(("chain", "draw"))
894+
predicted_covs = newtonian_idata.posterior["predicted_covariance"].mean(("chain", "draw"))
911895
```
912896

913897
```{code-cell} ipython3
914-
post_mean = simple_idata.posterior["predicted_observed_state"].mean(("chain", "draw"))
898+
post_mean = newtonian_idata.posterior["predicted_observed_state"].mean(("chain", "draw"))
915899
```
916900

917901
Not bad for a model with only one parameter. We can see that the forecast gets wonky in the middle where the trajectory of the Hurricane changes directions over short time periods. Again, it is important to keep in mind that what we are plotting are the one-step/period ahead forecast. In our case, our periods are six hours apart. Unfortunately, a 6-hour ahead hurricane forecast is not very practical. Let's see what we get when we generate a 4-period (24-hour) ahead forecast.
918902

903+
:::{note}
904+
905+
More often than not we build parametric models that are quite flexible in adapting to the data. In such cases, we have multiple parameters that give the model it's flexibility in fitting the data. In this particular case, we impose a rigid structure on the data by insisting that the data generating process adhere to the laws of Newtonian physics. In doing so, the model is much less flexible with only one parameter that accounts for the unmodeled angular acceleration.
906+
907+
:::
908+
919909
```{code-cell} ipython3
920910
fig = plot_hurricane_path(
921911
data=fiona_df,
@@ -929,17 +919,17 @@ fig.show(config={"displayModeBar": False})
929919
```{code-cell} ipython3
930920
plot_model_evaluations(
931921
*evaluate_haversine(fiona_df.select("longitude", "latitude").to_numpy(), post_mean.values),
932-
main_title="Simple"
922+
main_title="Newtonian"
933923
).show(width=1000, renderer="svg")
934924
```
935925

936-
# Generate 24-hour forecasts with our simple model
926+
# Generate 24-hour forecasts with our Newtonian model
937927

938928
```{code-cell} ipython3
939929
:tags: [hide-output]
940930
941931
f_mean, cppc_vcov = generate_period_forecasts(
942-
ssm_model=s_ssm, inference_data=simple_idata, data=fiona_df, periods=4
932+
ssm_model=n_ssm, inference_data=newtonian_idata, data=fiona_df, periods=4
943933
)
944934
```
945935

@@ -953,11 +943,11 @@ fig.show(config={"displayModeBar": False})
953943
```
954944

955945
```{code-cell} ipython3
956-
simple_errors, simple_cum_error, simple_mean_error = evaluate_haversine(
946+
newtonian_errors, newtonian_cum_error, newtonian_mean_error = evaluate_haversine(
957947
fiona_df.select("longitude", "latitude").to_numpy()[1:], f_mean.values
958948
)
959949
plot_model_evaluations(
960-
simple_errors, simple_cum_error, simple_mean_error, main_title="24-hour Simple"
950+
newtonian_errors, newtonian_cum_error, newtonian_mean_error, main_title="24-hour Newtonian"
961951
).show(width=1000, renderer="svg")
962952
```
963953

@@ -1001,13 +991,14 @@ The additions to the R matrix imply that the exogenous parameters do not exhibit
1001991

1002992
```{code-cell} ipython3
1003993
class ExogenousSSM(PyMCStateSpace):
1004-
def __init__(self, k_exog: int = None):
994+
def __init__(self, k_exog: int = None, mode: str | None = None):
1005995
k_states = 6 + k_exog # number of states (x, y, vx, vy, ax, ay)
1006996
k_posdef = 6 # number of shocks (size of the process innovations covariance matrix Q)
1007997
k_endog = 2 # number of observed states (we only observe x and y)
1008998
self.k_exog = k_exog
999+
mode = mode
10091000
1010-
super().__init__(k_endog=k_endog, k_states=k_states, k_posdef=k_posdef)
1001+
super().__init__(k_endog=k_endog, k_states=k_states, k_posdef=k_posdef, mode=mode)
10111002
10121003
def make_symbolic_graph(self):
10131004
# Add exogenous variables to the model
@@ -1063,10 +1054,10 @@ class ExogenousSSM(PyMCStateSpace):
10631054
Z = pt.as_tensor([[1.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0, 0.0, 0.0]])
10641055
exog_reshape = pt.repeat(
10651056
exogenous_data, self.k_endog, axis=0
1066-
) # We need to reshape the data to fit the proper dimensions
1057+
) # We need to reshape the data to fit the proper dimensions. This step duplicates the data
10671058
exog_reshape = pt.set_subtensor(
10681059
exog_reshape[1::2, :], pt.roll(exog_reshape[1::2, :], 1)
1069-
) # We need to reshape the data to fit the proper dimensions
1060+
) # We need to reshape the data to fit the proper dimensions. This step mirrors the data
10701061
exog_reshape = exog_reshape.reshape((n_obs, self.k_endog, self.k_exog))
10711062
10721063
self.ssm["design"] = pt.concatenate((pt.tile(Z, (n_obs, 1, 1)), exog_reshape), axis=2)
@@ -1208,7 +1199,7 @@ class ExogenousSSM(PyMCStateSpace):
12081199
return info
12091200
```
12101201

1211-
Note that because our variables are uni-dimensional we duplicate and mirror the data to apply each variable in two dimensions. The mirroring occurs in the model definition of the design matrix.
1202+
Note that because our variables are uni-dimensional we duplicate and mirror the data to apply each variable in two dimensions. The mirroring occurs in the model definition of the design matrix. This is necessary to ensure that the correct data is associated with the proper state during matrix multiplication. Take a look at how the states are defined above. In order to have a univariate variable apply to both the longitude and latitude positions (our observed states) we must duplicate our exogenous data for each observed stated and mirror the data so that when we are multiplying the exogenous data with the longitudinal states the data is non-zero on the longitudinal state but is zero for the latitudinal states and vice versa.
12121203

12131204
```{code-cell} ipython3
12141205
X_exog = (
@@ -1234,7 +1225,7 @@ X_exog = (
12341225
```
12351226

12361227
```{code-cell} ipython3
1237-
exog_ssm = ExogenousSSM(k_exog=X_exog.shape[1])
1228+
exog_ssm = ExogenousSSM(k_exog=X_exog.shape[1], mode="JAX")
12381229
```
12391230

12401231
```{code-cell} ipython3
@@ -1250,7 +1241,6 @@ with pm.Model(coords=exog_ssm.coords) as exogenous:
12501241
12511242
exog_ssm.build_statespace_graph(
12521243
data=fiona_df.select("longitude", "latitude").to_numpy(),
1253-
mode="JAX",
12541244
save_kalman_filter_outputs_in_idata=True,
12551245
)
12561246
exogenous_idata = pm.sample(
@@ -1277,7 +1267,7 @@ predicted_covs = exogenous_idata.posterior["predicted_covariance"].mean(("chain"
12771267
post_mean = exogenous_idata.posterior["predicted_observed_state"].mean(("chain", "draw"))
12781268
```
12791269

1280-
Our one-period ahead forecasts seem to be slightly worse than our simple model. You will notice that at the end of the forecast we see that our trajectory is erroneously more north rather than north-east. Since the exogenous variables we added to the model don't carry additional information with respect to the hurricane's trajectory, this results are expected.
1270+
Our one-period ahead forecasts seem to be slightly worse than our Newtonian model. You will notice that at the end of the forecast we see that our trajectory is erroneously more north rather than north-east. Since the exogenous variables we added to the model don't carry additional information with respect to the hurricane's trajectory, this results are expected.
12811271

12821272
```{code-cell} ipython3
12831273
fig = plot_hurricane_path(
@@ -1464,13 +1454,14 @@ and again the 0 in the matrix is a matrix of 0s with shape (number of spline par
14641454

14651455
```{code-cell} ipython3
14661456
class SplineSSM(PyMCStateSpace):
1467-
def __init__(self, k_exog: int = None):
1457+
def __init__(self, k_exog: int = None, mode: str | None = None):
14681458
k_states = 6 + k_exog # number of states (x, y, vx, vy, ax, ay)
14691459
k_posdef = 6 # number of shocks (size of the process innovations covariance matrix Q)
14701460
k_endog = 2 # number of observed states (we only observe x and y)
14711461
self.k_exog = k_exog
1462+
mode = mode
14721463
1473-
super().__init__(k_endog=k_endog, k_states=k_states, k_posdef=k_posdef)
1464+
super().__init__(k_endog=k_endog, k_states=k_states, k_posdef=k_posdef, mode=mode)
14741465
14751466
def make_symbolic_graph(self):
14761467
# Add exogenous variables to the model
@@ -1673,7 +1664,7 @@ class SplineSSM(PyMCStateSpace):
16731664
```
16741665

16751666
```{code-cell} ipython3
1676-
spline_ssm = SplineSSM(k_exog=exog_data.shape[1])
1667+
spline_ssm = SplineSSM(k_exog=exog_data.shape[1], mode="JAX")
16771668
```
16781669

16791670
```{code-cell} ipython3
@@ -1689,7 +1680,6 @@ with pm.Model(coords=spline_ssm.coords) as spline_model:
16891680
16901681
spline_ssm.build_statespace_graph(
16911682
data=fiona_df.select("longitude", "latitude").to_numpy(),
1692-
mode="JAX",
16931683
save_kalman_filter_outputs_in_idata=True,
16941684
)
16951685
spline_idata = pm.sample(
@@ -1776,17 +1766,17 @@ plot_model_evaluations(
17761766
# Closing Remarks
17771767
In this case study we looked at how we can track a hurricane in two-dimensional space using a state space representation of Newtonian kinematics. We proceeded to expand on the pure Newtonian model and added exogenous variables that may hold information pertintent to the Hurricane's track. We then expanded our model by modeling our variables as smooth functions using cubic B-splines.
17781768

1779-
Throughout, the case study we have been evaluating our 24-hour forecasts and our overall mean error is smallest with our first simple model. Below you will find the errors from all three models plotting against one another. It seems that (as expected) the exogenous information we included in the exogenous model was not informative with respect to the hurricances' trajectory. However, it is worth noting that in the period (around 40 through 56) where the hurricane manuevers we obtain less spikes in error in that section with our cubic B-spline model. This implies that the model could benefit from some non-linear specification to handle the angular acceleration. Hopefully, someday the `StateSpace` module in `pymc-extras` may support non-linear state space specifications with either the Extended Kalman Filter or with the Unscented Kalman Filter. Until then you can learn more about how to build your own custom state space models with the `StateSpace` module here {ref}`Making a Custom Statespace Model <>`.
1769+
Throughout, the case study we have been evaluating our 24-hour forecasts and our overall mean error is smallest with our first Newtonian model. Below you will find the errors from all three models plotting against one another. It seems that (as expected) the exogenous information we included in the exogenous model was not informative with respect to the hurricances' trajectory. However, it is worth noting that in the period (around 40 through 56) where the hurricane manuevers we obtain less spikes in error in that section with our cubic B-spline model. This implies that the model could benefit from some non-linear specification to handle the angular acceleration. Hopefully, someday the `StateSpace` module in `pymc-extras` may support non-linear state space specifications with either the Extended Kalman Filter or with the Unscented Kalman Filter. Until then you can learn more about how to build your own custom state space models with the `StateSpace` module here {ref}`Making a Custom Statespace Model <>`.
17801770

17811771
```{code-cell} ipython3
17821772
fig = go.Figure()
17831773
fig.add_traces(
17841774
[
17851775
go.Scatter(
1786-
x=np.arange(len(simple_errors)) + 1,
1787-
y=simple_errors,
1776+
x=np.arange(len(newtonian_errors)) + 1,
1777+
y=newtonian_errors,
17881778
mode="markers+lines",
1789-
name="Simple Model Errors",
1779+
name="Newtonian Model Errors",
17901780
hovertemplate="<b>Period</b>: %{x}<br><b>Miles Away</b>: %{y}",
17911781
),
17921782
go.Scatter(

0 commit comments

Comments
 (0)