Skip to content

Commit

Permalink
Add seasonality detection using periodograms, and Python/JS bindings (#…
Browse files Browse the repository at this point in the history
…61)

* Add seasonality detection using periodograms, and Python/JS bindings

* Use u32 instead of usize; return Vec instead of impl Iterator for lower MSRV

* Change Detector trait to pass data to detect

This means detectors don't need to store the data and can avoid
a lifetime parameter.

* Allow seasonality detector params to be customised in JS bindings

* Allow seasonality detector params to be customised in Python bindings
  • Loading branch information
sd2k authored Feb 15, 2024
1 parent 8a293a0 commit 14ffaa6
Show file tree
Hide file tree
Showing 16 changed files with 1,956 additions and 1 deletion.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ keywords = [
augurs-core = { version = "0.1.0-alpha.0", path = "crates/augurs-core" }
augurs-ets = { version = "0.1.0-alpha.0", path = "crates/augurs-ets" }
augurs-mstl = { version = "0.1.0-alpha.0", path = "crates/augurs-mstl" }
augurs-seasons = { version = "0.1.0-alpha.0", path = "crates/augurs-seasons" }
augurs-testing = { version = "0.1.0-alpha.0", path = "crates/augurs-testing" }

distrs = "0.2.1"
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ APIs are subject to change, and functionality may not be fully implemented.
| [`augurs-core`][] | Common structs and traits | alpha - API is flexible right now |
| [`augurs-ets`][] | Automatic exponential smoothing models | alpha - non-seasonal models working and tested against statsforecast |
| [`augurs-mstl`][] | Multiple Seasonal Trend Decomposition using LOESS (MSTL) | beta - working and tested against R |
| [`augurs-seasons`][] | Seasonality detection using periodograms | alpha - working and tested against Python in limited scenarios |
| [`augurs-testing`][] | Testing data and, eventually, evaluation harness for implementations | alpha - just data right now |
| [`augurs-js`][] | WASM bindings to augurs | alpha - untested, should work though |
| [`pyaugurs`][] | Python bindings to augurs | alpha - untested, should work though |
Expand All @@ -40,5 +41,6 @@ Licensed under the Apache License, Version 2.0 `<http://www.apache.org/licenses/
[`augurs-ets`]: https://crates.io/crates/augurs-ets
[`augurs-mstl`]: https://crates.io/crates/augurs-mstl
[`augurs-js`]: https://crates.io/crates/augurs-js
[`augurs-seasons`]: https://crates.io/crates/augurs-seasons
[`augurs-testing`]: https://crates.io/crates/augurs-testing
[`pyaugurs`]: https://crates.io/crates/pyaugurs
2 changes: 2 additions & 0 deletions crates/augurs-js/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ default = ["console_error_panic_hook"]
augurs-core = { workspace = true, features = ["serde"] }
augurs-ets = { workspace = true, features = ["mstl", "serde"] }
augurs-mstl = { workspace = true, features = ["serde"] }
augurs-seasons = { workspace = true }
# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for
# code size when deploying.
console_error_panic_hook = { version = "0.1.7", optional = true }
getrandom = { version = "0.2.10", features = ["js"] }
js-sys = "0.3.64"
serde.workspace = true
serde-wasm-bindgen = "0.6.0"
tracing-wasm = { version = "0.2.1", optional = true }
wasm-bindgen = "0.2.87"
1 change: 1 addition & 0 deletions crates/augurs-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use wasm_bindgen::prelude::*;

pub mod ets;
pub mod mstl;
pub mod seasons;

/// Initialize the logger and panic hook.
///
Expand Down
54 changes: 54 additions & 0 deletions crates/augurs-js/src/seasons.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//! Javascript bindings for augurs seasonality detection.
use serde::Deserialize;
use wasm_bindgen::prelude::*;

use augurs_seasons::{Detector, PeriodogramDetector};

/// Options for detecting seasonal periods.
#[derive(Debug, Default, Deserialize)]
pub struct SeasonalityOptions {
/// The minimum period to consider when detecting seasonal periods.
///
/// The default is 4.
pub min_period: Option<u32>,

/// The maximum period to consider when detecting seasonal periods.
///
/// The default is the length of the data divided by 3, or 512, whichever is smaller.
pub max_period: Option<u32>,

/// The threshold for detecting peaks in the periodogram.
///
/// The value will be clamped to the range 0.01 to 0.99.
///
/// The default is 0.9.
pub threshold: Option<f64>,
}

impl From<SeasonalityOptions> for PeriodogramDetector {
fn from(options: SeasonalityOptions) -> Self {
let mut builder = PeriodogramDetector::builder();
if let Some(min_period) = options.min_period {
builder = builder.min_period(min_period);
}
if let Some(max_period) = options.max_period {
builder = builder.max_period(max_period);
}
if let Some(threshold) = options.threshold {
builder = builder.threshold(threshold);
}
builder.build()
}
}

/// Detect the seasonal periods in a time series.
#[wasm_bindgen]
pub fn seasonalities(y: &[f64], options: JsValue) -> Vec<u32> {
let options: SeasonalityOptions =
serde_wasm_bindgen::from_value::<Option<SeasonalityOptions>>(options)
.ok()
.flatten()
.unwrap_or_default();
PeriodogramDetector::from(options).detect(y)
}
26 changes: 26 additions & 0 deletions crates/augurs-seasons/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[package]
name = "augurs-seasons"
version.workspace = true
authors.workspace = true
documentation.workspace = true
repository.workspace = true
license.workspace = true
edition.workspace = true
keywords.workspace = true
description = "Seasonality detection using periodograms"

[dependencies]
itertools.workspace = true
num-traits = "0.2.18"
thiserror.workspace = true
tracing.workspace = true
welch-sde = "0.1.0"

[dev-dependencies]
augurs-testing.workspace = true
criterion.workspace = true
pprof.workspace = true

[[bench]]
name = "periodogram"
harness = false
49 changes: 49 additions & 0 deletions crates/augurs-seasons/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Seasonality detection for time series

`augurs-seasons` contains methods for detecting seasonality or periodicity in time series.

It currently contains implementations to do so using periodograms, similar to the [`seasonal`] Python package.

## Usage

```rust
use augurs_seasons::{Detector, PeriodogramDetector};

# fn main() {
let y = &[
0.1, 0.3, 0.8, 0.5,
0.1, 0.31, 0.79, 0.48,
0.09, 0.29, 0.81, 0.49,
0.11, 0.28, 0.78, 0.53,
0.1, 0.3, 0.8, 0.5,
0.1, 0.31, 0.79, 0.48,
0.09, 0.29, 0.81, 0.49,
0.11, 0.28, 0.78, 0.53,
];
// Use the detector with default parameters.
let periods = PeriodogramDetector::default().detect(y);
assert_eq!(periods[0], 4);

// Customise the detector using the builder.
let periods = PeriodogramDetector::builder()
.min_period(4)
.max_period(8)
.threshold(0.8)
.build()
.detect(y);
assert_eq!(periods[0], 4);
# }
```

## Credits

This implementation is based heavily on the [`seasonal`] Python package.
It also makes heavy use of the [`welch-sde`] crate.

[`seasonal`]: https://github.com/welch/seasonal
[`welch-sde`]: https://crates.io/crates/welch-sde

## License

Dual-licensed to be compatible with the Rust project.
Licensed under the Apache License, Version 2.0 `<http://www.apache.org/licenses/LICENSE-2.0>` or the MIT license `<http://opensource.org/licenses/MIT>`, at your option.
20 changes: 20 additions & 0 deletions crates/augurs-seasons/benches/periodogram.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
use criterion::{criterion_group, criterion_main, Criterion};
use pprof::criterion::{Output, PProfProfiler};

use augurs_seasons::{Detector, PeriodogramDetector};
use augurs_testing::data::SEASON_EIGHT;

fn season_eight(c: &mut Criterion) {
let y = SEASON_EIGHT;
let detector = PeriodogramDetector::builder().build();
c.bench_function("season_eight", |b| {
b.iter(|| detector.detect(y));
});
}

criterion_group! {
name = benches;
config = Criterion::default().with_profiler(PProfProfiler::new(100, Output::Protobuf));
targets = season_eight
}
criterion_main!(benches);
21 changes: 21 additions & 0 deletions crates/augurs-seasons/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#![doc = include_str!("../README.md")]
#![warn(
missing_docs,
missing_debug_implementations,
rust_2018_idioms,
unreachable_pub
)]

mod periodogram;
#[cfg(test)]
mod test_data;

pub use periodogram::{
Builder as PeriodogramDetectorBuilder, Detector as PeriodogramDetector, Periodogram,
};

/// A detector of periodic signals in a time series.
pub trait Detector {
/// Detects the periods of a time series.
fn detect(&self, data: &[f64]) -> Vec<u32>;
}
Loading

0 comments on commit 14ffaa6

Please sign in to comment.