Holt-Winters Double Exponential Smoothing


In [9]:
def holt(timeSeries: Array[Double], alpha: Double, beta: Double): Array[Double] = {
    val initialLevel = timeSeries(0)
    val initialTrend = timeSeries(1) - timeSeries(0)
    val forecastedLevelAndTrends = timeSeries.scanLeft((initialLevel, initialTrend)) {
      case ((prevLevel, prevTrend), value) =>
        // Clamping the level at 0
        val level = Math.max(0.0, alpha * value + (1 - alpha) * (prevLevel + prevTrend))
        val trend = beta * (level - prevLevel) + (1 - beta) * prevTrend
        (level, trend)
    }
    val forecasted = forecastedLevelAndTrends.map { case(level, trend) => level + trend}
    // Discarding the first forecast and prepending the initial time series value
    initialLevel +: forecasted.tail
}


defined function holt

In [10]:
val timeSeries = Array(1, 2, 3, 4, 5).map(_.toDouble)


timeSeries: Array[Double] = Array(1.0, 2.0, 3.0, 4.0, 5.0)

In [12]:
holt(timeSeries, 0.5, 0.5)


res11: Array[Double] = Array(1.0, 2.25, 2.8125, 3.640625, 4.64453125, 5.7353515625)

This doesn't appear to be a very good fit. We have to tune the alpha and beta parameters.

Error

But before we do that we have to define what "good" means. To that end I'll use Mean Absolute Percentage Error (MAPE) which we want to minimize:


In [13]:
def meanAbsolutePercentageError(actualAndForecasted: Array[(Double, Double)], zeroThreshold: Double = 0.5): Double = {
    actualAndForecasted.map { case(actual, forecast) =>
      if (actual < zeroThreshold)
        // An actual value of 0 gets 0% error if the forecasted view is 0 and 100% otherwise
        if (forecast < zeroThreshold) 0 else 1
      else
        Math.abs(forecast / actual - 1)
    }.sum / actualAndForecasted.length
}


defined function meanAbsolutePercentageError

Now having defined the error metric let's search the space of alpha and beta. Both of these range between 0 and 1.


In [15]:
def holtGridSearch(
    timeSeries: Array[Double],
    alphaMax: Double = 1.0,
    betaMax: Double = 0.8,
    ticks: Int = 5,
    searchDepth: Int = 3,
    alphaInitial: Double = 0.5,
    betaInitial: Double = 0.5): Double = {
    case class CostAndForecast(cost: Double, forecast: Double)

    def compare(timeSeries: Array[Double], alpha: Double, beta: Double) = {
      val holtFitPlusForecast = holt(timeSeries, alpha, beta)
      val holtFit = holtFitPlusForecast.slice(0, timeSeries.length)
      CostAndForecast(meanAbsolutePercentageError(timeSeries.zip(holtFit)), holtFitPlusForecast.last)
    }

    def search(alphaStart: Double, alphaEnd: Double, betaStart: Double, betaEnd: Double, increment: Double) = {
      // Search the grid
      val grid = for {
        alpha <- alphaStart to alphaEnd by increment
        beta <- betaStart to betaEnd by increment
        if alpha >= 0 && alpha <=alphaMax && beta >= 0 && beta <= betaMax
      } yield (compare(timeSeries, alpha, beta), alpha, beta)

      // Return the lowest cost forecast
      grid.minBy(_._1.cost)
    }

    val (_, _, forecast) = (1 to searchDepth).foldLeft((alphaInitial, betaInitial, 0.0))
    { case ((alphaPrevious, betaPrevious, _), depth) =>
      // Using resolution = Math.pow(0.1, depth) was leading to slight variations in the grid that broke some unit tests
      val resolution = s"1e-$depth".toDouble
      val range = ticks * resolution
      val alphaStart = alphaPrevious - range
      val alphaEnd = alphaPrevious + range
      val betaStart = betaPrevious - range
      val betaEnd = betaPrevious + range
      val (costAndForecast, alpha, beta) = search(alphaStart, alphaEnd, betaStart, betaEnd, resolution)
      (alpha, beta, costAndForecast.forecast)
    }
    forecast
}


defined function holtGridSearch

In [16]:
holtGridSearch(timeSeries)


res15: Double = 6.0

In [ ]:
Now that looks better.