Powered by AppSignal & Oban Pro

Soothsayer Tutorial 🧙🔮

livebook/soothsayer_tutorial.livemd

Soothsayer Tutorial 🧙🔮

Mix.install([
  {:soothsayer, ">= 0.0.0"},
  {:explorer, ">= 0.0.0"},
  {:vega_lite, ">= 0.0.0"},
  {:kino_vega_lite, ">= 0.0.0"},
  {:kino_explorer, ">= 0.0.0"},
  {:req, ">= 0.0.0"}
])

alias Explorer.DataFrame
alias Explorer.Series
alias VegaLite, as: Vl

Nx.global_default_backend(EXLA.Backend)

Intro

A progressive guide to time series forecasting with Soothsayer. We’ll start with the basics and incrementally add more powerful features.

1. Basic Model: Trend + Seasonality

Soothsayer decomposes time series into interpretable components:

y(t) = trend(t) + seasonality(t) + ar(t) + events(t)

Let’s start with synthetic data that has trend, yearly seasonality, and weekly seasonality.

Generate synthetic data

:rand.seed(:exsss, {42, 42, 42})

start_date = ~D[2020-01-01]
end_date = ~D[2023-12-31]
dates = Date.range(start_date, end_date)

y =
  Enum.map(dates, fn date ->
    days_since_start = Date.diff(date, start_date)
    trend = 1000 + 0.5 * days_since_start
    yearly = 50 * :math.sin(2 * :math.pi() * days_since_start / 365.25)
    weekly = 20 * :math.cos(2 * :math.pi() * Date.day_of_week(date) / 7)
    noise = :rand.normal(0, 30)
    trend + yearly + weekly + noise
  end)

df = DataFrame.new(%{"ds" => dates, "y" => y})
Vl.new(width: 800, height: 400, title: "Synthetic Time Series Data")
|> Vl.data_from_values(df, only: ["ds", "y"])
|> Vl.mark(:point, opacity: 0.5)
|> Vl.encode_field(:x, "ds", type: :temporal)
|> Vl.encode_field(:y, "y", type: :quantitative)

Create and fit a basic model

model = Soothsayer.new(%{
  trend: %{enabled: true},
  seasonality: %{
    yearly: %{enabled: true, fourier_terms: 6},
    weekly: %{enabled: true, fourier_terms: 3}
  },
  epochs: 50
})

fitted_model = Soothsayer.fit(model, df)

Visualize the neural network

Soothsayer uses a neural network under the hood. Each component (trend, seasonality) is a separate input that gets combined:

# Build input templates matching the network's expected shapes
changepoints = model.config.trend[:changepoints] || 0
yearly_terms = model.config.seasonality.yearly.fourier_terms
weekly_terms = model.config.seasonality.weekly.fourier_terms

input = %{
  "trend" => Nx.template({1, 1 + changepoints}, :f32),
  "yearly" => Nx.template({1, 2 * yearly_terms}, :f32),
  "weekly" => Nx.template({1, 2 * weekly_terms}, :f32)
}

Axon.Display.as_graph(Soothsayer.display_network(model), input)

Make predictions

predictions = Soothsayer.predict(fitted_model, df["ds"])

df_with_predictions =
  df
  |> DataFrame.put("yhat", predictions)
Vl.new(width: 800, height: 400, title: "Actual vs Predicted")
|> Vl.data_from_values(df_with_predictions, only: ["ds", "y", "yhat"])
|> Vl.layers([
  Vl.new()
  |> Vl.mark(:point, opacity: 0.3)
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "y", type: :quantitative),
  Vl.new()
  |> Vl.mark(:line, color: "tomato", stroke_width: 2)
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "yhat", type: :quantitative)
])

Extract components

One of Soothsayer’s strengths is interpretability. We can see what each component contributes:

components = Soothsayer.predict_components(fitted_model, df["ds"])

df_components =
  df
  |> DataFrame.put("trend", components.trend)
  |> DataFrame.put("yearly", components.yearly_seasonality)
  |> DataFrame.put("weekly", components.weekly_seasonality)
Vl.new(width: 800, height: 250, title: "Trend Component")
|> Vl.data_from_values(df_components)
|> Vl.mark(:line, color: "steelblue", stroke_width: 2)
|> Vl.encode_field(:x, "ds", type: :temporal)
|> Vl.encode_field(:y, "trend", type: :quantitative)
Vl.new(width: 800, height: 250, title: "Yearly Seasonality")
|> Vl.data_from_values(df_components)
|> Vl.mark(:line, color: "green", stroke_width: 2)
|> Vl.encode_field(:x, "ds", type: :temporal)
|> Vl.encode_field(:y, "yearly", type: :quantitative)
# Zoom to see weekly pattern
Vl.new(width: 800, height: 250, title: "Weekly Seasonality (3 months)")
|> Vl.data_from_values(df_components)
|> Vl.mark(:line, color: "purple", stroke_width: 2)
|> Vl.encode_field(:x, "ds",
  type: :temporal,
  scale: [domain: ["2023-01-01", "2023-03-31"]]
)
|> Vl.encode_field(:y, "weekly", type: :quantitative)

2. Changepoint Detection

Real-world trends often change slope over time. Changepoints allow the trend to have different slopes at different periods - useful for capturing product launches, market shifts, or policy changes.

Data with a slope change

:rand.seed(:exsss, {42, 42, 42})

n_days = 730  # 2 years
cp_dates = Enum.map(0..(n_days - 1), fn i -> Date.add(~D[2020-01-01], i) end)

# Dramatic slope change: flat first year, steep second year
y_cp =
  Enum.map(0..(n_days - 1), fn i ->
    trend = if i < 365, do: 100 + 0.1 * i, else: 100 + 0.1 * 365 + 3.0 * (i - 365)
    yearly = 20 * :math.sin(2 * :math.pi() * i / 365.25)
    noise = :rand.normal(0, 10)
    trend + yearly + noise
  end)

df_cp = DataFrame.new(%{"ds" => cp_dates, "y" => y_cp})
Vl.new(width: 800, height: 400, title: "Data with Slope Change at Day 365")
|> Vl.data_from_values(df_cp, only: ["ds", "y"])
|> Vl.mark(:point, opacity: 0.5)
|> Vl.encode_field(:x, "ds", type: :temporal)
|> Vl.encode_field(:y, "y", type: :quantitative)

Compare: Simple Linear vs Piecewise Linear

# WITHOUT changepoints
model_linear = Soothsayer.new(%{
  trend: %{changepoints: 0},
  seasonality: %{yearly: %{enabled: true}, weekly: %{enabled: false}},
  epochs: 100
})

fitted_linear = Soothsayer.fit(model_linear, df_cp)
pred_linear = Soothsayer.predict(fitted_linear, df_cp["ds"])
# WITH changepoints
model_piecewise = Soothsayer.new(%{
  trend: %{changepoints: 10, changepoints_range: 0.8},
  seasonality: %{yearly: %{enabled: true}, weekly: %{enabled: false}},
  epochs: 100
})

fitted_piecewise = Soothsayer.fit(model_piecewise, df_cp)
pred_piecewise = Soothsayer.predict(fitted_piecewise, df_cp["ds"])
df_cp_compare =
  df_cp
  |> DataFrame.put("linear", pred_linear)
  |> DataFrame.put("piecewise", pred_piecewise)

Vl.new(width: 800, height: 400, title: "Simple Linear (orange) vs Piecewise Linear (green)")
|> Vl.data_from_values(df_cp_compare)
|> Vl.layers([
  Vl.new()
  |> Vl.mark(:point, opacity: 0.3)
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "y", type: :quantitative),
  Vl.new()
  |> Vl.mark(:line, color: "orange", stroke_width: 3)
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "linear", type: :quantitative),
  Vl.new()
  |> Vl.mark(:line, color: "green", stroke_width: 2)
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "piecewise", type: :quantitative)
])

The green line (piecewise) captures the slope change, while the orange line (simple linear) averages through it.

Network with changepoints

With changepoints enabled, the trend input includes additional features:

input_with_cp = %{
  "trend" => Nx.template({1, 11}, :f32),  # 1 + 10 changepoints
  "yearly" => Nx.template({1, 12}, :f32),
  "weekly" => Nx.template({1, 6}, :f32)
}

Axon.Display.as_graph(Soothsayer.display_network(model_piecewise), input_with_cp)

3. Auto-Regression (AR)

AR captures dependencies on recent values. Enable this when today’s value depends on yesterday’s - common in financial data, sensor readings, and anything with momentum.

Data with momentum

:rand.seed(:exsss, {123, 456, 789})

n_days_ar = 500
ar_dates = Enum.map(0..(n_days_ar - 1), fn i -> Date.add(~D[2022-01-01], i) end)

# AR(1) process: each value depends on the previous
y_ar =
  Enum.reduce(1..(n_days_ar - 1), [100.0], fn _i, [prev | _] = acc ->
    trend = 0.1
    ar = 0.7 * (prev - 100)  # mean-reverting
    noise = :rand.normal(0, 5)
    [100 + trend * length(acc) + ar + noise | acc]
  end)
  |> Enum.reverse()

df_ar = DataFrame.new(%{"ds" => ar_dates, "y" => y_ar})
Vl.new(width: 800, height: 400, title: "Data with Auto-Regressive Pattern")
|> Vl.data_from_values(df_ar, only: ["ds", "y"])
|> Vl.mark(:line)
|> Vl.encode_field(:x, "ds", type: :temporal)
|> Vl.encode_field(:y, "y", type: :quantitative)

Compare: Without AR vs With AR

# WITHOUT AR
model_no_ar = Soothsayer.new(%{
  trend: %{changepoints: 5},
  seasonality: %{yearly: %{enabled: false}, weekly: %{enabled: false}},
  ar: %{enabled: false},
  epochs: 30
})

fitted_no_ar = Soothsayer.fit(model_no_ar, df_ar)
pred_no_ar = Soothsayer.predict(fitted_no_ar, df_ar["ds"])
# WITH AR
model_with_ar = Soothsayer.new(%{
  trend: %{changepoints: 5},
  seasonality: %{yearly: %{enabled: false}, weekly: %{enabled: false}},
  ar: %{enabled: true, lags: 7},
  epochs: 30
})

fitted_with_ar = Soothsayer.fit(model_with_ar, df_ar)
pred_with_ar = Soothsayer.predict(fitted_with_ar, df_ar["ds"])
df_ar_compare =
  df_ar
  |> DataFrame.put("no_ar", pred_no_ar)
  |> DataFrame.put("with_ar", pred_with_ar)

Vl.new(width: 800, height: 400, title: "Trend Only (orange) vs Trend + AR (purple)")
|> Vl.data_from_values(df_ar_compare)
|> Vl.layers([
  Vl.new()
  |> Vl.mark(:line, opacity: 0.5)
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "y", type: :quantitative),
  Vl.new()
  |> Vl.mark(:line, color: "orange", stroke_width: 2)
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "no_ar", type: :quantitative),
  Vl.new()
  |> Vl.mark(:line, color: "purple", stroke_width: 2)
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "with_ar", type: :quantitative)
])

The purple line (with AR) tracks short-term fluctuations much better.

Inspect AR weights

ar_weights = Soothsayer.get_ar_weights(fitted_with_ar)
kernel = ar_weights["ar_dense_out"].kernel |> Nx.to_flat_list()

lag_weights =
  kernel
  |> Enum.with_index(1)
  |> Enum.map(fn {weight, lag} -> %{lag: lag, weight: weight} end)

Vl.new(width: 400, height: 300, title: "AR Lag Weights")
|> Vl.data_from_values(lag_weights)
|> Vl.mark(:bar)
|> Vl.encode_field(:x, "lag", type: :ordinal, title: "Lag")
|> Vl.encode_field(:y, "weight", type: :quantitative, title: "Weight")

Lag 1 has the highest weight, matching our AR(1) data generation.

Network with AR

input_with_ar = %{
  "trend" => Nx.template({1, 6}, :f32),
  "yearly" => Nx.template({1, 12}, :f32),
  "weekly" => Nx.template({1, 6}, :f32),
  "ar" => Nx.template({1, 7}, :f32)  # 7 lags
}

Axon.Display.as_graph(Soothsayer.display_network(model_with_ar), input_with_ar)

4. Events

Events capture the impact of special occasions that affect your time series - holidays, promotions, product launches, etc. Each event becomes additive binary features.

Data with event spikes

:rand.seed(:exsss, {100, 200, 300})

event_dates = Enum.map(0..364, fn i -> Date.add(~D[2023-01-01], i) end)

# Define two "sale" events that spike the value
sale_dates = [~D[2023-03-15], ~D[2023-09-15]]

y_events =
  Enum.map(event_dates, fn date ->
    days_since_start = Date.diff(date, ~D[2023-01-01])
    trend = 100 + 0.1 * days_since_start
    # Sale events add a spike
    spike = if date in sale_dates, do: 50, else: 0
    noise = :rand.normal(0, 5)
    trend + spike + noise
  end)

df_events = DataFrame.new(%{"ds" => event_dates, "y" => y_events})
Vl.new(width: 800, height: 400, title: "Data with Sale Events (March 15, Sept 15)")
|> Vl.data_from_values(df_events, only: ["ds", "y"])
|> Vl.mark(:point, opacity: 0.5)
|> Vl.encode_field(:x, "ds", type: :temporal)
|> Vl.encode_field(:y, "y", type: :quantitative)

Create events DataFrame

Events are defined in a DataFrame with “event” (name) and “ds” (date) columns:

events_df = DataFrame.new(%{
  "event" => ["sale", "sale"],
  "ds" => sale_dates
})

Configure and fit with events

model_events = Soothsayer.new(%{
  trend: %{enabled: true, changepoints: 0},
  seasonality: %{yearly: %{enabled: false}, weekly: %{enabled: false}},
  events: %{
    "sale" => %{lower_window: 0, upper_window: 0}
  },
  epochs: 50
})

fitted_events = Soothsayer.fit(model_events, df_events, events: events_df)

Predict with future events

# Future dates
future_start = ~D[2024-01-01]
future_dates_list = Enum.map(0..364, fn i -> Date.add(future_start, i) end)
future_series = Series.from_list(future_dates_list)

# Future events (when we expect sales next year)
future_events = DataFrame.new(%{
  "event" => ["sale", "sale"],
  "ds" => [~D[2024-03-15], ~D[2024-09-15]]
})

predictions_events = Soothsayer.predict(fitted_events, future_series, events: future_events)
df_future = DataFrame.new(%{
  "ds" => future_dates_list,
  "yhat" => Nx.to_flat_list(predictions_events)
})

Vl.new(width: 800, height: 400, title: "Predictions with Future Sale Events")
|> Vl.data_from_values(df_future)
|> Vl.mark(:line, color: "tomato", stroke_width: 2)
|> Vl.encode_field(:x, "ds", type: :temporal)
|> Vl.encode_field(:y, "yhat", type: :quantitative)

Extract event effects

See the learned impact of each event:

effects = Soothsayer.get_event_effects(fitted_events)
IO.inspect(effects, label: "Learned event effects")

Event windows

Events can affect surrounding days. Use lower_window (days before) and upper_window (days after):

:rand.seed(:exsss, {111, 222, 333})

# Black Friday with pre and post effects
bf_date = ~D[2023-11-24]
window_dates = Enum.map(0..364, fn i -> Date.add(~D[2023-01-01], i) end)

y_window =
  Enum.map(window_dates, fn date ->
    trend = 100
    # Effect builds before and lingers after
    days_from_bf = Date.diff(date, bf_date)
    effect =
      cond do
        days_from_bf == -2 -> 10   # 2 days before
        days_from_bf == -1 -> 25   # 1 day before
        days_from_bf == 0 -> 50    # Black Friday
        days_from_bf == 1 -> 15    # 1 day after
        true -> 0
      end
    trend + effect + :rand.normal(0, 3)
  end)

df_window = DataFrame.new(%{"ds" => window_dates, "y" => y_window})

bf_events = DataFrame.new(%{
  "event" => ["black_friday"],
  "ds" => [bf_date]
})

model_window = Soothsayer.new(%{
  trend: %{enabled: true, changepoints: 0},
  seasonality: %{yearly: %{enabled: false}, weekly: %{enabled: false}},
  events: %{
    "black_friday" => %{lower_window: -2, upper_window: 1}  # 4 features
  },
  epochs: 50
})

fitted_window = Soothsayer.fit(model_window, df_window, events: bf_events)
window_effects = Soothsayer.get_event_effects(fitted_window)

IO.inspect(window_effects, label: "Windowed event effects")

Each window position (-2, -1, 0, +1) learns its own coefficient.

5. Real World Example: Spanish Energy Prices

Let’s apply everything to real data: daily energy prices from Spain (2015-2018).

real_df =
  DataFrame.from_csv!(
    "https://raw.githubusercontent.com/ourownstory/neuralprophet-data/main/kaggle-energy/datasets/tutorial01.csv"
  )

real_df =
  real_df
  |> DataFrame.put("ds", real_df["ds"] |> Series.cast(:date))
Vl.new(width: 800, height: 400, title: "Spanish Energy Prices (2015-2018)")
|> Vl.data_from_values(real_df, only: ["ds", "y"])
|> Vl.mark(:point, opacity: 0.5)
|> Vl.encode_field(:x, "ds", type: :temporal)
|> Vl.encode_field(:y, "y", type: :quantitative)

Full model with all features

energy_model = Soothsayer.new(%{
  trend: %{changepoints: 15, changepoints_range: 0.8},
  seasonality: %{
    yearly: %{enabled: true, fourier_terms: 8},
    weekly: %{enabled: true, fourier_terms: 3}
  },
  ar: %{enabled: true, lags: 7},
  epochs: 100
})

fitted_energy = Soothsayer.fit(energy_model, real_df)

Full network architecture

With all features enabled, the network combines trend (with changepoints), yearly seasonality, weekly seasonality, and AR:

full_input = %{
  "trend" => Nx.template({1, 16}, :f32),   # 1 + 15 changepoints
  "yearly" => Nx.template({1, 16}, :f32),  # 2 * 8 fourier terms
  "weekly" => Nx.template({1, 6}, :f32),   # 2 * 3 fourier terms
  "ar" => Nx.template({1, 7}, :f32)        # 7 lags
}

Axon.Display.as_graph(Soothsayer.display_network(energy_model), full_input)
energy_pred = Soothsayer.predict(fitted_energy, real_df["ds"])

real_df_pred =
  real_df
  |> DataFrame.put("yhat", energy_pred)

Vl.new(width: 800, height: 400, title: "Energy Prices: Actual vs Predicted")
|> Vl.data_from_values(real_df_pred, only: ["ds", "y", "yhat"])
|> Vl.layers([
  Vl.new()
  |> Vl.mark(:point, opacity: 0.3)
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "y", type: :quantitative),
  Vl.new()
  |> Vl.mark(:line, color: "tomato", stroke_width: 1)
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "yhat", type: :quantitative)
])

Decomposed components

energy_components = Soothsayer.predict_components(fitted_energy, real_df["ds"])

real_df_comp =
  real_df
  |> DataFrame.put("trend", energy_components.trend)
  |> DataFrame.put("yearly", energy_components.yearly_seasonality)
  |> DataFrame.put("weekly", energy_components.weekly_seasonality)
Vl.new(width: 800, height: 250, title: "Trend (with changepoints)")
|> Vl.data_from_values(real_df_comp)
|> Vl.mark(:line, color: "steelblue", stroke_width: 2)
|> Vl.encode_field(:x, "ds", type: :temporal)
|> Vl.encode_field(:y, "trend", type: :quantitative)
Vl.new(width: 800, height: 250, title: "Yearly Seasonality")
|> Vl.data_from_values(real_df_comp)
|> Vl.mark(:line, color: "green", stroke_width: 2)
|> Vl.encode_field(:x, "ds", type: :temporal)
|> Vl.encode_field(:y, "yearly", type: :quantitative)
Vl.new(width: 800, height: 250, title: "Weekly Seasonality (Q1 2017)")
|> Vl.data_from_values(real_df_comp)
|> Vl.mark(:line, color: "purple", stroke_width: 2)
|> Vl.encode_field(:x, "ds",
  type: :temporal,
  scale: [domain: ["2017-01-01", "2017-03-31"]]
)
|> Vl.encode_field(:y, "weekly", type: :quantitative)

AR weights for energy data

energy_ar = Soothsayer.get_ar_weights(fitted_energy)
energy_kernel = energy_ar["ar_dense_out"].kernel |> Nx.to_flat_list()

energy_lags =
  energy_kernel
  |> Enum.with_index(1)
  |> Enum.map(fn {w, lag} -> %{lag: lag, weight: w} end)

Vl.new(width: 400, height: 300, title: "AR Lag Weights (Energy)")
|> Vl.data_from_values(energy_lags)
|> Vl.mark(:bar)
|> Vl.encode_field(:x, "lag", type: :ordinal, title: "Lag (days)")
|> Vl.encode_field(:y, "weight", type: :quantitative, title: "Weight")

Forecasting future energy prices

Now let’s predict energy prices for the next 90 days beyond our training data:

# Get the last date from training data
last_date = real_df["ds"] |> Series.to_list() |> List.last()

# Generate 90 future dates
future_dates = Enum.map(1..90, fn i -> Date.add(last_date, i) end)
future_series = Series.from_list(future_dates)

# Make predictions
future_predictions = Soothsayer.predict(fitted_energy, future_series)

future_df = DataFrame.new(%{
  "ds" => future_dates,
  "yhat" => Nx.to_flat_list(future_predictions)
})
# Combine historical and forecast data for visualization
historical_subset =
  real_df
  |> DataFrame.tail(180)
  |> DataFrame.put("yhat", Soothsayer.predict(fitted_energy, DataFrame.tail(real_df, 180)["ds"]))
  |> DataFrame.put("type", List.duplicate("historical", 180))

forecast_with_type =
  future_df
  |> DataFrame.put("type", List.duplicate("forecast", 90))

combined_df = DataFrame.concat_rows([historical_subset, forecast_with_type])

Vl.new(width: 800, height: 400, title: "Energy Price Forecast (90 days)")
|> Vl.data_from_values(combined_df)
|> Vl.layers([
  # Historical actual values
  Vl.new()
  |> Vl.mark(:point, opacity: 0.3)
  |> Vl.transform(filter: "datum.type == 'historical'")
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "y", type: :quantitative),
  # Historical fitted line
  Vl.new()
  |> Vl.mark(:line, color: "steelblue", stroke_width: 1)
  |> Vl.transform(filter: "datum.type == 'historical'")
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "yhat", type: :quantitative),
  # Forecast line
  Vl.new()
  |> Vl.mark(:line, color: "tomato", stroke_width: 2)
  |> Vl.transform(filter: "datum.type == 'forecast'")
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "yhat", type: :quantitative)
])

Forecast components

We can also decompose the forecast to understand what’s driving future predictions:

future_components = Soothsayer.predict_components(fitted_energy, future_series)

future_comp_df =
  future_df
  |> DataFrame.put("trend", future_components.trend)
  |> DataFrame.put("yearly", future_components.yearly_seasonality)
  |> DataFrame.put("weekly", future_components.weekly_seasonality)
Vl.new(width: 800, height: 250, title: "Forecast Trend")
|> Vl.data_from_values(future_comp_df)
|> Vl.mark(:line, color: "steelblue", stroke_width: 2)
|> Vl.encode_field(:x, "ds", type: :temporal)
|> Vl.encode_field(:y, "trend", type: :quantitative)
Vl.new(width: 800, height: 250, title: "Forecast Seasonality (Yearly + Weekly)")
|> Vl.data_from_values(future_comp_df)
|> Vl.layers([
  Vl.new()
  |> Vl.mark(:line, color: "green", stroke_width: 2)
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "yearly", type: :quantitative),
  Vl.new()
  |> Vl.mark(:line, color: "purple", stroke_width: 1, opacity: 0.7)
  |> Vl.encode_field(:x, "ds", type: :temporal)
  |> Vl.encode_field(:y, "weekly", type: :quantitative)
])

Summary

Feature Use When Configuration
Trend Data has upward/downward direction trend: %{enabled: true}
Changepoints Trend slope changes over time trend: %{changepoints: 10}
Yearly seasonality Annual patterns seasonality: %{yearly: %{enabled: true}}
Weekly seasonality Weekly patterns seasonality: %{weekly: %{enabled: true}}
AR Values depend on recent history ar: %{enabled: true, lags: 7}
Events Holidays, promotions, special dates events: %{"sale" => %{lower_window: 0, upper_window: 0}}
Regularization Prevent overfitting regularization: 0.1

Tips:

  • Start simple (trend + seasonality) and add features as needed
  • Use changepoints: 0 for simple linear trends
  • Use get_ar_weights/1 to understand what lags matter
  • Use get_event_effects/1 to see learned event impacts
  • More fourier_terms = more flexible seasonality (but risk overfitting)
  • Regularization helps when you have many changepoints or lags