package com.linkedin.thirdeye.anomalydetection.model.detection;
import com.linkedin.thirdeye.anomalydetection.context.AnomalyDetectionContext;
import com.linkedin.thirdeye.anomalydetection.context.TimeSeries;
import com.linkedin.thirdeye.anomalydetection.model.prediction.ExpectedTimeSeriesPredictionModel;
import com.linkedin.thirdeye.anomalydetection.model.prediction.PredictionModel;
import com.linkedin.thirdeye.api.DimensionMap;
import com.linkedin.thirdeye.datalayer.dto.RawAnomalyResultDTO;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.List;
import org.joda.time.Interval;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class SimpleThresholdDetectionModel extends AbstractDetectionModel {
private static final Logger LOGGER = LoggerFactory.getLogger(SimpleThresholdDetectionModel.class);
public static final String CHANGE_THRESHOLD = "changeThreshold";
public static final String AVERAGE_VOLUME_THRESHOLD = "averageVolumeThreshold";
public static final String DEFAULT_MESSAGE_TEMPLATE = "change : %.2f %%, currentVal : %.2f, baseLineVal : %.2f, threshold : %s";
@Override
public List<RawAnomalyResultDTO> detect(String metricName, AnomalyDetectionContext anomalyDetectionContext) {
List<RawAnomalyResultDTO> anomalyResults = new ArrayList<>();
// Get thresholds
double changeThreshold = Double.valueOf(getProperties().getProperty(CHANGE_THRESHOLD));
double volumeThreshold = 0d;
if (getProperties().containsKey(AVERAGE_VOLUME_THRESHOLD)) {
volumeThreshold = Double.valueOf(getProperties().getProperty(AVERAGE_VOLUME_THRESHOLD));
}
long bucketSizeInMillis = anomalyDetectionContext.getBucketSizeInMS();
// Compute the weight of this time series (average across whole)
TimeSeries currentTimeSeries = anomalyDetectionContext.getTransformedCurrent(metricName);
double averageValue = 0;
for (long time : currentTimeSeries.timestampSet()) {
averageValue += currentTimeSeries.get(time);
}
Interval currentInterval = currentTimeSeries.getTimeSeriesInterval();
long currentStart = currentInterval.getStartMillis();
long currentEnd = currentInterval.getEndMillis();
long numBuckets = (currentEnd - currentStart) / bucketSizeInMillis;
if (numBuckets != 0) {
averageValue /= numBuckets;
}
// Check if this time series even meets our volume threshold
DimensionMap dimensionMap = anomalyDetectionContext.getTimeSeriesKey().getDimensionMap();
if (averageValue < volumeThreshold) {
LOGGER.info("{} does not meet volume threshold {}: {}", dimensionMap, volumeThreshold, averageValue);
return anomalyResults; // empty list
}
PredictionModel predictionModel = anomalyDetectionContext.getTrainedPredictionModel(metricName);
if (!(predictionModel instanceof ExpectedTimeSeriesPredictionModel)) {
LOGGER.info("SimpleThresholdDetectionModel detection model expects an ExpectedTimeSeriesPredictionModel but the trained prediction model in anomaly detection context is not.");
return anomalyResults; // empty list
}
ExpectedTimeSeriesPredictionModel expectedTimeSeriesPredictionModel = (ExpectedTimeSeriesPredictionModel) predictionModel;
TimeSeries expectedTimeSeries = expectedTimeSeriesPredictionModel.getExpectedTimeSeries();
Interval expectedTSInterval = expectedTimeSeries.getTimeSeriesInterval();
long expectedStart = expectedTSInterval.getStartMillis();
long seasonalOffset = currentStart - expectedStart;
for (long currentTimestamp : currentTimeSeries.timestampSet()) {
long expectedTimestamp = currentTimestamp - seasonalOffset;
if (!expectedTimeSeries.hasTimestamp(expectedTimestamp)) {
continue;
}
double baselineValue = expectedTimeSeries.get(expectedTimestamp);
double currentValue = currentTimeSeries.get(currentTimestamp);
if (isAnomaly(currentValue, baselineValue, changeThreshold)) {
RawAnomalyResultDTO anomalyResult = new RawAnomalyResultDTO();
anomalyResult.setDimensions(dimensionMap);
anomalyResult.setProperties(getProperties().toString());
anomalyResult.setStartTime(currentTimestamp);
anomalyResult.setEndTime(currentTimestamp + bucketSizeInMillis); // point-in-time
anomalyResult.setScore(averageValue);
anomalyResult.setWeight(calculateChange(currentValue, baselineValue));
anomalyResult.setAvgCurrentVal(currentValue);
anomalyResult.setAvgBaselineVal(baselineValue);
String message = getAnomalyResultMessage(changeThreshold, currentValue, baselineValue);
anomalyResult.setMessage(message);
anomalyResults.add(anomalyResult);
if (currentValue == 0.0 || baselineValue == 0.0) {
anomalyResult.setDataMissing(true);
}
}
}
return anomalyResults;
}
private boolean isAnomaly(double currentValue, double expectedValue, double threshold) {
if (expectedValue > 0) {
double percentChange = calculateChange(currentValue, expectedValue);
if (threshold > 0 && percentChange > threshold || threshold < 0 && percentChange < threshold) {
return true;
}
}
return false;
}
private double calculateChange(double currentValue, double expectedValue) {
return (currentValue - expectedValue) / expectedValue;
}
private String getAnomalyResultMessage(double threshold, double currentValue, double baselineValue) {
double change = calculateChange(currentValue, baselineValue);
NumberFormat percentInstance = NumberFormat.getPercentInstance();
percentInstance.setMaximumFractionDigits(2);
String thresholdPercent = percentInstance.format(threshold);
String message = String
.format(DEFAULT_MESSAGE_TEMPLATE, change * 100, currentValue, baselineValue, thresholdPercent);
return message;
}
}