Skip to content

Commit 8abfcf7

Browse files
authored
feat: add CusumDetector, cumulative sum change detection for streams (#7593)
Signed-off-by: alxkm <19151554+alxkm@users.noreply.github.com> Co-authored-by: alxkm <19151554+alxkm@users.noreply.github.com>
1 parent ad10eda commit 8abfcf7

2 files changed

Lines changed: 470 additions & 0 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
package com.thealgorithms.streaming;
2+
3+
/**
4+
* <b>CUSUM</b> (cumulative sum) change detection: it notices that the level of a stream has shifted,
5+
* long before the shift is visible in a moving average.
6+
*
7+
* <p>A threshold on the raw samples can only catch a change that is large compared with the noise.
8+
* CUSUM instead accumulates evidence. Every sample is normalised into
9+
* {@code z = (x - target) / sigma} and pushed into two one-sided sums:
10+
*
11+
* <pre>
12+
* upper &lt;- max(0, upper + z - allowance)
13+
* lower &lt;- max(0, lower - z - allowance)
14+
* </pre>
15+
*
16+
* <p>The <i>allowance</i> {@code k} is a toll paid on every step. While the stream sits at its
17+
* target the toll exceeds the average evidence, both sums are pinned at zero and the detector stays
18+
* quiet no matter how long it runs. As soon as the mean shifts by more than {@code k} standard
19+
* deviations, the corresponding sum starts drifting upwards, and it keeps drifting: a small but
20+
* persistent bias accumulates until it crosses the decision threshold {@code h}. That is the whole
21+
* point of the method - a shift of half a standard deviation is invisible in any single sample, yet
22+
* unmistakable after twenty of them.
23+
*
24+
* <p>The classic tuning is {@code k = delta / 2} for the shift size {@code delta} one wants to catch
25+
* quickly, together with {@code h} between 4 and 5, which keeps false alarms rare while detecting a
26+
* one sigma shift within roughly ten samples. Both sums are cleared whenever an alarm fires, so the
27+
* detector immediately starts looking for the next change instead of latching.
28+
*
29+
* <h2>Usage</h2>
30+
*
31+
* <pre>{@code
32+
* // Watch for a shift of one standard deviation around a target of 20.0.
33+
* CusumDetector detector = new CusumDetector(20.0, 0.5, 0.5, 5.0);
34+
* for (double sample : stream) {
35+
* ShiftSignal signal = detector.accept(sample);
36+
* if (signal.isAlarm()) {
37+
* alert(signal, detector.count());
38+
* }
39+
* }
40+
* }</pre>
41+
*
42+
* <p>Each sample costs O(1) time and the detector keeps O(1) state. This class is not thread-safe.
43+
*
44+
* @see <a href="https://en.wikipedia.org/wiki/CUSUM">CUSUM</a>
45+
*/
46+
public final class CusumDetector {
47+
48+
/** Allowance used when none is given; it targets shifts of one standard deviation. */
49+
public static final double DEFAULT_ALLOWANCE = 0.5;
50+
51+
/** Decision threshold used when none is given. */
52+
public static final double DEFAULT_THRESHOLD = 5.0;
53+
54+
private final double target;
55+
private final double standardDeviation;
56+
private final double allowance;
57+
private final double threshold;
58+
59+
private double upperSum;
60+
private double lowerSum;
61+
private long count;
62+
private long alarmCount;
63+
private ShiftSignal lastSignal = ShiftSignal.NONE;
64+
65+
/**
66+
* Creates a detector tuned for shifts of about one standard deviation.
67+
*
68+
* @param target the level the stream is expected to sit at
69+
* @param standardDeviation the noise level of the stream, strictly positive
70+
* @throws IllegalArgumentException if {@code target} is not finite or {@code standardDeviation} is not strictly positive
71+
*/
72+
public CusumDetector(double target, double standardDeviation) {
73+
this(target, standardDeviation, DEFAULT_ALLOWANCE, DEFAULT_THRESHOLD);
74+
}
75+
76+
/**
77+
* Creates a detector.
78+
*
79+
* @param target the level the stream is expected to sit at
80+
* @param standardDeviation the noise level of the stream, strictly positive
81+
* @param allowance the toll subtracted on every step, in standard deviations; half of the shift
82+
* size one wants to detect quickly
83+
* @param threshold how much accumulated evidence raises an alarm, in standard deviations
84+
* @throws IllegalArgumentException if any argument is not finite, if {@code standardDeviation} or
85+
* {@code threshold} is not strictly positive, or if {@code allowance} is negative
86+
*/
87+
public CusumDetector(double target, double standardDeviation, double allowance, double threshold) {
88+
requireFinite(target, "target");
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 (!(allowance >= 0.0) || !Double.isFinite(allowance)) {
93+
throw new IllegalArgumentException("The allowance must be finite and non-negative, but was " + allowance);
94+
}
95+
if (!(threshold > 0.0) || !Double.isFinite(threshold)) {
96+
throw new IllegalArgumentException("The threshold must be finite and strictly positive, but was " + threshold);
97+
}
98+
this.target = target;
99+
this.standardDeviation = standardDeviation;
100+
this.allowance = allowance;
101+
this.threshold = threshold;
102+
}
103+
104+
/**
105+
* Feeds one sample into the detector.
106+
*
107+
* @param value the incoming sample
108+
* @return {@link ShiftSignal#NONE} while the stream stays in control, otherwise the direction of
109+
* the detected shift; the accumulated sums are cleared on an alarm
110+
* @throws IllegalArgumentException if {@code value} is NaN or infinite
111+
*/
112+
public ShiftSignal accept(double value) {
113+
requireFinite(value, "sample");
114+
count++;
115+
116+
double normalized = (value - target) / standardDeviation;
117+
upperSum = Math.max(0.0, upperSum + normalized - allowance);
118+
lowerSum = Math.max(0.0, lowerSum - normalized - allowance);
119+
120+
if (upperSum > threshold) {
121+
lastSignal = ShiftSignal.UPWARD;
122+
} else if (lowerSum > threshold) {
123+
lastSignal = ShiftSignal.DOWNWARD;
124+
} else {
125+
lastSignal = ShiftSignal.NONE;
126+
}
127+
128+
if (lastSignal.isAlarm()) {
129+
alarmCount++;
130+
upperSum = 0.0;
131+
lowerSum = 0.0;
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 evidence accumulated in favour of an upward shift.
154+
*
155+
* @return the one-sided upper sum, never negative
156+
*/
157+
public double upperSum() {
158+
return upperSum;
159+
}
160+
161+
/**
162+
* Returns the evidence accumulated in favour of a downward shift.
163+
*
164+
* @return the one-sided lower sum, never negative
165+
*/
166+
public double lowerSum() {
167+
return lowerSum;
168+
}
169+
170+
/**
171+
* Returns the verdict on the most recent sample.
172+
*
173+
* @return the last signal, {@link ShiftSignal#NONE} before the first sample
174+
*/
175+
public ShiftSignal lastSignal() {
176+
return lastSignal;
177+
}
178+
179+
/**
180+
* Returns how many samples have been inspected since the last reset.
181+
*
182+
* @return the sample count
183+
*/
184+
public long count() {
185+
return count;
186+
}
187+
188+
/**
189+
* Returns how many alarms have been raised since the last reset.
190+
*
191+
* @return the alarm count
192+
*/
193+
public long alarmCount() {
194+
return alarmCount;
195+
}
196+
197+
/**
198+
* Returns the expected level of the stream.
199+
*
200+
* @return the target given at construction time
201+
*/
202+
public double target() {
203+
return target;
204+
}
205+
206+
/**
207+
* Returns the assumed noise level.
208+
*
209+
* @return the standard deviation given at construction time
210+
*/
211+
public double standardDeviation() {
212+
return standardDeviation;
213+
}
214+
215+
/**
216+
* Returns the per-step allowance.
217+
*
218+
* @return the allowance given at construction time
219+
*/
220+
public double allowance() {
221+
return allowance;
222+
}
223+
224+
/**
225+
* Returns the decision threshold.
226+
*
227+
* @return the threshold given at construction time
228+
*/
229+
public double threshold() {
230+
return threshold;
231+
}
232+
233+
/**
234+
* Clears the accumulated evidence and the counters.
235+
*/
236+
public void reset() {
237+
upperSum = 0.0;
238+
lowerSum = 0.0;
239+
count = 0;
240+
alarmCount = 0;
241+
lastSignal = ShiftSignal.NONE;
242+
}
243+
244+
@Override
245+
public String toString() {
246+
return "CusumDetector{target=" + target + ", upperSum=" + upperSum + ", lowerSum=" + lowerSum + ", alarms=" + alarmCount + '}';
247+
}
248+
249+
private static void requireFinite(double value, String name) {
250+
if (!Double.isFinite(value)) {
251+
throw new IllegalArgumentException("The " + name + " must be finite, but was " + value);
252+
}
253+
}
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+
}
279+
}

0 commit comments

Comments
 (0)