Skip to content

Commit 1f08afb

Browse files
authored
feat: add EwmaChangeDetector, an EWMA control chart for streams (#7600)
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. Signed-off-by: alxkm <19151554+alxkm@users.noreply.github.com> Co-authored-by: alxkm <19151554+alxkm@users.noreply.github.com>
1 parent d93d0ea commit 1f08afb

7 files changed

Lines changed: 902 additions & 26 deletions

File tree

src/main/java/com/thealgorithms/streaming/CusumDetector.java

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -251,29 +251,4 @@ private static void requireFinite(double value, String name) {
251251
throw new IllegalArgumentException("The " + name + " must be finite, but was " + value);
252252
}
253253
}
254-
255-
/**
256-
* What the detector reports after looking at one sample: either the stream still behaves as
257-
* expected, or its level has shifted, in one direction or the other.
258-
*/
259-
public enum ShiftSignal {
260-
261-
/** No evidence of a change; the stream is in control. */
262-
NONE,
263-
264-
/** The level of the stream has moved above the target. */
265-
UPWARD,
266-
267-
/** The level of the stream has moved below the target. */
268-
DOWNWARD;
269-
270-
/**
271-
* Tells whether this signal reports a change.
272-
*
273-
* @return {@code true} for {@link #UPWARD} and {@link #DOWNWARD}
274-
*/
275-
public boolean isAlarm() {
276-
return this != NONE;
277-
}
278-
}
279254
}
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package com.thealgorithms.streaming;
2+
3+
/**
4+
* An <b>EWMA control chart</b>: change detection built on the exponentially weighted moving average.
5+
*
6+
* <p>The detector smooths the stream with
7+
* {@link ExponentialMovingAverage} seeded at the target level and compares the smoothed value with a
8+
* band around that target:
9+
*
10+
* <pre>
11+
* z &lt;- z + alpha * (x - target)
12+
* limit = width * sigma * sqrt( alpha / (2 - alpha) * (1 - (1 - alpha)^(2n)) )
13+
* </pre>
14+
*
15+
* <p>The band is the exact standard deviation of {@code z} under the null hypothesis, multiplied by
16+
* the desired width in sigmas. It starts narrow and widens towards its asymptote
17+
* {@code width * sigma * sqrt(alpha / (2 - alpha))}, which keeps the false alarm rate steady during
18+
* the warm-up instead of letting the first few samples trip the alarm. An alarm fires as soon as the
19+
* smoothed value leaves the band; the average is then reset to the target so that the detector
20+
* starts fresh on the next change rather than latching.
21+
*
22+
* <p>Where {@link CusumDetector} accumulates evidence without limit and so excels at small,
23+
* persistent shifts, an EWMA chart looks at a decaying window of the recent past: {@code alpha}
24+
* around {@code 0.1 - 0.3} is a good compromise, larger values reacting faster to big jumps and
25+
* smaller ones being more sensitive to slow drifts. A width of 3 sigmas is the customary setting.
26+
*
27+
* <h2>Usage</h2>
28+
*
29+
* <pre>{@code
30+
* EwmaChangeDetector detector = new EwmaChangeDetector(20.0, 0.5, 0.2, 3.0);
31+
* for (double sample : stream) {
32+
* if (detector.accept(sample).isAlarm()) {
33+
* alert(detector.statistic(), detector.controlLimit());
34+
* }
35+
* }
36+
* }</pre>
37+
*
38+
* <p>Each sample costs O(1) time and the detector keeps O(1) state. This class is not thread-safe.
39+
*
40+
* @see CusumDetector
41+
* @see ExponentialMovingAverage
42+
* @see <a href="https://en.wikipedia.org/wiki/EWMA_chart">EWMA chart</a>
43+
*/
44+
public final class EwmaChangeDetector {
45+
46+
/** Smoothing factor used when none is given. */
47+
public static final double DEFAULT_ALPHA = 0.2;
48+
49+
/** Half-width of the control band, in sigmas, used when none is given. */
50+
public static final double DEFAULT_WIDTH = 3.0;
51+
52+
private final double target;
53+
private final double standardDeviation;
54+
private final double alpha;
55+
private final double width;
56+
57+
private final ExponentialMovingAverage average;
58+
private long count;
59+
private long stepsSinceAlarm;
60+
private long alarmCount;
61+
private ShiftSignal lastSignal = ShiftSignal.NONE;
62+
63+
/**
64+
* Creates a detector with the customary smoothing factor of {@code 0.2} and a band of three
65+
* sigmas.
66+
*
67+
* @param target the level the stream is expected to sit at
68+
* @param standardDeviation the noise level of the stream, strictly positive
69+
* @throws IllegalArgumentException if {@code target} is not finite or {@code standardDeviation} is not strictly positive
70+
*/
71+
public EwmaChangeDetector(double target, double standardDeviation) {
72+
this(target, standardDeviation, DEFAULT_ALPHA, DEFAULT_WIDTH);
73+
}
74+
75+
/**
76+
* Creates a detector.
77+
*
78+
* @param target the level the stream is expected to sit at
79+
* @param standardDeviation the noise level of the stream, strictly positive
80+
* @param alpha smoothing factor in {@code (0, 1]}; larger values react faster but tolerate less noise
81+
* @param width half-width of the control band in sigmas, strictly positive
82+
* @throws IllegalArgumentException if any argument is not finite, if {@code standardDeviation} or
83+
* {@code width} is not strictly positive, or if {@code alpha} is outside {@code (0, 1]}
84+
*/
85+
public EwmaChangeDetector(double target, double standardDeviation, double alpha, double width) {
86+
if (!Double.isFinite(target)) {
87+
throw new IllegalArgumentException("The target must be finite, but was " + target);
88+
}
89+
if (!(standardDeviation > 0.0) || !Double.isFinite(standardDeviation)) {
90+
throw new IllegalArgumentException("The standard deviation must be finite and strictly positive, but was " + standardDeviation);
91+
}
92+
if (!(width > 0.0) || !Double.isFinite(width)) {
93+
throw new IllegalArgumentException("The width must be finite and strictly positive, but was " + width);
94+
}
95+
this.target = target;
96+
this.standardDeviation = standardDeviation;
97+
this.width = width;
98+
this.average = ExponentialMovingAverage.ofAlpha(alpha, target);
99+
this.alpha = this.average.alpha();
100+
}
101+
102+
/**
103+
* Feeds one sample into the detector.
104+
*
105+
* @param value the incoming sample
106+
* @return {@link ShiftSignal#NONE} while the smoothed value stays inside the control band,
107+
* otherwise the direction in which it left the band; the average is reset to the target on an alarm
108+
* @throws IllegalArgumentException if {@code value} is NaN or infinite
109+
*/
110+
public ShiftSignal accept(double value) {
111+
if (!Double.isFinite(value)) {
112+
throw new IllegalArgumentException("Samples must be finite, but was " + value);
113+
}
114+
count++;
115+
stepsSinceAlarm++;
116+
double smoothed = average.add(value);
117+
double deviation = smoothed - target;
118+
double limit = controlLimit();
119+
120+
if (deviation > limit) {
121+
lastSignal = ShiftSignal.UPWARD;
122+
} else if (deviation < -limit) {
123+
lastSignal = ShiftSignal.DOWNWARD;
124+
} else {
125+
lastSignal = ShiftSignal.NONE;
126+
}
127+
128+
if (lastSignal.isAlarm()) {
129+
alarmCount++;
130+
stepsSinceAlarm = 0;
131+
average.reset();
132+
}
133+
return lastSignal;
134+
}
135+
136+
/**
137+
* Runs the detector over a whole signal.
138+
*
139+
* @param signal the samples to inspect
140+
* @return a new array of the same length holding the verdict for every sample
141+
* @throws IllegalArgumentException if any sample is NaN or infinite
142+
* @throws NullPointerException if {@code signal} is {@code null}
143+
*/
144+
public ShiftSignal[] scan(double[] signal) {
145+
ShiftSignal[] signals = new ShiftSignal[signal.length];
146+
for (int i = 0; i < signal.length; i++) {
147+
signals[i] = accept(signal[i]);
148+
}
149+
return signals;
150+
}
151+
152+
/**
153+
* Returns the smoothed value the alarm decision is based on.
154+
*
155+
* @return the exponentially weighted average of the stream
156+
*/
157+
public double statistic() {
158+
return average.value();
159+
}
160+
161+
/**
162+
* Returns the current half-width of the control band, which widens during the warm-up and then
163+
* settles.
164+
*
165+
* @return the distance from the target at which an alarm fires
166+
*/
167+
public double controlLimit() {
168+
double asymptotic = alpha / (2.0 - alpha);
169+
double warmUp = -Math.expm1(2.0 * stepsSinceAlarm * Math.log1p(-alpha));
170+
return width * standardDeviation * Math.sqrt(asymptotic * warmUp);
171+
}
172+
173+
/**
174+
* Returns the verdict on the most recent sample.
175+
*
176+
* @return the last signal, {@link ShiftSignal#NONE} before the first sample
177+
*/
178+
public ShiftSignal lastSignal() {
179+
return lastSignal;
180+
}
181+
182+
/**
183+
* Returns how many samples have been inspected since the last reset.
184+
*
185+
* @return the sample count
186+
*/
187+
public long count() {
188+
return count;
189+
}
190+
191+
/**
192+
* Returns how many alarms have been raised since the last reset.
193+
*
194+
* @return the alarm count
195+
*/
196+
public long alarmCount() {
197+
return alarmCount;
198+
}
199+
200+
/**
201+
* Returns the expected level of the stream.
202+
*
203+
* @return the target given at construction time
204+
*/
205+
public double target() {
206+
return target;
207+
}
208+
209+
/**
210+
* Returns the assumed noise level.
211+
*
212+
* @return the standard deviation given at construction time
213+
*/
214+
public double standardDeviation() {
215+
return standardDeviation;
216+
}
217+
218+
/**
219+
* Returns the smoothing factor in use.
220+
*
221+
* @return alpha
222+
*/
223+
public double alpha() {
224+
return alpha;
225+
}
226+
227+
/**
228+
* Returns the configured band half-width.
229+
*
230+
* @return the width in sigmas given at construction time
231+
*/
232+
public double width() {
233+
return width;
234+
}
235+
236+
/**
237+
* Returns the average to the target and clears the counters.
238+
*/
239+
public void reset() {
240+
average.reset();
241+
count = 0;
242+
stepsSinceAlarm = 0;
243+
alarmCount = 0;
244+
lastSignal = ShiftSignal.NONE;
245+
}
246+
247+
@Override
248+
public String toString() {
249+
return "EwmaChangeDetector{target=" + target + ", statistic=" + statistic() + ", limit=" + controlLimit() + ", alarms=" + alarmCount + '}';
250+
}
251+
}

0 commit comments

Comments
 (0)