Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add seasonality detection using periodograms, and Python/JS bindings #61

Merged
merged 5 commits into from
Feb 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading