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
}
In [10]:
val timeSeries = Array(1, 2, 3, 4, 5).map(_.toDouble)
In [12]:
holt(timeSeries, 0.5, 0.5)
This doesn't appear to be a very good fit. We have to tune the alpha and beta parameters.
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
}
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
}
In [16]:
holtGridSearch(timeSeries)
In [ ]:
Now that looks better.