Current section
Files
Jump to
Current section
Files
lib/soothsayer.ex
defmodule Soothsayer do
@moduledoc """
The main module for the Soothsayer library, providing functions for creating, fitting, and using time series forecasting models.
"""
alias Explorer.DataFrame
alias Explorer.Series
alias Soothsayer.Model
alias Soothsayer.Preprocessor
@doc """
Creates a new Soothsayer model with the given configuration.
## Parameters
* `config` - A map containing the model configuration. Defaults to an empty map.
## Returns
A new `Soothsayer.Model` struct.
## Examples
iex> Soothsayer.new()
%Soothsayer.Model{config: %{trend: %{enabled: true}, seasonality: %{yearly: %{enabled: true, fourier_terms: 6}, weekly: %{enabled: true, fourier_terms: 3}}, epochs: 100, learning_rate: 0.01}, network: %Axon.Node{}, params: nil}
iex> Soothsayer.new(%{epochs: 200, learning_rate: 0.005})
%Soothsayer.Model{config: %{trend: %{enabled: true}, seasonality: %{yearly: %{enabled: true, fourier_terms: 6}, weekly: %{enabled: true, fourier_terms: 3}}, epochs: 200, learning_rate: 0.005}, network: %Axon.Node{}, params: nil}
"""
@spec new(map()) :: Soothsayer.Model.t()
def new(config \\ %{}) do
default_config = %{
trend: %{enabled: true},
seasonality: %{
yearly: %{enabled: true, fourier_terms: 6},
weekly: %{enabled: true, fourier_terms: 3}
},
epochs: 100,
learning_rate: 0.01
}
merged_config = deep_merge(default_config, config)
Model.new(merged_config)
end
@doc """
Fits the Soothsayer model to the provided data.
## Parameters
* `model` - A `Soothsayer.Model` struct.
* `data` - An `Explorer.DataFrame` containing the training data.
## Returns
An updated `Soothsayer.Model` struct with fitted parameters.
## Examples
iex> model = Soothsayer.new()
iex> data = Explorer.DataFrame.new(%{"ds" => [...], "y" => [...]})
iex> fitted_model = Soothsayer.fit(model, data)
%Soothsayer.Model{config: %{}, network: %Axon.Node{}, params: %{}}
"""
@spec fit(Soothsayer.Model.t(), Explorer.DataFrame.t()) :: Soothsayer.Model.t()
def fit(%Model{} = model, %DataFrame{} = data) do
processed_data = Preprocessor.prepare_data(data, "y", "ds", model.config.seasonality)
y = processed_data["y"] |> Series.to_tensor() |> Nx.as_type({:f, 32}) |> Nx.new_axis(-1)
{y_normalized, y_mean, y_std} = normalize(y)
x = %{
"trend" =>
processed_data["ds"] |> Series.to_tensor() |> Nx.as_type({:f, 32}) |> Nx.new_axis(-1),
"yearly" => get_seasonality_input(processed_data, :yearly),
"weekly" => get_seasonality_input(processed_data, :weekly)
}
{x_normalized, x_norm} = normalize_inputs(x)
fitted_model = Model.fit(model, x_normalized, y_normalized, model.config.epochs)
%{
fitted_model
| config:
Map.put(model.config, :normalization, %{x: x_norm, y: %{mean: y_mean, std: y_std}})
}
end
@doc """
Makes predictions using a fitted Soothsayer model.
## Parameters
* `model` - A fitted `Soothsayer.Model` struct.
* `x` - An `Explorer.Series` containing the dates for which to make predictions.
## Returns
An `Nx.Tensor` containing the predicted values.
## Examples
iex> fitted_model = Soothsayer.fit(model, training_data)
iex> future_dates = Explorer.Series.from_list([~D[2023-01-01], ~D[2023-01-02], ~D[2023-01-03]])
iex> predictions = Soothsayer.predict(fitted_model, future_dates)
#Nx.Tensor<
f32[3][1]
[
[1.5],
[2.3],
[3.1]
]
>
"""
@spec predict(Soothsayer.Model.t(), Explorer.Series.t()) :: Nx.Tensor.t()
def predict(%Model{} = model, %Series{} = x) do
%{combined: combined} = predict_components(model, x)
combined
end
@doc """
Makes predictions and returns the individual components (trend, seasonality) using a fitted Soothsayer model.
## Parameters
* `model` - A fitted `Soothsayer.Model` struct.
* `x` - An `Explorer.Series` containing the dates for which to make predictions.
## Returns
A map containing the predicted values for each component (trend, yearly seasonality, weekly seasonality) and the combined prediction.
## Examples
iex> fitted_model = Soothsayer.fit(model, training_data)
iex> future_dates = Explorer.Series.from_list([~D[2023-01-01], ~D[2023-01-02], ~D[2023-01-03]])
iex> predictions = Soothsayer.predict_components(fitted_model, future_dates)
%{
combined: #Nx.Tensor<...>,
trend: #Nx.Tensor<...>,
yearly_seasonality: #Nx.Tensor<...>,
weekly_seasonality: #Nx.Tensor<...>
}
"""
@spec predict_components(Soothsayer.Model.t(), Explorer.Series.t()) :: %{
combined: Nx.Tensor.t(),
trend: Nx.Tensor.t(),
yearly_seasonality: Nx.Tensor.t(),
weekly_seasonality: Nx.Tensor.t()
}
def predict_components(%Model{} = model, %Series{} = x) do
processed_x =
Preprocessor.prepare_data(DataFrame.new(%{"ds" => x}), nil, "ds", model.config.seasonality)
x_input = %{
"trend" =>
processed_x["ds"] |> Series.to_tensor() |> Nx.as_type({:f, 32}) |> Nx.new_axis(-1),
"yearly" => get_seasonality_input(processed_x, :yearly),
"weekly" => get_seasonality_input(processed_x, :weekly)
}
x_normalized = normalize_with_params(x_input, model.config.normalization.x)
predictions = Model.predict(model, x_normalized)
Map.new(predictions, fn {key, node} ->
{key, denormalize(node, model.config.normalization.y)}
end)
end
defp get_seasonality_input(data, seasonality) do
columns = data.names |> Enum.filter(&String.starts_with?(&1, Atom.to_string(seasonality)))
data[columns]
|> DataFrame.to_series()
|> Map.values()
|> Enum.map(&Series.to_tensor/1)
|> Nx.stack(axis: 1)
|> Nx.as_type({:f, 32})
end
defp normalize(tensor) do
mean = Nx.mean(tensor, axes: [0])
std = Nx.standard_deviation(tensor, axes: [0])
std = Nx.select(Nx.equal(std, 0), Nx.tensor(1), std)
{Nx.divide(Nx.subtract(tensor, mean), std), mean, std}
end
defp normalize_inputs(x) do
Enum.reduce(x, {%{}, %{}}, fn {key, tensor}, {normalized, norm_params} ->
{normalized_tensor, mean, std} = normalize(tensor)
{Map.put(normalized, key, normalized_tensor),
Map.put(norm_params, key, %{mean: mean, std: std})}
end)
end
defp normalize_with_params(x, norm_params) do
Enum.map(x, fn {key, tensor} ->
mean = norm_params[key].mean
std = norm_params[key].std
{key, Nx.divide(Nx.subtract(tensor, mean), std)}
end)
|> Enum.into(%{})
end
defp denormalize(tensor, %{mean: mean, std: std}) do
Nx.add(Nx.multiply(tensor, std), mean)
end
defp deep_merge(left, right) do
Map.merge(left, right, fn
_, %{} = left, %{} = right -> deep_merge(left, right)
_, _left, right -> right
end)
end
end