/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.elasticsearch.search.aggregations.pipeline.movavg.models;
import org.elasticsearch.ElasticsearchParseException;
import org.elasticsearch.common.Nullable;
import org.elasticsearch.common.ParseField;
import org.elasticsearch.common.ParseFieldMatcher;
import org.elasticsearch.common.io.stream.StreamInput;
import org.elasticsearch.common.io.stream.StreamOutput;
import org.elasticsearch.common.xcontent.XContentBuilder;
import org.elasticsearch.search.aggregations.AggregationExecutionException;
import org.elasticsearch.search.aggregations.pipeline.movavg.MovAvgParser;
import java.io.IOException;
import java.text.ParseException;
import java.util.*;
/**
* Calculate a triple exponential weighted moving average
*/
public class HoltWintersModel extends MovAvgModel {
protected static final ParseField NAME_FIELD = new ParseField("holt_winters");
/**
* Controls smoothing of data. Also known as "level" value.
* Alpha = 1 retains no memory of past values
* (e.g. random walk), while alpha = 0 retains infinite memory of past values (e.g.
* mean of the series).
*/
private final double alpha;
/**
* Controls smoothing of trend.
* Beta = 1 retains no memory of past values
* (e.g. random walk), while alpha = 0 retains infinite memory of past values (e.g.
* mean of the series).
*/
private final double beta;
/**
* Controls smoothing of seasonality.
* Gamma = 1 retains no memory of past values
* (e.g. random walk), while alpha = 0 retains infinite memory of past values (e.g.
* mean of the series).
*/
private final double gamma;
/**
* Periodicity of the data
*/
private final int period;
/**
* Whether this is a multiplicative or additive HW
*/
private final SeasonalityType seasonalityType;
/**
* Padding is used to add a very small amount to values, so that zeroes do not interfere
* with multiplicative seasonality math (e.g. division by zero)
*/
private final boolean pad;
private final double padding;
public enum SeasonalityType {
ADDITIVE((byte) 0, "add"), MULTIPLICATIVE((byte) 1, "mult");
/**
* Parse a string SeasonalityType into the byte enum
*
* @param text SeasonalityType in string format (e.g. "add")
* @param parseFieldMatcher Matcher for field names
* @return SeasonalityType enum
*/
@Nullable
public static SeasonalityType parse(String text, ParseFieldMatcher parseFieldMatcher) {
if (text == null) {
return null;
}
SeasonalityType result = null;
for (SeasonalityType policy : values()) {
if (parseFieldMatcher.match(text, policy.parseField)) {
result = policy;
break;
}
}
if (result == null) {
final List<String> validNames = new ArrayList<>();
for (SeasonalityType policy : values()) {
validNames.add(policy.getName());
}
throw new ElasticsearchParseException("failed to parse seasonality type [{}]. accepted values are [{}]", text, validNames);
}
return result;
}
private final byte id;
private final ParseField parseField;
SeasonalityType(byte id, String name) {
this.id = id;
this.parseField = new ParseField(name);
}
/**
* Serialize the SeasonalityType to the output stream
*/
public void writeTo(StreamOutput out) throws IOException {
out.writeByte(id);
}
/**
* Deserialize the SeasonalityType from the input stream
*
* @param in the input stream
* @return SeasonalityType Enum
*/
public static SeasonalityType readFrom(StreamInput in) throws IOException {
byte id = in.readByte();
for (SeasonalityType seasonalityType : values()) {
if (id == seasonalityType.id) {
return seasonalityType;
}
}
throw new IllegalStateException("Unknown Seasonality Type with id [" + id + "]");
}
/**
* Return the english-formatted name of the SeasonalityType
*
* @return English representation of SeasonalityType
*/
public String getName() {
return parseField.getPreferredName();
}
}
public HoltWintersModel(double alpha, double beta, double gamma, int period, SeasonalityType seasonalityType, boolean pad) {
this.alpha = alpha;
this.beta = beta;
this.gamma = gamma;
this.period = period;
this.seasonalityType = seasonalityType;
this.pad = pad;
// Only pad if we are multiplicative and padding is enabled
// The padding amount is not currently user-configurable...i dont see a reason to expose it?
this.padding = seasonalityType.equals(SeasonalityType.MULTIPLICATIVE) && pad ? 0.0000000001 : 0;
}
@Override
public boolean minimizeByDefault() {
return true;
}
@Override
public boolean canBeMinimized() {
return true;
}
@Override
public MovAvgModel neighboringModel() {
double newValue = Math.random();
switch ((int) (Math.random() * 3)) {
case 0:
return new HoltWintersModel(newValue, beta, gamma, period, seasonalityType, pad);
case 1:
return new HoltWintersModel(alpha, newValue, gamma, period, seasonalityType, pad);
case 2:
return new HoltWintersModel(alpha, beta, newValue, period, seasonalityType, pad);
default:
assert (false): "Random value fell outside of range [0-2]";
return new HoltWintersModel(newValue, beta, gamma, period, seasonalityType, pad); // This should never technically happen...
}
}
@Override
public MovAvgModel clone() {
return new HoltWintersModel(alpha, beta, gamma, period, seasonalityType, pad);
}
@Override
public boolean hasValue(int valuesAvailable) {
// We need at least (period * 2) data-points (e.g. two "seasons")
return valuesAvailable >= period * 2;
}
/**
* Predicts the next `n` values in the series, using the smoothing model to generate new values.
* Unlike the other moving averages, HoltWinters has forecasting/prediction built into the algorithm.
* Prediction is more than simply adding the next prediction to the window and repeating. HoltWinters
* will extrapolate into the future by applying the trend and seasonal information to the smoothed data.
*
* @param values Collection of numerics to movingAvg, usually windowed
* @param numPredictions Number of newly generated predictions to return
* @param <T> Type of numeric
* @return Returns an array of doubles, since most smoothing methods operate on floating points
*/
@Override
protected <T extends Number> double[] doPredict(Collection<T> values, int numPredictions) {
return next(values, numPredictions);
}
@Override
public <T extends Number> double next(Collection<T> values) {
return next(values, 1)[0];
}
/**
* Calculate a doubly exponential weighted moving average
*
* @param values Collection of values to calculate avg for
* @param numForecasts number of forecasts into the future to return
*
* @param <T> Type T extending Number
* @return Returns a Double containing the moving avg for the window
*/
public <T extends Number> double[] next(Collection<T> values, int numForecasts) {
if (values.size() < period * 2) {
// We need at least two full "seasons" to use HW
// This should have been caught earlier, we can't do anything now...bail
throw new AggregationExecutionException("Holt-Winters aggregation requires at least (2 * period == 2 * "
+ period + " == "+(2 * period)+") data-points to function. Only [" + values.size() + "] were provided.");
}
// Smoothed value
double s = 0;
double last_s;
// Trend value
double b = 0;
double last_b = 0;
// Seasonal value
double[] seasonal = new double[values.size()];
int counter = 0;
double[] vs = new double[values.size()];
for (T v : values) {
vs[counter] = v.doubleValue() + padding;
counter += 1;
}
// Initial level value is average of first season
// Calculate the slopes between first and second season for each period
for (int i = 0; i < period; i++) {
s += vs[i];
b += (vs[i + period] - vs[i]) / period;
}
s /= (double) period;
b /= (double) period;
last_s = s;
// Calculate first seasonal
if (Double.compare(s, 0.0) == 0 || Double.compare(s, -0.0) == 0) {
Arrays.fill(seasonal, 0.0);
} else {
for (int i = 0; i < period; i++) {
seasonal[i] = vs[i] / s;
}
}
for (int i = period; i < vs.length; i++) {
// TODO if perf is a problem, we can specialize a subclass to avoid conditionals on each iteration
if (seasonalityType.equals(SeasonalityType.MULTIPLICATIVE)) {
s = alpha * (vs[i] / seasonal[i - period]) + (1.0d - alpha) * (last_s + last_b);
} else {
s = alpha * (vs[i] - seasonal[i - period]) + (1.0d - alpha) * (last_s + last_b);
}
b = beta * (s - last_s) + (1 - beta) * last_b;
if (seasonalityType.equals(SeasonalityType.MULTIPLICATIVE)) {
seasonal[i] = gamma * (vs[i] / (last_s + last_b )) + (1 - gamma) * seasonal[i - period];
} else {
seasonal[i] = gamma * (vs[i] - (last_s - last_b )) + (1 - gamma) * seasonal[i - period];
}
last_s = s;
last_b = b;
}
double[] forecastValues = new double[numForecasts];
for (int i = 1; i <= numForecasts; i++) {
int idx = values.size() - period + ((i - 1) % period);
// TODO perhaps pad out seasonal to a power of 2 and use a mask instead of modulo?
if (seasonalityType.equals(SeasonalityType.MULTIPLICATIVE)) {
forecastValues[i-1] = (s + (i * b)) * seasonal[idx];
} else {
forecastValues[i-1] = s + (i * b) + seasonal[idx];
}
}
return forecastValues;
}
public static final MovAvgModelStreams.Stream STREAM = new MovAvgModelStreams.Stream() {
@Override
public MovAvgModel readResult(StreamInput in) throws IOException {
double alpha = in.readDouble();
double beta = in.readDouble();
double gamma = in.readDouble();
int period = in.readVInt();
SeasonalityType type = SeasonalityType.readFrom(in);
boolean pad = in.readBoolean();
return new HoltWintersModel(alpha, beta, gamma, period, type, pad);
}
@Override
public String getName() {
return NAME_FIELD.getPreferredName();
}
};
@Override
public void writeTo(StreamOutput out) throws IOException {
out.writeString(STREAM.getName());
out.writeDouble(alpha);
out.writeDouble(beta);
out.writeDouble(gamma);
out.writeVInt(period);
seasonalityType.writeTo(out);
out.writeBoolean(pad);
}
public static class HoltWintersModelParser extends AbstractModelParser {
@Override
public String getName() {
return NAME_FIELD.getPreferredName();
}
@Override
public MovAvgModel parse(@Nullable Map<String, Object> settings, String pipelineName, int windowSize,
ParseFieldMatcher parseFieldMatcher) throws ParseException {
double alpha = parseDoubleParam(settings, "alpha", 0.3);
double beta = parseDoubleParam(settings, "beta", 0.1);
double gamma = parseDoubleParam(settings, "gamma", 0.3);
int period = parseIntegerParam(settings, "period", 1);
if (windowSize < 2 * period) {
throw new ParseException("Field [window] must be at least twice as large as the period when " +
"using Holt-Winters. Value provided was [" + windowSize + "], which is less than (2*period) == "
+ (2 * period), 0);
}
SeasonalityType seasonalityType = SeasonalityType.ADDITIVE;
if (settings != null) {
Object value = settings.get("type");
if (value != null) {
if (value instanceof String) {
seasonalityType = SeasonalityType.parse((String)value, parseFieldMatcher);
settings.remove("type");
} else {
throw new ParseException("Parameter [type] must be a String, type `"
+ value.getClass().getSimpleName() + "` provided instead", 0);
}
}
}
boolean pad = parseBoolParam(settings, "pad", seasonalityType.equals(SeasonalityType.MULTIPLICATIVE));
checkUnrecognizedParams(settings);
return new HoltWintersModel(alpha, beta, gamma, period, seasonalityType, pad);
}
}
public static class HoltWintersModelBuilder implements MovAvgModelBuilder {
private Double alpha;
private Double beta;
private Double gamma;
private Integer period;
private SeasonalityType seasonalityType;
private Boolean pad;
/**
* Alpha controls the smoothing of the data. Alpha = 1 retains no memory of past values
* (e.g. a random walk), while alpha = 0 retains infinite memory of past values (e.g.
* the series mean). Useful values are somewhere in between. Defaults to 0.5.
*
* @param alpha A double between 0-1 inclusive, controls data smoothing
*
* @return The builder to continue chaining
*/
public HoltWintersModelBuilder alpha(double alpha) {
this.alpha = alpha;
return this;
}
/**
* Equivalent to <code>alpha</code>, but controls the smoothing of the trend instead of the data
*
* @param beta a double between 0-1 inclusive, controls trend smoothing
*
* @return The builder to continue chaining
*/
public HoltWintersModelBuilder beta(double beta) {
this.beta = beta;
return this;
}
public HoltWintersModelBuilder gamma(double gamma) {
this.gamma = gamma;
return this;
}
public HoltWintersModelBuilder period(int period) {
this.period = period;
return this;
}
public HoltWintersModelBuilder seasonalityType(SeasonalityType type) {
this.seasonalityType = type;
return this;
}
public HoltWintersModelBuilder pad(boolean pad) {
this.pad = pad;
return this;
}
@Override
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
builder.field(MovAvgParser.MODEL.getPreferredName(), NAME_FIELD.getPreferredName());
builder.startObject(MovAvgParser.SETTINGS.getPreferredName());
if (alpha != null) {
builder.field("alpha", alpha);
}
if (beta != null) {
builder.field("beta", beta);
}
if (gamma != null) {
builder.field("gamma", gamma);
}
if (period != null) {
builder.field("period", period);
}
if (pad != null) {
builder.field("pad", pad);
}
if (seasonalityType != null) {
builder.field("type", seasonalityType.getName());
}
builder.endObject();
return builder;
}
}
}