RedEnginePress logo
RedEnginePress
AlgorithmsLanguagesPlaygroundAbout

Cusum Detector

O
package com.thealgorithms.streaming;

/**
 * <b>CUSUM</b> (cumulative sum) change detection: it notices that the level of a stream has shifted,
 * long before the shift is visible in a moving average.
 *
 * <p>A threshold on the raw samples can only catch a change that is large compared with the noise.
 * CUSUM instead accumulates evidence. Every sample is normalised into
 * {@code z = (x - target) / sigma} and pushed into two one-sided sums:
 *
 * <pre>
 * upper &lt;- max(0, upper + z - allowance)
 * lower &lt;- max(0, lower - z - allowance)
 * </pre>
 *
 * <p>The <i>allowance</i> {@code k} is a toll paid on every step. While the stream sits at its
 * target the toll exceeds the average evidence, both sums are pinned at zero and the detector stays
 * quiet no matter how long it runs. As soon as the mean shifts by more than {@code k} standard
 * deviations, the corresponding sum starts drifting upwards, and it keeps drifting: a small but
 * persistent bias accumulates until it crosses the decision threshold {@code h}. That is the whole
 * point of the method - a shift of half a standard deviation is invisible in any single sample, yet
 * unmistakable after twenty of them.
 *
 * <p>The classic tuning is {@code k = delta / 2} for the shift size {@code delta} one wants to catch
 * quickly, together with {@code h} between 4 and 5, which keeps false alarms rare while detecting a
 * one sigma shift within roughly ten samples. Both sums are cleared whenever an alarm fires, so the
 * detector immediately starts looking for the next change instead of latching.
 *
 * <h2>Usage</h2>
 *
 * <pre>{@code
 * // Watch for a shift of one standard deviation around a target of 20.0.
 * CusumDetector detector = new CusumDetector(20.0, 0.5, 0.5, 5.0);
 * for (double sample : stream) {
 *     ShiftSignal signal = detector.accept(sample);
 *     if (signal.isAlarm()) {
 *         alert(signal, detector.count());
 *     }
 * }
 * }</pre>
 *
 * <p>Each sample costs O(1) time and the detector keeps O(1) state. This class is not thread-safe.
 *
 * @see <a href="https://en.wikipedia.org/wiki/CUSUM">CUSUM</a>
 */
public final class CusumDetector {

    /** Allowance used when none is given; it targets shifts of one standard deviation. */
    public static final double DEFAULT_ALLOWANCE = 0.5;

    /** Decision threshold used when none is given. */
    public static final double DEFAULT_THRESHOLD = 5.0;

    private final double target;
    private final double standardDeviation;
    private final double allowance;
    private final double threshold;

    private double upperSum;
    private double lowerSum;
    private long count;
    private long alarmCount;
    private ShiftSignal lastSignal = ShiftSignal.NONE;

    /**
     * Creates a detector tuned for shifts of about one standard deviation.
     *
     * @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 CusumDetector(double target, double standardDeviation) {
        this(target, standardDeviation, DEFAULT_ALLOWANCE, DEFAULT_THRESHOLD);
    }

    /**
     * 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 allowance the toll subtracted on every step, in standard deviations; half of the shift
     *     size one wants to detect quickly
     * @param threshold how much accumulated evidence raises an alarm, in standard deviations
     * @throws IllegalArgumentException if any argument is not finite, if {@code standardDeviation} or
     *     {@code threshold} is not strictly positive, or if {@code allowance} is negative
     */
    public CusumDetector(double target, double standardDeviation, double allowance, double threshold) {
        requireFinite(target, "target");
        if (!(standardDeviation > 0.0) || !Double.isFinite(standardDeviation)) {
            throw new IllegalArgumentException("The standard deviation must be finite and strictly positive, but was " + standardDeviation);
        }
        if (!(allowance >= 0.0) || !Double.isFinite(allowance)) {
            throw new IllegalArgumentException("The allowance must be finite and non-negative, but was " + allowance);
        }
        if (!(threshold > 0.0) || !Double.isFinite(threshold)) {
            throw new IllegalArgumentException("The threshold must be finite and strictly positive, but was " + threshold);
        }
        this.target = target;
        this.standardDeviation = standardDeviation;
        this.allowance = allowance;
        this.threshold = threshold;
    }

    /**
     * Feeds one sample into the detector.
     *
     * @param value the incoming sample
     * @return {@link ShiftSignal#NONE} while the stream stays in control, otherwise the direction of
     *     the detected shift; the accumulated sums are cleared on an alarm
     * @throws IllegalArgumentException if {@code value} is NaN or infinite
     */
    public ShiftSignal accept(double value) {
        requireFinite(value, "sample");
        count++;

        double normalized = (value - target) / standardDeviation;
        upperSum = Math.max(0.0, upperSum + normalized - allowance);
        lowerSum = Math.max(0.0, lowerSum - normalized - allowance);

        if (upperSum > threshold) {
            lastSignal = ShiftSignal.UPWARD;
        } else if (lowerSum > threshold) {
            lastSignal = ShiftSignal.DOWNWARD;
        } else {
            lastSignal = ShiftSignal.NONE;
        }

        if (lastSignal.isAlarm()) {
            alarmCount++;
            upperSum = 0.0;
            lowerSum = 0.0;
        }
        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 evidence accumulated in favour of an upward shift.
     *
     * @return the one-sided upper sum, never negative
     */
    public double upperSum() {
        return upperSum;
    }

    /**
     * Returns the evidence accumulated in favour of a downward shift.
     *
     * @return the one-sided lower sum, never negative
     */
    public double lowerSum() {
        return lowerSum;
    }

    /**
     * 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 per-step allowance.
     *
     * @return the allowance given at construction time
     */
    public double allowance() {
        return allowance;
    }

    /**
     * Returns the decision threshold.
     *
     * @return the threshold given at construction time
     */
    public double threshold() {
        return threshold;
    }

    /**
     * Clears the accumulated evidence and the counters.
     */
    public void reset() {
        upperSum = 0.0;
        lowerSum = 0.0;
        count = 0;
        alarmCount = 0;
        lastSignal = ShiftSignal.NONE;
    }

    @Override
    public String toString() {
        return "CusumDetector{target=" + target + ", upperSum=" + upperSum + ", lowerSum=" + lowerSum + ", alarms=" + alarmCount + '}';
    }

    private static void requireFinite(double value, String name) {
        if (!Double.isFinite(value)) {
            throw new IllegalArgumentException("The " + name + " must be finite, but was " + value);
        }
    }
}