Skip to content

Commit d47c683

Browse files
Merge pull request #11 from YuminosukeSato/feat/seasonal-state-model
feat: state-space seasonal model matching R bsts AddSeasonal()
2 parents 39d0709 + 9cdf0e6 commit d47c683

12 files changed

Lines changed: 1641 additions & 231 deletions

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ Tests run on every commit with seed-fixed MCMC for deterministic reproduction.
103103
|---|---|---|
104104
| `point_effect_mean` | ±3% relative | Passing on core scenarios |
105105
| `cumulative_effect_total` | ±3% relative | Passing on core scenarios |
106-
| `ci_lower` / `ci_upper` | Tight parity | `±1.5%` no-covariates, `±1%` covariates, explicit Phase 2 acceptance `±3%`, seasonal fixture `±5%` |
106+
| `ci_lower` / `ci_upper` | Tight parity | `±1%` no-covariates, `±1%` covariates, explicit Phase 2 acceptance `±3%`, seasonal `±1%` |
107107
| `p_value` | Significance match | Classification at alpha=0.05 |
108108

109109
### What is matching R and what is not
@@ -118,10 +118,13 @@ Tests run on every commit with seed-fixed MCMC for deterministic reproduction.
118118
| Spike-and-slab variable selection | Matching | Coordinate-wise sampling with StudentSpikeSlabPrior defaults (`expected.r2=0.8`, `prior.df=50`, `prior.information.weight=0.01`, `diagonal.shrinkage=0.5`) |
119119
| expected.model.size | Matching | Unified default `2` in `CausalImpact` and `ModelOptions` |
120120
| expected.r2 = 0.8, prior.df = 50 | Matching | Same documented residual variance prior defaults as BoomSpikeSlab / bsts |
121-
| Seasonal component (`nseasons`, `season_duration`) | Supported | R-compatible API with seasonal fixture coverage |
121+
| Seasonal component (`nseasons`, `season_duration`) | Matching | State-space model matching R bsts `AddSeasonal()` (±1% CI parity) |
122122
| Dynamic regression | Supported | Time-varying coefficients via random-walk FFBS; `dynamic_regression=True` |
123123
| Local linear trend | Supported | Opt in with `state_model="local_linear_trend"` |
124124

125+
Matching = CI-enforced numerical equivalence with R bsts (±3% or tighter).
126+
Supported = Feature implemented, no R parity fixture yet.
127+
125128
Covariate CI bounds are enforced twice: the legacy parity fixture remains tighter than
126129
Phase 2 requirements, and a separate Phase 2 acceptance test keeps the threshold at `±3%`.
127130

docs/api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,8 @@ ci = CausalImpact(data, pre_period, post_period, model_args=opts)
5656
| `expected_model_size` | `int` | 2 | Expected number of active covariates for spike-and-slab prior |
5757
| `dynamic_regression` | `bool` | `False` | Enable time-varying regression coefficients |
5858
| `state_model` | `str` | `"local_level"` | `"local_level"` or `"local_linear_trend"` |
59-
| `nseasons` | `int \| None` | `None` | Seasonal cycle count |
60-
| `season_duration` | `int \| None` | `None` | Duration of each seasonal block; defaults to 1 when `nseasons` is set |
59+
| `nseasons` | `int \| None` | `None` | Seasonal cycle count. `nseasons=1` is equivalent to no seasonal component. |
60+
| `season_duration` | `int \| None` | `None` | Duration of each seasonal block; defaults to 1 when `nseasons` is set. Requires `nseasons` to be set. |
6161

6262
## `CausalImpactResults`
6363

docs/compatibility-matrix.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Comparison of features between R CausalImpact (bsts 1.4.1) and this Python imple
88
|---|---|---|---|
99
| Local level | Yes | Yes | Identical algorithm |
1010
| Local linear trend | Yes | Yes | `state_model="local_linear_trend"` |
11-
| Seasonality | Yes | Yes | R-compatible API with seasonal fixture coverage |
11+
| Seasonality | Yes | Yes | State-space model matching R bsts `AddSeasonal()` |
1212
| Dynamic regression | Yes | Yes | `dynamic_regression=True` |
1313
| Regression (static) | Yes | Yes | Identical algorithm |
1414

@@ -18,7 +18,7 @@ Comparison of features between R CausalImpact (bsts 1.4.1) and this Python imple
1818
|---|---|---|---|
1919
| niter | Yes | Yes | Same default (1000) |
2020
| nseasons | Yes | Yes | `ModelOptions.nseasons` or `model_args["nseasons"]` |
21-
| season.duration | Yes | Yes | `ModelOptions.season_duration` or `model_args["season.duration"]` |
21+
| season.duration | Yes | Yes | `ModelOptions.season_duration` or `model_args["season_duration"]` (R compat: `"season.duration"`) |
2222
| prior.level.sd | Yes | Yes | Same default (0.01) |
2323
| standardize.data | Yes | Yes | Same default (True) |
2424
| expected.model.size | Yes | Yes | Unified default `2` |
@@ -71,7 +71,7 @@ Comparison of features between R CausalImpact (bsts 1.4.1) and this Python imple
7171
|---|---|---|
7272
| point_effect_mean | ±3% relative | Passing |
7373
| cumulative_effect_total | ±3% relative | Passing |
74-
| ci_lower / ci_upper | Tight parity (`±1.5%` no-cov, `±1%` covariates, `±5%` seasonal) | Passing |
74+
| ci_lower / ci_upper | Tight parity (`±1%` no-cov, `±1%` covariates, `±1%` seasonal) | Passing |
7575
| p_value significance | Match at alpha=0.05 | Passing |
7676

7777
Tests run against R CausalImpact 1.4.1 fixtures on every PR.

docs/examples.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,38 @@ Posterior prob. of a causal effect: 99.95%
183183
True effect = 8.0, estimated = 7.57. The 95% CI [5.99, 9.18] contains the true
184184
value.
185185

186+
### How it works
187+
188+
The seasonal component uses a state-space model matching R bsts `AddSeasonal()`.
189+
The state vector is `[μ_t, s_1(t), ..., s_{S-1}(t)]` where `S = nseasons`.
190+
Seasonal states evolve via a sum-to-zero transition: the next season equals
191+
the negative sum of the previous `S-1` seasons plus noise.
192+
193+
### Using `season_duration`
194+
195+
Set `season_duration > 1` when each season spans multiple time steps.
196+
The seasonal transition only fires at season boundaries
197+
(`t % season_duration == 0`); in between, the seasonal state is frozen.
198+
199+
```python
200+
# Using the same data from the seasonal example above
201+
202+
# Daily data with 7-day weekly pattern (default: season_duration=1)
203+
ci = CausalImpact(
204+
data, pre_period, post_period,
205+
model_args={"nseasons": 7, "season_duration": 1, "seed": 42},
206+
)
207+
208+
# Monthly data with quarterly pattern (each quarter = 3 months)
209+
ci = CausalImpact(
210+
data, pre_period, post_period,
211+
model_args={"nseasons": 4, "season_duration": 3, "seed": 42},
212+
)
213+
```
214+
215+
When `season_duration` is omitted, it defaults to 1 (every time step is a new
216+
season). `nseasons=1` is equivalent to no seasonal component.
217+
186218
---
187219

188220
## 4. Dynamic Regression

docs/migration-from-r.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ Key differences:
8181

8282
## Numerical Equivalence
8383

84-
This library verifies ±3% agreement with R CausalImpact on point estimates and cumulative effects across multiple test scenarios, including a seasonal fixture. Tests run on every PR.
84+
This library verifies ±3% agreement with R CausalImpact on point estimates and cumulative effects across multiple test scenarios. Tests run on every PR.
85+
86+
The seasonal model uses the same state-space algorithm as R bsts `AddSeasonal()`, achieving ±1% CI parity on the seasonal fixture.
8587

8688
Differences arise from independent RNG implementations (R's `set.seed` vs Rust's `ChaCha8Rng`), not from algorithmic differences.
8789

0 commit comments

Comments
 (0)