From 7266171e09660510cc01a3bbc4fd2db6343334e2 Mon Sep 17 00:00:00 2001 From: alxkm <19151554+alxkm@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:29:13 +0200 Subject: [PATCH] feat: add EwmaChangeDetector, an EWMA control chart for streams Adds an exponentially weighted moving average control chart that flags level shifts in a stream in O(1) time and memory per sample, with an exact variance-based control band so that the warm-up does not trip false alarms. It builds on a new reusable ExponentialMovingAverage (alpha, span or half-life parameterisation, and the exponentially weighted variance of the same stream) and shares the ShiftSignal verdict enum with CusumDetector, out of which it is extracted to the package level so that both detectors report the same type. Co-authored-by: Oleksandr Klymenko <19151554+alxkm@users.noreply.github.com> Signed-off-by: alxkm <19151554+alxkm@users.noreply.github.com> --- .../streaming/CusumDetector.java | 25 -- .../streaming/EwmaChangeDetector.java | 251 ++++++++++++++++++ .../streaming/ExponentialMovingAverage.java | 233 ++++++++++++++++ .../thealgorithms/streaming/ShiftSignal.java | 29 ++ .../streaming/CusumDetectorTest.java | 1 - .../streaming/EwmaChangeDetectorTest.java | 209 +++++++++++++++ .../ExponentialMovingAverageTest.java | 180 +++++++++++++ 7 files changed, 902 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/thealgorithms/streaming/EwmaChangeDetector.java create mode 100644 src/main/java/com/thealgorithms/streaming/ExponentialMovingAverage.java create mode 100644 src/main/java/com/thealgorithms/streaming/ShiftSignal.java create mode 100644 src/test/java/com/thealgorithms/streaming/EwmaChangeDetectorTest.java create mode 100644 src/test/java/com/thealgorithms/streaming/ExponentialMovingAverageTest.java 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()); + } +}