Powered by AppSignal & Oban Pro

ForecastEx Interactive Demo

forecast_ex_demo.livemd

ForecastEx Interactive Demo

Mix.install([
  {:nx, "~> 0.7"},
  {:axon, "~> 0.6"},
  {:exla, "~> 0.7"},
  {:kino, "~> 0.12"},
  {:kino_vega_lite, "~> 0.1"}
])

Introduction

Welcome to ForecastEx! This is an interactive demonstration of a time series forecasting engine built with Elixir using the Nx ecosystem.

What you’ll see in this notebook:

  • Synthetic time series data generation
  • Data preprocessing and visualization
  • Model architecture definition
  • Simple prediction examples
# Define our modules inline for this demo
defmodule DemoDataGenerator do
  @moduledoc """
  Simplified data generator for the Livebook demo
  """
  
  def generate_noisy_sine_wave(num_points, opts \\ []) do
    frequency = Keyword.get(opts, :frequency, 0.1)
    amplitude = Keyword.get(opts, :amplitude, 1.0)
    noise_level = Keyword.get(opts, :noise_level, 0.1)
    trend = Keyword.get(opts, :trend, 0.01)
    
    0..(num_points - 1)
    |> Enum.map(fn i ->
      sine_val = :math.sin(i * frequency) * amplitude
      trend_val = i * trend
      noise_val = (:rand.uniform() - 0.5) * noise_level * 2
      
      sine_val + trend_val + noise_val
    end)
  end
  
  def create_sequences(data, sequence_length) do
    num_sequences = length(data) - sequence_length
    
    inputs = 
      0..(num_sequences - 1)
      |> Enum.map(fn i ->
        data
        |> Enum.slice(i, sequence_length)
        |> Enum.map(&[&1])
      end)
    
    targets = 
      sequence_length..(length(data) - 1)
      |> Enum.map(fn i ->
        [Enum.at(data, i)]
      end)
    
    inputs_tensor = Nx.tensor(inputs, type: :f32)
    targets_tensor = Nx.tensor(targets, type: :f32)
    
    {inputs_tensor, targets_tensor}
  end
  
  def normalize(data) do
    data_tensor = Nx.tensor(data, type: :f32)
    mean = Nx.mean(data_tensor)
    std = Nx.standard_deviation(data_tensor)
    
    normalized = 
      data_tensor
      |> Nx.subtract(mean)
      |> Nx.divide(std)
    
    {Nx.to_list(normalized), Nx.to_number(mean), Nx.to_number(std)}
  end
end

:ok

Step 1: Generate Synthetic Time Series Data

Let’s start by generating some synthetic time series data that mimics real-world patterns.

# Generate synthetic data
num_points = 200
data = DemoDataGenerator.generate_noisy_sine_wave(num_points, 
  frequency: 0.1, 
  amplitude: 1.5, 
  noise_level: 0.2, 
  trend: 0.005
)

IO.puts("Generated #{length(data)} data points")
IO.puts("First 10 values: #{Enum.take(data, 10) |> Enum.map(&Float.round(&1, 3)) |> inspect}")
IO.puts("Last 10 values: #{Enum.take(data, -10) |> Enum.map(&Float.round(&1, 3)) |> inspect}")

# Basic statistics
mean = Enum.sum(data) / length(data)
{min_val, max_val} = Enum.min_max(data)

IO.puts("\nData Statistics:")
IO.puts("Mean: #{Float.round(mean, 3)}")
IO.puts("Min: #{Float.round(min_val, 3)}")
IO.puts("Max: #{Float.round(max_val, 3)}")
IO.puts("Range: #{Float.round(max_val - min_val, 3)}")

data

Step 2: Visualize the Time Series

# Prepare data for visualization
chart_data = 
  data
  |> Enum.with_index()
  |> Enum.map(fn {value, index} -> %{x: index, y: value} end)

# Create the chart
VegaLite.new(width: 600, height: 300)
|> VegaLite.data_from_values(chart_data)
|> VegaLite.mark(:line, point: true, size: 2)
|> VegaLite.encode_field(:x, "x", type: :quantitative, title: "Time Step")
|> VegaLite.encode_field(:y, "y", type: :quantitative, title: "Value")
|> VegaLite.resolve(:scale, y: :independent)
|> Kino.VegaLite.new()

Step 3: Data Preprocessing

# Normalize the data
{normalized_data, mean, std} = DemoDataGenerator.normalize(data)

IO.puts("Normalization Results:")
IO.puts("Original mean: #{Float.round(mean, 3)}")
IO.puts("Original std: #{Float.round(std, 3)}")

# Verify normalization
normalized_mean = Enum.sum(normalized_data) / length(normalized_data)
IO.puts("Normalized mean: #{Float.round(normalized_mean, 6)} (should be ~0)")

# Create training sequences
sequence_length = 10
{inputs, targets} = DemoDataGenerator.create_sequences(normalized_data, sequence_length)

IO.puts("\nSequence Creation:")
IO.puts("Input tensor shape: #{inspect(Nx.shape(inputs))}")
IO.puts("Target tensor shape: #{inspect(Nx.shape(targets))}")
IO.puts("Number of training sequences: #{Nx.axis_size(inputs, 0)}")

{inputs, targets, normalized_data, mean, std}

Step 4: Model Architecture

# Define a simple LSTM model for time series forecasting
model = 
  Axon.input("sequence", shape: {nil, sequence_length, 1})
  |> then(fn input ->
    {output, _state} = Axon.lstm(input, 32)
    output
  end)
  |> Axon.nx(fn x -> x[[.., -1, ..]] end)  # Take last time step
  |> Axon.dropout(rate: 0.2)
  |> Axon.dense(1)

IO.puts("Model Architecture:")
IO.puts("✓ Input layer: accepts sequences of length #{sequence_length}")
IO.puts("✓ LSTM layer: 32 hidden units")
IO.puts("✓ Dropout layer: 20% dropout rate for regularization")
IO.puts("✓ Dense output layer: single prediction value")

model

Step 5: Simple Prediction Demo

Since the full LSTM training has compatibility issues in this environment, let’s demonstrate a simple statistical prediction approach:

defmodule SimplePrediction do
  def predict_next_linear_trend(data, window_size \\ 10) do
    # Take the last window_size points
    recent_data = Enum.take(data, -window_size)
    
    # Calculate simple moving average
    avg = Enum.sum(recent_data) / length(recent_data)
    
    # Calculate trend (difference between recent and earlier averages)
    if length(recent_data) >= 6 do
      first_half = Enum.take(recent_data, div(window_size, 2))
      second_half = Enum.take(recent_data, -div(window_size, 2))
      
      first_avg = Enum.sum(first_half) / length(first_half)
      second_avg = Enum.sum(second_half) / length(second_half)
      
      trend = second_avg - first_avg
      avg + trend
    else
      avg
    end
  end
  
  def generate_actual_next(data, time_index) do
    # Generate next point using the original formula
    sine_val = :math.sin(time_index * 0.1) * 1.5
    trend_val = time_index * 0.005
    noise_val = (:rand.uniform() - 0.5) * 0.2 * 2
    
    sine_val + trend_val + noise_val
  end
end

# Make prediction
next_prediction = SimplePrediction.predict_next_linear_trend(data, 15)
actual_next = SimplePrediction.generate_actual_next(data, length(data))

# Denormalize if working with normalized data
# (for this demo, we're using the original data)

prediction_error = abs(next_prediction - actual_next)

IO.puts("Simple Prediction Results:")
IO.puts("Predicted next value: #{Float.round(next_prediction, 3)}")
IO.puts("Actual next value: #{Float.round(actual_next, 3)}")
IO.puts("Prediction error: #{Float.round(prediction_error, 3)}")
IO.puts("Relative error: #{Float.round(prediction_error / abs(actual_next) * 100, 1)}%")

{next_prediction, actual_next, prediction_error}

Step 6: Prediction Visualization

# Create extended dataset with prediction
extended_data = data ++ [actual_next]
prediction_data = data ++ [next_prediction]

# Prepare data for visualization
actual_chart_data = 
  extended_data
  |> Enum.with_index()
  |> Enum.map(fn {value, index} -> 
    type = if index < length(data), do: "Historical", else: "Actual Next"
    %{x: index, y: value, type: type}
  end)

prediction_chart_data = 
  prediction_data
  |> Enum.with_index()
  |> Enum.map(fn {value, index} -> 
    type = if index < length(data), do: "Historical", else: "Predicted Next"
    %{x: index, y: value, type: type}
  end)

# Combine datasets
combined_data = actual_chart_data ++ [List.last(prediction_chart_data)]

# Create the chart
VegaLite.new(width: 700, height: 400)
|> VegaLite.data_from_values(combined_data)
|> VegaLite.mark(:line, point: true)
|> VegaLite.encode_field(:x, "x", type: :quantitative, title: "Time Step")
|> VegaLite.encode_field(:y, "y", type: :quantitative, title: "Value")
|> VegaLite.encode_field(:color, "type", 
    type: :nominal, 
    title: "Data Type",
    scale: %{
      "domain" => ["Historical", "Actual Next", "Predicted Next"],
      "range" => ["steelblue", "red", "orange"]
    }
  )
|> VegaLite.encode(:size, value: 3)
|> Kino.VegaLite.new()

Summary

This interactive demo showcased the core components of ForecastEx:

✅ What We Demonstrated:

  1. Synthetic Data Generation: Created realistic time series with trend, seasonality, and noise
  2. Data Preprocessing: Normalization and sequence creation for model training
  3. Model Architecture: Defined LSTM-based neural network structure
  4. Simple Prediction: Implemented statistical forecasting methods
  5. Visualization: Interactive charts showing data and predictions

🔧 Key Features:

  • Configurable Data Generation: Adjustable frequency, amplitude, noise, and trend
  • Comprehensive Preprocessing: Normalization, sequence windowing, train/test splits
  • Flexible Model Architecture: Support for various LSTM configurations
  • Prediction Methods: Both statistical and neural network approaches
  • Rich Visualization: Interactive charts with VegaLite

🚀 Next Steps:

  • Implement robust LSTM training pipeline
  • Add multi-step prediction capabilities
  • Include uncertainty estimation
  • Expand to real-world datasets
  • Add more advanced model architectures

ForecastEx demonstrates Elixir’s growing capabilities in machine learning and numerical computing, providing a solid foundation for time series analysis and forecasting applications.

IO.puts("🎉 Demo completed successfully!")
IO.puts("📈 ForecastEx: Time Series Forecasting with Elixir")
:demo_complete