diff --git a/src/main/java/com/thealgorithms/streaming/CusumDetector.java b/src/main/java/com/thealgorithms/streaming/CusumDetector.java index 07a801b8015e..618edb25a9af 100644 --- a/src/main/java/com/thealgorithms/streaming/CusumDetector.java +++ b/src/main/java/com/thealgorithms/streaming/CusumDetector.java @@ -251,29 +251,4 @@ private static void requireFinite(double value, String name) { throw new IllegalArgumentException("The " + name + " must be finite, but was " + value); } } - - /** - * What the detector reports after looking at one sample: either the stream still behaves as - * expected, or its level has shifted, in one direction or the other. - */ - public enum ShiftSignal { - - /** No evidence of a change; the stream is in control. */ - NONE, - - /** The level of the stream has moved above the target. */ - UPWARD, - - /** The level of the stream has moved below the target. */ - DOWNWARD; - - /** - * Tells whether this signal reports a change. - * - * @return {@code true} for {@link #UPWARD} and {@link #DOWNWARD} - */ - public boolean isAlarm() { - return this != NONE; - } - } } diff --git a/src/main/java/com/thealgorithms/streaming/EwmaChangeDetector.java b/src/main/java/com/thealgorithms/streaming/EwmaChangeDetector.java new file mode 100644 index 000000000000..88d60da02988 --- /dev/null +++ b/src/main/java/com/thealgorithms/streaming/EwmaChangeDetector.java @@ -0,0 +1,251 @@ +package com.thealgorithms.streaming; + +/** + * An EWMA control chart: change detection built on the exponentially weighted moving average. + * + *
The detector smooths the stream with + * {@link ExponentialMovingAverage} seeded at the target level and compares the smoothed value with a + * band around that target: + * + *
+ * z <- z + alpha * (x - target) + * limit = width * sigma * sqrt( alpha / (2 - alpha) * (1 - (1 - alpha)^(2n)) ) + *+ * + *
The band is the exact standard deviation of {@code z} under the null hypothesis, multiplied by + * the desired width in sigmas. It starts narrow and widens towards its asymptote + * {@code width * sigma * sqrt(alpha / (2 - alpha))}, which keeps the false alarm rate steady during + * the warm-up instead of letting the first few samples trip the alarm. An alarm fires as soon as the + * smoothed value leaves the band; the average is then reset to the target so that the detector + * starts fresh on the next change rather than latching. + * + *
Where {@link CusumDetector} accumulates evidence without limit and so excels at small, + * persistent shifts, an EWMA chart looks at a decaying window of the recent past: {@code alpha} + * around {@code 0.1 - 0.3} is a good compromise, larger values reacting faster to big jumps and + * smaller ones being more sensitive to slow drifts. A width of 3 sigmas is the customary setting. + * + *
{@code
+ * EwmaChangeDetector detector = new EwmaChangeDetector(20.0, 0.5, 0.2, 3.0);
+ * for (double sample : stream) {
+ * if (detector.accept(sample).isAlarm()) {
+ * alert(detector.statistic(), detector.controlLimit());
+ * }
+ * }
+ * }
+ *
+ * Each sample costs O(1) time and the detector keeps O(1) state. This class is not thread-safe. + * + * @see CusumDetector + * @see ExponentialMovingAverage + * @see EWMA chart + */ +public final class EwmaChangeDetector { + + /** Smoothing factor used when none is given. */ + public static final double DEFAULT_ALPHA = 0.2; + + /** Half-width of the control band, in sigmas, used when none is given. */ + public static final double DEFAULT_WIDTH = 3.0; + + private final double target; + private final double standardDeviation; + private final double alpha; + private final double width; + + private final ExponentialMovingAverage average; + private long count; + private long stepsSinceAlarm; + private long alarmCount; + private ShiftSignal lastSignal = ShiftSignal.NONE; + + /** + * Creates a detector with the customary smoothing factor of {@code 0.2} and a band of three + * sigmas. + * + * @param target the level the stream is expected to sit at + * @param standardDeviation the noise level of the stream, strictly positive + * @throws IllegalArgumentException if {@code target} is not finite or {@code standardDeviation} is not strictly positive + */ + public EwmaChangeDetector(double target, double standardDeviation) { + this(target, standardDeviation, DEFAULT_ALPHA, DEFAULT_WIDTH); + } + + /** + * Creates a detector. + * + * @param target the level the stream is expected to sit at + * @param standardDeviation the noise level of the stream, strictly positive + * @param alpha smoothing factor in {@code (0, 1]}; larger values react faster but tolerate less noise + * @param width half-width of the control band in sigmas, strictly positive + * @throws IllegalArgumentException if any argument is not finite, if {@code standardDeviation} or + * {@code width} is not strictly positive, or if {@code alpha} is outside {@code (0, 1]} + */ + public EwmaChangeDetector(double target, double standardDeviation, double alpha, double width) { + if (!Double.isFinite(target)) { + throw new IllegalArgumentException("The target must be finite, but was " + target); + } + if (!(standardDeviation > 0.0) || !Double.isFinite(standardDeviation)) { + throw new IllegalArgumentException("The standard deviation must be finite and strictly positive, but was " + standardDeviation); + } + if (!(width > 0.0) || !Double.isFinite(width)) { + throw new IllegalArgumentException("The width must be finite and strictly positive, but was " + width); + } + this.target = target; + this.standardDeviation = standardDeviation; + this.width = width; + this.average = ExponentialMovingAverage.ofAlpha(alpha, target); + this.alpha = this.average.alpha(); + } + + /** + * Feeds one sample into the detector. + * + * @param value the incoming sample + * @return {@link ShiftSignal#NONE} while the smoothed value stays inside the control band, + * otherwise the direction in which it left the band; the average is reset to the target on an alarm + * @throws IllegalArgumentException if {@code value} is NaN or infinite + */ + public ShiftSignal accept(double value) { + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("Samples must be finite, but was " + value); + } + count++; + stepsSinceAlarm++; + double smoothed = average.add(value); + double deviation = smoothed - target; + double limit = controlLimit(); + + if (deviation > limit) { + lastSignal = ShiftSignal.UPWARD; + } else if (deviation < -limit) { + lastSignal = ShiftSignal.DOWNWARD; + } else { + lastSignal = ShiftSignal.NONE; + } + + if (lastSignal.isAlarm()) { + alarmCount++; + stepsSinceAlarm = 0; + average.reset(); + } + return lastSignal; + } + + /** + * Runs the detector over a whole signal. + * + * @param signal the samples to inspect + * @return a new array of the same length holding the verdict for every sample + * @throws IllegalArgumentException if any sample is NaN or infinite + * @throws NullPointerException if {@code signal} is {@code null} + */ + public ShiftSignal[] scan(double[] signal) { + ShiftSignal[] signals = new ShiftSignal[signal.length]; + for (int i = 0; i < signal.length; i++) { + signals[i] = accept(signal[i]); + } + return signals; + } + + /** + * Returns the smoothed value the alarm decision is based on. + * + * @return the exponentially weighted average of the stream + */ + public double statistic() { + return average.value(); + } + + /** + * Returns the current half-width of the control band, which widens during the warm-up and then + * settles. + * + * @return the distance from the target at which an alarm fires + */ + public double controlLimit() { + double asymptotic = alpha / (2.0 - alpha); + double warmUp = -Math.expm1(2.0 * stepsSinceAlarm * Math.log1p(-alpha)); + return width * standardDeviation * Math.sqrt(asymptotic * warmUp); + } + + /** + * Returns the verdict on the most recent sample. + * + * @return the last signal, {@link ShiftSignal#NONE} before the first sample + */ + public ShiftSignal lastSignal() { + return lastSignal; + } + + /** + * Returns how many samples have been inspected since the last reset. + * + * @return the sample count + */ + public long count() { + return count; + } + + /** + * Returns how many alarms have been raised since the last reset. + * + * @return the alarm count + */ + public long alarmCount() { + return alarmCount; + } + + /** + * Returns the expected level of the stream. + * + * @return the target given at construction time + */ + public double target() { + return target; + } + + /** + * Returns the assumed noise level. + * + * @return the standard deviation given at construction time + */ + public double standardDeviation() { + return standardDeviation; + } + + /** + * Returns the smoothing factor in use. + * + * @return alpha + */ + public double alpha() { + return alpha; + } + + /** + * Returns the configured band half-width. + * + * @return the width in sigmas given at construction time + */ + public double width() { + return width; + } + + /** + * Returns the average to the target and clears the counters. + */ + public void reset() { + average.reset(); + count = 0; + stepsSinceAlarm = 0; + alarmCount = 0; + lastSignal = ShiftSignal.NONE; + } + + @Override + public String toString() { + return "EwmaChangeDetector{target=" + target + ", statistic=" + statistic() + ", limit=" + controlLimit() + ", alarms=" + alarmCount + '}'; + } +} diff --git a/src/main/java/com/thealgorithms/streaming/ExponentialMovingAverage.java b/src/main/java/com/thealgorithms/streaming/ExponentialMovingAverage.java new file mode 100644 index 000000000000..ce97c6339c8c --- /dev/null +++ b/src/main/java/com/thealgorithms/streaming/ExponentialMovingAverage.java @@ -0,0 +1,233 @@ +package com.thealgorithms.streaming; + +/** + * Exponentially weighted moving average (EWMA) and, alongside it, the exponentially weighted + * variance of the same stream. + * + *
A plain moving average has to remember the whole window. An exponentially weighted one does + * not: every sample simply decays, so a single number carries the entire history. + * + *
+ * mean <- mean + alpha * (x - mean) + * variance <- (1 - alpha) * (variance + alpha * (x - mean_before)^2) + *+ * + *
The smoothing factor {@code alpha} in {@code (0, 1]} sets how fast the past is forgotten: + * {@code alpha == 1} keeps only the latest sample, while a small {@code alpha} produces a smooth but + * sluggish estimate. It is usually easier to specify the responsiveness in terms of a window, which + * the factory methods do for you: + * + *
| Factory | alpha | Meaning |
|---|---|---|
| {@link #ofAlpha(double)} | as given | direct control |
| {@link #ofSpan(double)} | {@code 2 / (span + 1)} | comparable to a simple moving average of {@code span} samples |
| {@link #ofHalfLife(double)} | {@code 1 - exp(-ln2 / halfLife)} | a sample loses half of its weight after {@code halfLife} steps |
The average is seeded with the first sample, which avoids the warm-up bias that a zero seed + * would introduce. Pass an explicit seed to {@link #ofAlpha(double, double)} when the resting level + * of the signal is known in advance, as a control chart does. + * + *
Both the update and every query run in O(1) time and O(1) memory. This class is not + * thread-safe. + * + * @see EwmaChangeDetector + * @see Exponential moving average + */ +public final class ExponentialMovingAverage { + + private final double alpha; + private final double seed; + private final boolean seeded; + + private double mean; + private double variance; + private long count; + private boolean initialized; + + private ExponentialMovingAverage(double alpha, double seed, boolean seeded) { + if (!(alpha > 0.0) || alpha > 1.0) { + throw new IllegalArgumentException("The smoothing factor alpha must lie in (0, 1], but was " + alpha); + } + if (seeded && !Double.isFinite(seed)) { + throw new IllegalArgumentException("The seed must be finite, but was " + seed); + } + this.alpha = alpha; + this.seed = seed; + this.seeded = seeded; + reset(); + } + + /** + * Creates an average that is seeded with its first sample. + * + * @param alpha smoothing factor in {@code (0, 1]} + * @return a new average + * @throws IllegalArgumentException if {@code alpha} is outside {@code (0, 1]} + */ + public static ExponentialMovingAverage ofAlpha(double alpha) { + return new ExponentialMovingAverage(alpha, 0.0, false); + } + + /** + * Creates an average that starts from a known level instead of waiting for the first sample. + * + * @param alpha smoothing factor in {@code (0, 1]} + * @param seed the initial value of the average + * @return a new average + * @throws IllegalArgumentException if {@code alpha} is outside {@code (0, 1]} or {@code seed} is not finite + */ + public static ExponentialMovingAverage ofAlpha(double alpha, double seed) { + return new ExponentialMovingAverage(alpha, seed, true); + } + + /** + * Creates an average whose responsiveness matches a simple moving average of {@code span} + * samples, that is {@code alpha = 2 / (span + 1)}. + * + * @param span the equivalent window length, greater than or equal to one + * @return a new average + * @throws IllegalArgumentException if {@code span} is smaller than one or not finite + */ + public static ExponentialMovingAverage ofSpan(double span) { + if (!(span >= 1.0) || !Double.isFinite(span)) { + throw new IllegalArgumentException("The span must be finite and at least 1, but was " + span); + } + return ofAlpha(2.0 / (span + 1.0)); + } + + /** + * Creates an average in which a sample loses half of its weight after {@code halfLife} updates, + * that is {@code alpha = 1 - exp(-ln2 / halfLife)}. + * + * @param halfLife number of updates after which a weight is halved, strictly positive + * @return a new average + * @throws IllegalArgumentException if {@code halfLife} is not strictly positive or not finite + */ + public static ExponentialMovingAverage ofHalfLife(double halfLife) { + if (!(halfLife > 0.0) || !Double.isFinite(halfLife)) { + throw new IllegalArgumentException("The half-life must be finite and positive, but was " + halfLife); + } + return ofAlpha(-Math.expm1(-Math.log(2.0) / halfLife)); + } + + /** + * Incorporates one sample. + * + * @param value the sample to add + * @return the updated average + * @throws IllegalArgumentException if {@code value} is NaN or infinite + */ + public double add(double value) { + if (!Double.isFinite(value)) { + throw new IllegalArgumentException("Samples must be finite, but was " + value); + } + count++; + if (!initialized) { + initialized = true; + mean = value; + variance = 0.0; + return mean; + } + double deviation = value - mean; + mean += alpha * deviation; + variance = (1.0 - alpha) * (variance + alpha * deviation * deviation); + return mean; + } + + /** + * Incorporates every given sample, in order. + * + * @param values the samples to add + * @return the average after the last sample + * @throws IllegalArgumentException if any value is NaN or infinite + * @throws IllegalStateException if {@code values} is empty and the average is not initialized yet + * @throws NullPointerException if {@code values} is {@code null} + */ + public double addAll(double... values) { + for (double value : values) { + add(value); + } + return value(); + } + + /** + * Returns the current average. + * + * @return the exponentially weighted mean + * @throws IllegalStateException if the average has neither a seed nor a sample yet + */ + public double value() { + if (!initialized) { + throw new IllegalStateException("The average has not seen any sample yet"); + } + return mean; + } + + /** + * Returns the exponentially weighted variance of the stream, the natural companion of + * {@link #value()} when the spread matters as much as the level. + * + * @return the weighted variance, {@code 0} until the second sample arrives + * @throws IllegalStateException if the average has neither a seed nor a sample yet + */ + public double variance() { + if (!initialized) { + throw new IllegalStateException("The average has not seen any sample yet"); + } + return variance; + } + + /** + * Returns the square root of {@link #variance()}. + * + * @return the weighted standard deviation + * @throws IllegalStateException if the average has neither a seed nor a sample yet + */ + public double standardDeviation() { + return Math.sqrt(variance()); + } + + /** + * Returns the smoothing factor in use. + * + * @return alpha + */ + public double alpha() { + return alpha; + } + + /** + * Returns the number of samples added since the last reset. + * + * @return the sample count, which excludes the seed + */ + public long count() { + return count; + } + + /** + * Tells whether {@link #value()} may be queried. + * + * @return {@code true} once a seed or at least one sample is available + */ + public boolean isInitialized() { + return initialized; + } + + /** + * Restores the state the average had right after construction. + */ + public void reset() { + count = 0; + variance = 0.0; + mean = seeded ? seed : 0.0; + initialized = seeded; + } + + @Override + public String toString() { + return "ExponentialMovingAverage{alpha=" + alpha + ", value=" + (initialized ? mean : Double.NaN) + ", count=" + count + '}'; + } +} diff --git a/src/main/java/com/thealgorithms/streaming/ShiftSignal.java b/src/main/java/com/thealgorithms/streaming/ShiftSignal.java new file mode 100644 index 000000000000..467dcf060e0e --- /dev/null +++ b/src/main/java/com/thealgorithms/streaming/ShiftSignal.java @@ -0,0 +1,29 @@ +package com.thealgorithms.streaming; + +/** + * What a change detector reports after looking at one sample: either the stream still behaves as + * expected, or its level has shifted, in one direction or the other. + * + * @see CusumDetector + * @see EwmaChangeDetector + */ +public enum ShiftSignal { + + /** No evidence of a change; the stream is in control. */ + NONE, + + /** The level of the stream has moved above the target. */ + UPWARD, + + /** The level of the stream has moved below the target. */ + DOWNWARD; + + /** + * Tells whether this signal reports a change. + * + * @return {@code true} for {@link #UPWARD} and {@link #DOWNWARD} + */ + public boolean isAlarm() { + return this != NONE; + } +} diff --git a/src/test/java/com/thealgorithms/streaming/CusumDetectorTest.java b/src/test/java/com/thealgorithms/streaming/CusumDetectorTest.java index 9c0e4a49f56a..996d71d219bc 100644 --- a/src/test/java/com/thealgorithms/streaming/CusumDetectorTest.java +++ b/src/test/java/com/thealgorithms/streaming/CusumDetectorTest.java @@ -5,7 +5,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.thealgorithms.streaming.CusumDetector.ShiftSignal; import java.util.Random; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/thealgorithms/streaming/EwmaChangeDetectorTest.java b/src/test/java/com/thealgorithms/streaming/EwmaChangeDetectorTest.java new file mode 100644 index 000000000000..37b1688b424f --- /dev/null +++ b/src/test/java/com/thealgorithms/streaming/EwmaChangeDetectorTest.java @@ -0,0 +1,209 @@ +package com.thealgorithms.streaming; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class EwmaChangeDetectorTest { + + /** + * Index of the first alarm in a sequence of verdicts, or {@code -1} if there is none. + */ + private static int firstAlarm(ShiftSignal[] signals) { + for (int i = 0; i < signals.length; i++) { + if (signals[i].isAlarm()) { + return i; + } + } + return -1; + } + + private static double[] stepSignal(int length, int stepAt, double before, double after) { + double[] signal = new double[length]; + for (int i = 0; i < length; i++) { + signal[i] = i < stepAt ? before : after; + } + return signal; + } + + @Test + void rejectsInvalidConfiguration() { + assertThrows(IllegalArgumentException.class, () -> new EwmaChangeDetector(Double.NaN, 1.0)); + assertThrows(IllegalArgumentException.class, () -> new EwmaChangeDetector(0.0, 0.0)); + assertThrows(IllegalArgumentException.class, () -> new EwmaChangeDetector(0.0, -2.0)); + assertThrows(IllegalArgumentException.class, () -> new EwmaChangeDetector(0.0, 1.0, 0.2, 0.0)); + assertThrows(IllegalArgumentException.class, () -> new EwmaChangeDetector(0.0, 1.0, 0.0, 3.0)); + assertThrows(IllegalArgumentException.class, () -> new EwmaChangeDetector(0.0, 1.0, 1.5, 3.0)); + } + + @ParameterizedTest + @ValueSource(doubles = {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) + void rejectsNonFiniteSamples(double value) { + EwmaChangeDetector detector = new EwmaChangeDetector(0.0, 1.0); + assertThrows(IllegalArgumentException.class, () -> detector.accept(value)); + } + + @Test + void exposesItsConfiguration() { + EwmaChangeDetector detector = new EwmaChangeDetector(20.0, 0.5, 0.3, 2.5); + assertEquals(20.0, detector.target()); + assertEquals(0.5, detector.standardDeviation()); + assertEquals(0.3, detector.alpha()); + assertEquals(2.5, detector.width()); + assertEquals(20.0, detector.statistic()); + assertEquals(ShiftSignal.NONE, detector.lastSignal()); + assertEquals(0L, detector.count()); + assertEquals(0L, detector.alarmCount()); + } + + @Test + @DisplayName("the defaults are the customary alpha of 0.2 and a band of three sigmas") + void usesCustomaryDefaults() { + EwmaChangeDetector detector = new EwmaChangeDetector(0.0, 1.0); + assertEquals(EwmaChangeDetector.DEFAULT_ALPHA, detector.alpha()); + assertEquals(EwmaChangeDetector.DEFAULT_WIDTH, detector.width()); + } + + @Test + @DisplayName("a stream sitting exactly on target never raises an alarm") + void staysQuietOnTarget() { + EwmaChangeDetector detector = new EwmaChangeDetector(5.0, 1.0); + for (int i = 0; i < 10_000; i++) { + assertEquals(ShiftSignal.NONE, detector.accept(5.0)); + } + assertEquals(5.0, detector.statistic(), 1e-12); + assertEquals(0L, detector.alarmCount()); + assertEquals(10_000L, detector.count()); + } + + @Test + @DisplayName("the control band widens during the warm-up and then settles") + void controlLimitApproachesItsAsymptote() { + double alpha = 0.2; + double width = 3.0; + double sigma = 2.0; + EwmaChangeDetector detector = new EwmaChangeDetector(0.0, sigma, alpha, width); + + double asymptote = width * sigma * Math.sqrt(alpha / (2.0 - alpha)); + assertEquals(0.0, detector.controlLimit(), 1e-12); + + double previous = 0.0; + for (int i = 0; i < 40; i++) { + detector.accept(0.0); + double limit = detector.controlLimit(); + assertTrue(limit > previous, "the limit stopped growing at step " + i); + assertTrue(limit < asymptote, "the exact limit stays below its asymptote, but was " + limit); + previous = limit; + } + + for (int i = 0; i < 200; i++) { + detector.accept(0.0); + } + assertEquals(asymptote, detector.controlLimit(), 1e-9); + } + + @Test + void detectsAnUpwardShift() { + EwmaChangeDetector detector = new EwmaChangeDetector(0.0, 1.0, 0.2, 3.0); + ShiftSignal[] verdicts = detector.scan(stepSignal(60, 30, 0.0, 2.0)); + + int alarm = firstAlarm(verdicts); + assertTrue(alarm >= 30, "alarmed before the shift, at index " + alarm); + assertTrue(alarm <= 36, "took too long to alarm, index " + alarm); + assertEquals(ShiftSignal.UPWARD, verdicts[alarm]); + } + + @Test + void detectsADownwardShift() { + EwmaChangeDetector detector = new EwmaChangeDetector(100.0, 2.0, 0.2, 3.0); + ShiftSignal[] verdicts = detector.scan(stepSignal(60, 30, 100.0, 94.0)); + + int alarm = firstAlarm(verdicts); + assertTrue(alarm >= 30 && alarm <= 36, "alarm at index " + alarm); + assertEquals(ShiftSignal.DOWNWARD, verdicts[alarm]); + } + + @Test + @DisplayName("an alarm returns the statistic to the target so the detector does not latch") + void resetsTheStatisticOnAnAlarm() { + EwmaChangeDetector detector = new EwmaChangeDetector(0.0, 1.0, 0.2, 3.0); + ShiftSignal[] verdicts = detector.scan(stepSignal(40, 0, 2.0, 2.0)); + + assertTrue(firstAlarm(verdicts) >= 0); + assertTrue(detector.alarmCount() > 1, "a sustained shift should keep alarming"); + for (int i = 0; i < verdicts.length; i++) { + if (verdicts[i].isAlarm() && i + 1 < verdicts.length) { + assertEquals(ShiftSignal.NONE, verdicts[i + 1], "the sample right after an alarm restarts from the target"); + } + } + } + + @Test + @DisplayName("in-control noise rarely trips the band") + void staysMostlyQuietOnInControlNoise() { + Random random = new Random(20240517L); + EwmaChangeDetector detector = new EwmaChangeDetector(0.0, 1.0, 0.2, 4.0); + for (int i = 0; i < 2_000; i++) { + detector.accept(random.nextGaussian()); + } + assertTrue(detector.alarmCount() <= 3, "raised " + detector.alarmCount() + " false alarms in 2000 samples"); + } + + @Test + @DisplayName("a shift buried in noise is still found") + void detectsAShiftInNoisyData() { + Random random = new Random(4242L); + double[] signal = new double[400]; + for (int i = 0; i < signal.length; i++) { + signal[i] = (i < 200 ? 0.0 : 1.5) + random.nextGaussian(); + } + + EwmaChangeDetector detector = new EwmaChangeDetector(0.0, 1.0, 0.2, 4.0); + ShiftSignal[] verdicts = detector.scan(signal); + int alarm = firstAlarm(verdicts); + assertTrue(alarm >= 200, "alarmed before the shift, at index " + alarm); + assertTrue(alarm < 220, "the shift should be found quickly, but took until " + alarm); + assertEquals(ShiftSignal.UPWARD, verdicts[alarm]); + } + + @Test + void scanReportsOneVerdictPerSample() { + EwmaChangeDetector detector = new EwmaChangeDetector(0.0, 1.0); + ShiftSignal[] verdicts = detector.scan(new double[] {0.0, 0.0, 0.0}); + assertEquals(3, verdicts.length); + assertEquals(3L, detector.count()); + } + + @Test + void resetRestoresTheInitialState() { + EwmaChangeDetector detector = new EwmaChangeDetector(4.0, 1.0, 0.5, 3.0); + detector.scan(stepSignal(20, 0, 20.0, 20.0)); + detector.reset(); + + assertEquals(4.0, detector.statistic()); + assertEquals(0.0, detector.controlLimit(), 1e-12); + assertEquals(0L, detector.count()); + assertEquals(0L, detector.alarmCount()); + assertEquals(ShiftSignal.NONE, detector.lastSignal()); + } + + @Test + void toStringMentionsTheState() { + EwmaChangeDetector detector = new EwmaChangeDetector(7.0, 1.0); + assertTrue(detector.toString().contains("target=7.0"), detector.toString()); + } + + @Test + void rejectsNonFiniteConfiguration() { + assertThrows(IllegalArgumentException.class, () -> new EwmaChangeDetector(0.0, Double.NaN)); + assertThrows(IllegalArgumentException.class, () -> new EwmaChangeDetector(0.0, Double.POSITIVE_INFINITY)); + assertThrows(IllegalArgumentException.class, () -> new EwmaChangeDetector(0.0, 1.0, 0.2, Double.NaN)); + assertThrows(IllegalArgumentException.class, () -> new EwmaChangeDetector(0.0, 1.0, 0.2, Double.POSITIVE_INFINITY)); + } +} diff --git a/src/test/java/com/thealgorithms/streaming/ExponentialMovingAverageTest.java b/src/test/java/com/thealgorithms/streaming/ExponentialMovingAverageTest.java new file mode 100644 index 000000000000..83931dbec1b1 --- /dev/null +++ b/src/test/java/com/thealgorithms/streaming/ExponentialMovingAverageTest.java @@ -0,0 +1,180 @@ +package com.thealgorithms.streaming; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ExponentialMovingAverageTest { + + @ParameterizedTest + @ValueSource(doubles = {0.0, -0.5, 1.5, Double.NaN, Double.POSITIVE_INFINITY}) + void rejectsInvalidAlpha(double alpha) { + assertThrows(IllegalArgumentException.class, () -> ExponentialMovingAverage.ofAlpha(alpha)); + } + + @ParameterizedTest + @ValueSource(doubles = {0.0, 0.5, -3.0, Double.NaN, Double.POSITIVE_INFINITY}) + void rejectsInvalidSpan(double span) { + assertThrows(IllegalArgumentException.class, () -> ExponentialMovingAverage.ofSpan(span)); + } + + @ParameterizedTest + @ValueSource(doubles = {0.0, -1.0, Double.NaN, Double.POSITIVE_INFINITY}) + void rejectsInvalidHalfLife(double halfLife) { + assertThrows(IllegalArgumentException.class, () -> ExponentialMovingAverage.ofHalfLife(halfLife)); + } + + @Test + void rejectsNonFiniteSeed() { + assertThrows(IllegalArgumentException.class, () -> ExponentialMovingAverage.ofAlpha(0.5, Double.NaN)); + } + + @Test + void spanAndHalfLifeTranslateIntoAlpha() { + assertEquals(0.2, ExponentialMovingAverage.ofSpan(9.0).alpha(), 1e-12); + assertEquals(1.0, ExponentialMovingAverage.ofSpan(1.0).alpha(), 1e-12); + assertEquals(0.5, ExponentialMovingAverage.ofHalfLife(1.0).alpha(), 1e-12); + assertEquals(1.0 - Math.pow(0.5, 0.1), ExponentialMovingAverage.ofHalfLife(10.0).alpha(), 1e-12); + } + + @Test + void queriesBeforeTheFirstSampleFail() { + ExponentialMovingAverage average = ExponentialMovingAverage.ofAlpha(0.5); + assertFalse(average.isInitialized()); + assertThrows(IllegalStateException.class, average::value); + assertThrows(IllegalStateException.class, average::variance); + assertThrows(IllegalStateException.class, average::standardDeviation); + } + + @Test + @DisplayName("the first sample seeds the average instead of being pulled towards zero") + void seedsWithTheFirstSample() { + ExponentialMovingAverage average = ExponentialMovingAverage.ofAlpha(0.1); + assertEquals(100.0, average.add(100.0)); + assertTrue(average.isInitialized()); + assertEquals(1L, average.count()); + } + + @Test + void followsTheRecurrence() { + ExponentialMovingAverage average = ExponentialMovingAverage.ofAlpha(0.5); + assertEquals(1.0, average.add(1.0), 1e-12); + assertEquals(1.5, average.add(2.0), 1e-12); + assertEquals(2.25, average.add(3.0), 1e-12); + assertEquals(3L, average.count()); + } + + @Test + void anExplicitSeedIsUsedRightAway() { + ExponentialMovingAverage average = ExponentialMovingAverage.ofAlpha(0.5, 0.0); + assertTrue(average.isInitialized()); + assertEquals(0.0, average.value()); + assertEquals(0.5, average.add(1.0), 1e-12); + assertEquals(1L, average.count(), "the seed is not counted as a sample"); + } + + @Test + void alphaOfOneKeepsOnlyTheLatestSample() { + ExponentialMovingAverage average = ExponentialMovingAverage.ofAlpha(1.0); + average.addAll(1.0, 2.0, 3.0); + assertEquals(3.0, average.value(), 1e-12); + assertEquals(0.0, average.variance(), 1e-12); + } + + @Test + void trackedVarianceFollowsTheRecurrence() { + ExponentialMovingAverage average = ExponentialMovingAverage.ofAlpha(0.5); + average.add(0.0); + assertEquals(0.0, average.variance(), 1e-12); + average.add(2.0); + assertEquals(1.0, average.variance(), 1e-12); + average.add(0.0); + assertEquals(0.75, average.variance(), 1e-12); + assertEquals(Math.sqrt(0.75), average.standardDeviation(), 1e-12); + } + + @Test + void aConstantSignalHasNoSpread() { + ExponentialMovingAverage average = ExponentialMovingAverage.ofSpan(10.0); + for (int i = 0; i < 100; i++) { + average.add(7.0); + } + assertEquals(7.0, average.value(), 1e-12); + assertEquals(0.0, average.variance(), 1e-12); + } + + @Test + @DisplayName("a step in the signal is tracked, the faster the larger alpha is") + void tracksAStep() { + ExponentialMovingAverage fast = ExponentialMovingAverage.ofAlpha(0.5); + ExponentialMovingAverage slow = ExponentialMovingAverage.ofAlpha(0.05); + fast.add(0.0); + slow.add(0.0); + for (int i = 0; i < 10; i++) { + fast.add(10.0); + slow.add(10.0); + } + assertTrue(fast.value() > slow.value(), "fast=" + fast.value() + " slow=" + slow.value()); + assertEquals(10.0, fast.value(), 0.05); + assertTrue(slow.value() < 5.0); + + for (int i = 0; i < 500; i++) { + slow.add(10.0); + } + assertEquals(10.0, slow.value(), 1e-6); + } + + @Test + @DisplayName("on stationary noise the estimates sit close to the true mean and variance") + void approximatesTheStationaryMoments() { + Random random = new Random(987L); + ExponentialMovingAverage average = ExponentialMovingAverage.ofAlpha(0.01); + for (int i = 0; i < 200_000; i++) { + average.add(5.0 + 2.0 * random.nextGaussian()); + } + assertEquals(5.0, average.value(), 0.5); + assertEquals(4.0, average.variance(), 2.0); + } + + @Test + void resetReturnsToTheInitialState() { + ExponentialMovingAverage plain = ExponentialMovingAverage.ofAlpha(0.5); + plain.addAll(1.0, 2.0, 3.0); + plain.reset(); + assertFalse(plain.isInitialized()); + assertEquals(0L, plain.count()); + + ExponentialMovingAverage seeded = ExponentialMovingAverage.ofAlpha(0.5, 42.0); + seeded.addAll(1.0, 2.0); + seeded.reset(); + assertTrue(seeded.isInitialized()); + assertEquals(42.0, seeded.value()); + } + + @ParameterizedTest + @ValueSource(doubles = {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) + void rejectsNonFiniteSamples(double value) { + ExponentialMovingAverage average = ExponentialMovingAverage.ofAlpha(0.3); + assertThrows(IllegalArgumentException.class, () -> average.add(value)); + } + + @Test + void toStringMentionsTheState() { + ExponentialMovingAverage average = ExponentialMovingAverage.ofAlpha(0.25); + average.add(4.0); + assertTrue(average.toString().contains("alpha=0.25"), average.toString()); + } + + @Test + void toStringWorksBeforeTheFirstSample() { + ExponentialMovingAverage average = ExponentialMovingAverage.ofAlpha(0.25); + assertTrue(average.toString().contains("value=NaN"), average.toString()); + } +}