Hypotheses, thresholds, and the ROC: deciding whether a signal is there at all

This workshop is full of detectors that do not call themselves that. The voice-activity gate that stops the pitch tracker from reporting F0 on silence. The energy-and-ZCR thresholds that carve audio into speech and non-speech. The Hampel fences that flag a spike. The lock-in asking whether the LED’s light reaches the photodiode. Every one computes a statistic from the data and compares it to a threshold, every one occasionally cries wolf, every one occasionally sleeps through a burglary, and every one was presented, honestly, as a rule of thumb.

Detection theory is the discipline that replaces the rule of thumb. It answers three questions exactly, for a precisely stated problem: which statistic to compute (the likelihood ratio, and nothing else), where to put the threshold (from the false-alarm rate you can live with), and what performance follows (a curve, not a number: the ROC). This page builds that machinery on the smallest possible problem, then spends it on the three detectors an embedded system actually runs: the matched filter when the waveform is known, the energy detector when it is not, and the CFAR detector when even the noise level is unknown, which is the one the hardware companion puts on live ADC data.

Prerequisites

Part of the estimation & detection arc; the overview gives the reading order.

Estimation basics supplies the likelihood-function machinery this page reuses for decisions. The ROC section of outlier detection is the measured warm-up act for the theory here, and noise and SNR covers the Gaussian background. The clean, importable code is in detection.py, checked by test_detection.py.


Two hypotheses, four outcomes

The setup is deliberately spartan. Two competing explanations for the data \(y\):

\[H_0: \text{no signal (noise only)} \qquad H_1: \text{signal present}\]

with the probability density of \(y\) known under each, \(f(y|H_0)\) and \(f(y|H_1)\). A decision rule maps every possible observation to one of the two hypotheses, which is the same as cutting the observation space into two regions. Two hypotheses and two possible decisions give four outcomes, and the two errors have names that radar gave the whole field:

  • False alarm (type I): deciding \(H_1\) when \(H_0\) is true, with probability \(P_{fa} = P[D = H_1 \mid H_0]\).
  • Miss (type II): deciding \(H_0\) when \(H_1\) is true, with probability \(P_m = P[D = H_0 \mid H_1]\); its complement is the detection probability \(P_d = 1 - P_m\).

The two errors trade against each other through the threshold, and the whole game is choosing the trade deliberately instead of by folklore. The working example for the next two sections is the smallest detection problem that exists: one sample, \(y = w\) under \(H_0\) versus \(y = A + w\) under \(H_1\), with \(w\) zero-mean Gaussian of variance \(\sigma^2\) and \(A > 0\) known.

Show the code
A, sigma = 2.0, 1.0
gamma = 1.1
y = np.linspace(-4, 6, 600)
f0 = norm.pdf(y, 0, sigma)
f1 = norm.pdf(y, A, sigma)

fig, ax = plt.subplots(figsize=(7.5, 3.4))
ax.plot(y, f0, 'C0', lw=1.4, label=r'$f(y|H_0)$')
ax.plot(y, f1, 'C2', lw=1.4, label=r'$f(y|H_1)$')
ax.fill_between(y[y >= gamma], f0[y >= gamma], color='C3', alpha=0.45,
                label=r'$P_{fa}$')
ax.fill_between(y[y <= gamma], f1[y <= gamma], color='C1', alpha=0.45,
                label=r'$P_m$')
ax.axvline(gamma, color='k', ls='--', lw=1)
ax.annotate(r'$\gamma$', (gamma + 0.08, 0.40), fontsize=12)
ax.annotate(r'$D=H_0$', (gamma - 1.7, 0.43), fontsize=10)
ax.annotate(r'$D=H_1$', (gamma + 0.9, 0.43), fontsize=10)
ax.set_xlabel('observation $y$'); ax.set_ylabel('density')
ax.set_ylim(0, 0.47)
ax.legend(fontsize=9); ax.grid(True, alpha=0.3)
fig.tight_layout(); plt.show()

pfa = norm.sf(gamma, 0, sigma)
pm = norm.cdf(gamma, A, sigma)
print(f"at gamma = {gamma}: Pfa = {pfa:.3f}, Pm = {pm:.3f}, Pd = {1-pm:.3f}")
assert abs(pfa - norm.sf(gamma / sigma)) < 1e-12
assert abs((1 - pm) - norm.sf((gamma - A) / sigma)) < 1e-12
Figure 1: The one-sample problem: f(y|H0) and f(y|H1) are unit-variance Gaussians A = 2σ apart, and a threshold γ cuts the axis into the two decision regions. The shaded right tail of the H0 density is the false-alarm probability; the shaded left tail of the H1 density is the miss probability. Sliding γ right shrinks one at the expense of the other; no placement removes either.
at gamma = 1.1: Pfa = 0.136, Pm = 0.184, Pd = 0.816

One statistic, many thresholds: the likelihood ratio

What is the best decision rule? The remarkable answer, and the organising idea of this page, is that every reasonable notion of “best” produces the same statistic and differs only in where it puts the threshold. Form the likelihood ratio

\[L(y) = \frac{f(y|H_1)}{f(y|H_0)} \underset{H_0}{\overset{H_1}{\gtrless}} \gamma\]

and the classical decision rules are all of this form:

  • MAP (minimum probability of error, priors \(P[H_0], P[H_1]\) known): \(\gamma = P[H_0]/P[H_1]\).
  • Bayes (decision costs \(C_{ij}\) known): \(\gamma = \dfrac{P[H_0](C_{10} - C_{00})}{P[H_1](C_{01} - C_{11})}\), which collapses to MAP for hit/miss costs.
  • Maximum likelihood (no priors, no costs): \(\gamma = 1\).
  • Neyman-Pearson (next section): \(\gamma\) set so that \(P_{fa}\) equals a chosen level.

For the one-sample Gaussian problem the log-likelihood ratio is worth working through once, because the simplification pattern repeats for every Gaussian detector on this page:

\[\ln L(y) = \frac{-(y-A)^2 + y^2}{2\sigma^2} = \frac{A}{\sigma^2}\left(y - \frac{A}{2}\right)\]

Monotone in \(y\): comparing \(L(y)\) to any \(\gamma\) is the same as comparing \(y\) itself to a threshold. The optimal detector for a level shift in Gaussian noise is “is the sample big?”, which is reassuring, and the theory’s contribution is not the rule but the calibration of the rule, plus the guarantee that nothing cleverer exists.

From the archive: a 2002 lecture chapter, re-derived

The primary source for these two sections is a chapter of typeset lecture notes on signal detection (2002, kept in the author’s reference collection and staged in _raw/desktop-dump-picks/references/): binary hypothesis testing on a BPAM communication example, MAP and Bayes rules, minimax and Neyman-Pearson, and the correlator detector that reappears below. Per this arc’s standing rule, every equation used here was re-derived rather than transcribed, and in this case the source survived intact: no slips found, in contrast to the 2003 periodogram derivation. What the chapter does not cover, the energy detector, ROC machinery beyond a single figure, and CFAR, follows Kay (Kay 1998) and Urkowitz (Urkowitz 1967) instead, with every closed form checked by Monte Carlo on this page and in the tests.


Neyman-Pearson and the ROC curve

Priors and costs are luxuries a measurement system rarely has: what is the prior probability that a pipe is leaking, or the cost ratio of a missed voice frame to a false trigger? The Neyman-Pearson framing needs neither. Fix the false-alarm probability at a level you can tolerate, then maximise the detection probability. The Neyman-Pearson lemma (Neyman and Pearson 1933) says the likelihood-ratio test with \(\gamma\) chosen to hit that \(P_{fa}\) achieves the largest \(P_d\) that any test, of any construction, can reach at that false-alarm rate.

For the one-sample problem everything is closed-form. With the threshold expressed through the target false-alarm rate, the achievable operating points are

\[P_d = Q\!\left(Q^{-1}(P_{fa}) - d\right), \qquad d = \frac{A}{\sigma}\]

with \(Q\) the Gaussian tail function. Sweeping \(P_{fa}\) from 0 to 1 traces the receiver operating characteristic: the complete menu of trades the detector offers. One number, the deflection \(d\) (how many noise standard deviations the statistic moves when the signal appears), fixes the entire curve.

Show the code
rng = np.random.default_rng(21)
pfa_grid = np.linspace(1e-4, 1, 400)

fig, ax = plt.subplots(figsize=(6.2, 5))
ax.plot([0, 1], [0, 1], 'k:', lw=0.8, label='chance')
for d in (0.5, 1.0, 2.0, 3.0):
    ax.plot(pfa_grid, norm.sf(norm.isf(pfa_grid) - d), lw=1.3,
            label=f'$d$ = {d:g}')
for d, marker in ((1.0, 'o'), (2.0, 's')):
    fpr, tpr = roc_empirical(rng.standard_normal(20000),
                             rng.standard_normal(20000) + d)
    ax.plot(fpr[::800], tpr[::800], marker, ms=4, mfc='none', color='gray',
            label=f'measured, $d$ = {d:g}')
    # The measured curve must sit on the closed form away from the
    # sparse extreme-tail region (0.03 covers the binomial wobble of
    # both axes at 20k trials, slope-amplified; the tests pin the
    # closed forms tightly with matched thresholds).
    mid = (fpr > 0.02) & (fpr < 0.6)
    assert np.max(np.abs(tpr[mid] - norm.sf(norm.isf(fpr[mid]) - d))) < 0.03
ax.set_xlabel('false-alarm probability $P_{fa}$')
ax.set_ylabel('detection probability $P_d$')
ax.legend(fontsize=8, loc='lower right'); ax.grid(True, alpha=0.3)
fig.tight_layout(); plt.show()
Figure 2: ROC curves for the Gaussian shift problem at four deflections, with Monte Carlo measurements (20,000 trials per hypothesis) overlaid for d = 1 and d = 2. The chance diagonal is a detector that ignores the data. The curve is the complete performance statement: pick the false-alarm rate you can afford on the horizontal axis and read off the detection rate the physics will sell you.

Three habits of reading a ROC are worth building early. First, comparing detectors at one shared threshold value is meaningless when their statistics live on different scales; comparing whole curves is always fair, which is exactly the lesson the outlier-detection page learned by measurement when its three fence multipliers turned out to be on three different scales. Second, the interesting corner is almost always the far left: real systems run at \(P_{fa}\) of \(10^{-3}\) or below, where the linear plot above compresses everything (plot \(P_d\) against \(\log P_{fa}\) when working there). Third, a ROC from a finite experiment inherits the granularity of its event counts, so the smooth curves here are the asymptotic promise, and the measured points are what a finite record delivers.


The matched filter is the optimal detector

The matched-filtering page derived its correlator by maximising output SNR over all linear filters, using nothing beyond the Cauchy-Schwarz inequality. Detection theory upgrades that result to the strongest statement available. For a known waveform \(s[m]\) in white Gaussian noise,

\[H_0: y[m] = w[m] \qquad H_1: y[m] = s[m] + w[m], \qquad m = 0, \ldots, N-1,\]

the log-likelihood ratio collapses, by the same square-expanding step as the one-sample case, to

\[\ln L(\mathbf{y}) = \frac{1}{\sigma^2}\left(\mathbf{y}^T\mathbf{s} - \frac{E_s}{2}\right), \qquad E_s = \sum_m s[m]^2,\]

monotone in the correlation \(\mathbf{y}^T\mathbf{s}\). So the Neyman-Pearson optimal test is the matched filter’s output compared to a threshold, and the optimality is no longer merely against linear filters: by the lemma, no detector of any kind, however nonlinear, achieves a higher \(P_d\) at the same \(P_{fa}\) in this problem. The correlator statistic is Gaussian under both hypotheses with common variance \(\sigma^2 E_s\), so the whole performance question reduces to the one-sample problem with deflection

\[d = \sqrt{\frac{E_s}{\sigma^2}} \equiv \sqrt{\text{ENR}}, \qquad P_d = Q\!\left(Q^{-1}(P_{fa}) - \sqrt{\text{ENR}}\right).\]

Only the energy-to-noise ratio enters: waveform shape buys resolution and interference rejection, never detectability, which is the pulse-compression story restated as a theorem.

Which SNR? State the parameterisation, again

The ENR \(E_s/\sigma^2\) is a total-energy ratio: for an \(N\)-sample window it is \(N\) times the per-sample SNR, and for a whole-cycle sinusoid of amplitude \(A\) it is \(N A^2/(2\sigma^2)\), i.e. \(N\) times the \(\eta\) of the sinusoid-estimation page. Detection curves quoted against the wrong axis shift by \(10\log_{10} N\) dB, a far larger error than any approximation on this page. This arc already paid for a parameterisation slip once (the CRLB trap); the same discipline applies to detection statistics, which is why every curve below carries a Monte Carlo check.


The energy detector: the price of not knowing the waveform

Suppose the waveform is unknown: a machine transient, an unpredicted interferer, a voice. With nothing to correlate against, the natural statistic is the energy in the window,

\[T(\mathbf{y}) = \sum_{m=0}^{N-1} y[m]^2 \qquad \frac{T}{\sigma^2} \sim \begin{cases} \chi^2_N & \text{under } H_0 \\ \chi'^2_N(\lambda), \; \lambda = E_s/\sigma^2 & \text{under } H_1 \end{cases}\]

a chi-square with \(N\) degrees of freedom, gaining a noncentrality equal to the ENR when a (deterministic) signal is present (Urkowitz 1967). Both distributions are exact, so threshold and \(P_d\) are again closed-form (energy_detector_threshold, energy_detector_pd), and the comparison with the matched filter at the same \(P_{fa}\) and the same signal energy is the honest cost of ignorance:

Show the code
rng = np.random.default_rng(22)
N, pfa_op = 64, 1e-3
enr_db = np.linspace(0, 26, 200)
enr = 10**(enr_db / 10)

fig, ax = plt.subplots(figsize=(7.5, 4.2))
ax.plot(enr_db, [matched_filter_pd(pfa_op, e) for e in enr], 'C0', lw=1.4,
        label='matched filter (known waveform)')
ax.plot(enr_db, [incoherent_pd(pfa_op, e) for e in enr], 'C2', lw=1.4,
        label='incoherent magnitude (unknown phase)')
ax.plot(enr_db, [energy_detector_pd(pfa_op, e, N) for e in enr], 'C3',
        lw=1.4, label=f'energy detector (N = {N})')

# Monte Carlo: run each detector for real at a few ENRs.
trials = 20000
s_shape = np.sin(2 * np.pi * 0.13 * np.arange(N) + 0.4)
s_unit = s_shape / np.sqrt(np.sum(s_shape**2))
for e_db in (8.0, 12.0, 16.0, 20.0):
    e = 10**(e_db / 10)
    s = np.sqrt(e) * s_unit                      # sigma = 1, so ENR = e
    w = rng.standard_normal((trials, N))
    t_mf = (w + s) @ s
    g_mf = np.sqrt(e) * norm.isf(pfa_op)
    ax.plot(e_db, np.mean(t_mf > g_mf), 'o', color='C0', mfc='none', ms=6)
    assert abs(np.mean(t_mf > g_mf) - matched_filter_pd(pfa_op, e)) < 0.02
    t_ed = np.sum((w + s)**2, axis=1)
    g_ed = energy_detector_threshold(pfa_op, N)
    ax.plot(e_db, np.mean(t_ed > g_ed), 's', color='C3', mfc='none', ms=6)
    assert abs(np.mean(t_ed > g_ed) - energy_detector_pd(pfa_op, e, N)) < 0.02

ax.set_xlabel('ENR $E_s/\\sigma^2$ [dB]')
ax.set_ylabel(f'$P_d$ at $P_{{fa}} = 10^{{-3}}$')
ax.legend(fontsize=8, loc='upper left'); ax.grid(True, alpha=0.3)
fig.tight_layout(); plt.show()

from scipy.optimize import brentq
enr_mf = (norm.isf(pfa_op) - norm.isf(0.9))**2
enr_ed = brentq(lambda e: energy_detector_pd(pfa_op, e, N) - 0.9, 1, 500)
gap_db = 10 * np.log10(enr_ed / enr_mf)
print(f"ENR for Pd = 0.9: matched {enr_mf:.1f} ({10*np.log10(enr_mf):.1f} dB), "
      f"energy {enr_ed:.1f} ({10*np.log10(enr_ed):.1f} dB), gap {gap_db:.2f} dB")
assert abs(gap_db - 5.3) < 0.1, "the quoted 5.3 dB gap at N = 64"
Figure 3: Detection probability against ENR at Pfa = 10⁻³ for a 64-sample window: the matched filter (known waveform), the energy detector (unknown waveform), and the incoherent magnitude detector of the lock-in section below (known frequency, unknown phase). Markers are Monte Carlo measurements (20,000 trials per point) of each actual detector; they sit on their closed forms. At Pd = 0.9 the energy detector needs 5.3 dB more signal energy than the matched filter; the incoherent detector gives up only about 1 dB.
ENR for Pd = 0.9: matched 19.1 (12.8 dB), energy 65.4 (18.2 dB), gap 5.34 dB

The gap is not a constant: it grows with the window. The matched filter’s deflection is \(\sqrt{\text{ENR}}\) regardless of \(N\); the energy statistic’s mean shifts by \(E_s\) while its \(H_0\) standard deviation grows as \(\sigma^2\sqrt{2N}\), giving deflection \(\text{ENR}/\sqrt{2N}\). Equating the two: at low SNR the energy detector needs roughly \(\sqrt{2N}\) times more signal energy, about 3 dB more for every quadrupling of the window (measured at \(P_d = 0.9\): a 3.2 dB gap at \(N = 16\), 5.3 dB at 64, 7.8 dB at 256, 10.5 dB at 1024). Long windows are where knowing the waveform pays most, which is why LIGO correlates against 250,000 templates rather than integrating energy, and why a wakeword detector beats a level meter. When the noise is colored rather than white, both detectors prewhiten first; the theory here then applies verbatim to the whitened data.


CFAR: when even the noise level is unknown

Every threshold above contains \(\sigma^2\). On real hardware that is the quantity you do not have: the noise floor moves with temperature, gain settings, ambient interference, the machine next door. And the chi-square threshold is brutally sensitive to getting it wrong, because at useful window lengths the whole game is played out on the distribution’s thin upper tail:

Show the code
# A 64-sample energy detector designed for Pfa = 1e-3 at an assumed
# noise level, fed noise at a slightly different level.
g = chi2.isf(1e-3, 64)
print("designed Pfa = 1e-3 at N = 64; actual Pfa when the noise is off by:")
for db in (0.5, 1.0, 2.0, 3.0):
    actual = chi2.sf(g / 10**(db / 10), 64)
    print(f"  +{db:.1f} dB: Pfa = {actual:.3g}  ({actual/1e-3:.0f}x the design)")
under = chi2.sf(g / 10**(-1.0 / 10), 64)
print(f"  -1.0 dB: Pfa = {under:.2g}  (and Pd falls with it)")
assert chi2.sf(g / 10**0.1, 64) > 0.05, "+1 dB must blow Pfa past 5%"
assert under < 1e-5, "-1 dB must collapse Pfa (and detections with it)"
designed Pfa = 1e-3 at N = 64; actual Pfa when the noise is off by:
  +0.5 dB: Pfa = 0.0098  (10x the design)
  +1.0 dB: Pfa = 0.0539  (54x the design)
  +2.0 dB: Pfa = 0.405  (405x the design)
  +3.0 dB: Pfa = 0.848  (848x the design)
  -1.0 dB: Pfa = 1.3e-06  (and Pd falls with it)

A one-decibel error in the assumed noise level turns a one-in-a-thousand false-alarm rate into one-in-twenty, and a 3 dB error into a detector that fires 85% of the time on pure noise. Underestimating in the other direction silently destroys \(P_d\) instead. No fixed threshold survives contact with a real noise floor; the threshold must be derived from the data.

The cell-averaging CFAR (constant false-alarm rate) detector does exactly that: estimate the noise level from \(N_{\text{ref}}\) reference blocks adjacent to the block under test, and set the threshold as a multiple of that estimate,

\[E_{\text{cut}} \underset{H_0}{\overset{H_1}{\gtrless}} \alpha \cdot \frac{1}{N_{\text{ref}}}\sum_{i=1}^{N_{\text{ref}}} E_i.\]

The reason this works exactly, not approximately, is that under \(H_0\) both sides are \(\sigma^2\) times chi-square variables, so their ratio is F-distributed with \((M, N_{\text{ref}}M)\) degrees of freedom for \(M\)-sample blocks, and \(\sigma^2\) cancels: the false-alarm rate depends only on \(\alpha\), at every noise level, which is the constant-false-alarm-rate property that names the method. The factor is \(\alpha = F^{-1}(P_{fa}; M, N_{\text{ref}}M)\) (cfar_factor), and for the square-law cells of classical radar (\(M = 2\)) it reduces to the textbook result \(P_{fa} = (1 + \alpha/N_{\text{ref}})^{-N_{\text{ref}}}\), which the tests verify to ten decimal places.

Show the code
rng = np.random.default_rng(23)
fs, M, n_ref, n_guard = 8000, 64, 16, 2
pfa_d = 1e-3
dur = 16.0
n = int(dur * fs)
t = np.arange(n) / fs

sigma_t = np.where(t < 8.0, 1.0, 10**0.5)        # +10 dB power step
x = sigma_t * rng.standard_normal(n)
burst = np.sin(2 * np.pi * 1100 * np.arange(M) / fs)
amp = 2.6
burst_blocks = set()
enr_bursts = []
# The third burst's amplitude scales by sqrt(10) because the noise
# VARIANCE stepped by 10: equal ENR means amplitude tracks sigma, not
# sigma^2 (a double-sqrt slip here shipped in the first draft, 5 dB
# quiet, and was caught by the review battery's equation lens).
for t0, scale, s2 in ((3.0, 1.0, 1.0), (6.2, 1.0, 1.0),
                      (12.5, 10**0.5, 10.0)):
    blk = round(t0 * fs / M)                     # snap to a block boundary
    x[blk * M:(blk + 1) * M] += amp * scale * burst
    burst_blocks.add(blk)
    e_b = np.sum((burst - burst.mean())**2)      # mean-removed, as detected
    enr_bursts.append((amp * scale)**2 * e_b / s2)
# The caption's equal-ENR claim, pinned: identical to well under 0.1 dB.
assert np.ptp(10 * np.log10(enr_bursts)) < 0.01
assert abs(10 * np.log10(enr_bursts[0]) - 23.3) < 0.05

e = block_energies(x, M, remove_mean=True)
alpha = cfar_factor(pfa_d, M - 1, n_ref)
det, thr = ca_cfar(e, alpha, n_ref, n_guard)
g_fixed = energy_detector_threshold(pfa_d, M - 1, 1.0)
t_blk = (np.arange(len(e)) + 0.5) * M / fs

fig, ax = plt.subplots(figsize=(7.5, 4))
ax.semilogy(t_blk, e, 'C0', lw=0.5, label='block energy (8 ms blocks)')
ax.semilogy(t_blk, thr, 'C3', lw=1.2, label='CFAR threshold')
ax.axhline(g_fixed, color='gray', ls='--', lw=1.2,
           label='fixed threshold (designed for the quiet half)')
ax.plot(t_blk[det], e[det], 'kv', ms=7, mfc='none', label='CFAR detections')
ax.set_xlabel('time [s]'); ax.set_ylabel('block energy')
ax.legend(fontsize=8, loc='lower right'); ax.grid(True, alpha=0.3)
fig.tight_layout(); plt.show()

step_blk = int(8.0 * fs) // M
relearn = set(range(step_blk, step_blk + n_ref + n_guard + 1))
extra = [i for i in np.where(det)[0] if i not in burst_blocks]
loud = e[step_blk + n_ref + n_guard:]
print(f"CFAR detections: bursts {sorted(burst_blocks)} all caught; "
      f"{len(extra)} step-transient hits, all inside blocks "
      f"{step_blk}..{step_blk + n_ref + n_guard}: "
      f"{set(extra) <= relearn}")
print(f"fixed threshold in the loud half: fires on "
      f"{np.mean(loud > g_fixed):.0%} of blocks")
assert burst_blocks <= set(np.where(det)[0]), "CFAR must catch all 3 bursts"
assert set(extra) <= relearn, "all other hits confined to the step transient"
assert np.mean(loud > g_fixed) > 0.95, "the fixed threshold must drown"
Figure 4: A CFAR energy detector surviving what breaks a fixed threshold. The record is white noise whose floor steps up 10 dB at t = 8 s, with three one-block tone bursts hidden at 3, 6.2, and 12.5 s, the third scaled with the floor so that all three sit at the same per-block ENR of 23.3 dB (asserted below, not eyeballed). The fixed threshold (gray), set for Pfa = 10⁻³ at the initial noise level, is correct for eight seconds and then fires on every block, permanently. The CFAR threshold (red) rides 16 reference blocks behind the data: it flags the three bursts, fires on the step itself for about a tenth of a second while the loud blocks fill its reference window, and is then correctly calibrated at the new floor. Over this record’s two thousand blocks the design itself promises about two false alarms; this particular noise draw happens to contain none.
CFAR detections: bursts [375, 775, 1562] all caught; 10 step-transient hits, all inside blocks 1000..1018: True
fixed threshold in the loud half: fires on 100% of blocks

The step transient in the figure is worth a pause rather than an apology. For about \(N_{\text{ref}} + N_{\text{guard}}\) blocks after the floor jumps, the detector reports “signal”, because a sustained rise genuinely is indistinguishable from a signal until enough history accumulates to call it the new normal. Whether a persistent change should count as an event or as a new baseline is not something a threshold can decide; it is the defining question of sequential change detection, the arc’s next page, and the CFAR’s re-learning window is the crude first answer. That page also makes the point that this detector’s greatest virtue is a blind spot: a detector engineered to be indifferent to the noise level cannot report that the noise level is drifting, which for a device left in a machine room for three years is often the measurement that mattered.

Beyond that transient, the self-calibration is not free, on two counts, and both are design parameters rather than surprises:

CFAR loss. The reference average is a noisy estimate of the noise level, so \(\alpha\) must be set slightly higher than the known-\(\sigma\) threshold to hold the same \(P_{fa}\), which costs detection. The loss is exactly computable (the signal-present ratio is noncentral-F, cfar_pd) and shrinks fast with the reference window: at \(M = 64\), \(P_{fa} = 10^{-3}\), \(P_d = 0.9\), the extra ENR required is 1.5 dB with 2 reference blocks, 0.4 dB with 8, 0.2 dB with 16, and 0.06 dB with 64. Sixteen blocks of noise history buy performance within a quarter-decibel of clairvoyance.

Show the code
# The quoted loss ladder, computed from the noncentral-F closed form.
from scipy.optimize import brentq
M_dof, pfa_l = 64, 1e-3
ideal = brentq(lambda e: energy_detector_pd(pfa_l, e, M_dof) - 0.9, 1, 500)
print(f"known-sigma energy detector: ENR {ideal:.1f} for Pd = 0.9")
losses = {}
for nr in (2, 4, 8, 16, 32, 64):
    need = brentq(lambda e: cfar_pd(pfa_l, e, M_dof, nr) - 0.9, 1, 2000)
    losses[nr] = 10 * np.log10(need / ideal)
    print(f"  n_ref = {nr:2d}: ENR {need:5.1f}, CFAR loss {losses[nr]:.2f} dB")
assert 1.4 < losses[2] < 1.6 and 0.15 < losses[16] < 0.30
assert losses[64] < 0.1
assert all(losses[a] > losses[b] for a, b in zip((2, 4, 8, 16, 32),
                                                 (4, 8, 16, 32, 64)))
known-sigma energy detector: ENR 65.4 for Pd = 0.9
  n_ref =  2: ENR  92.6, CFAR loss 1.51 dB
  n_ref =  4: ENR  78.8, CFAR loss 0.81 dB
  n_ref =  8: ENR  72.1, CFAR loss 0.42 dB
  n_ref = 16: ENR  68.7, CFAR loss 0.22 dB
  n_ref = 32: ENR  67.0, CFAR loss 0.11 dB
  n_ref = 64: ENR  66.2, CFAR loss 0.06 dB

Self-masking. The reference window must contain noise. A signal that leaks into it inflates the estimate and raises the threshold against itself, which is why the implementation keeps guard blocks between the cell under test and the reference window, and why a long-lasting signal (or a second target) can still mask a weaker one. The robust fix is to replace the reference mean with an order statistic such as the median, trading a little extra loss for immunity to contaminated reference cells (Rohling 1983). If that sounds familiar, it should: it is precisely the reasoning that led the outlier-detection page from standard-deviation fences to MAD fences.


The lock-in as a detector, finished

The lock-in page ended on a promise: its no-signal magnitude reading is Rayleigh-distributed, never zero, and deciding “tone present” means thresholding between that Rayleigh floor and the signal’s Rician distribution. The machinery of this page closes that loop in three lines. The two demodulated quadratures are independent Gaussians with per-quadrature variance \(\sigma_z^2\), so \(|z|^2/\sigma_z^2\) is \(\chi^2_2\) under \(H_0\) and noncentral \(\chi'^2_2(\lambda)\) with \(\lambda = A^2/\sigma_z^2\) under \(H_1\): the energy detector’s mathematics with \(N = 2\), applied after the lock-in has already collapsed the record onto one known frequency. The threshold relation is the cleanest closed form in all of detection theory,

\[P_{fa} = \exp\!\left(-\frac{\gamma^2}{2\sigma_z^2}\right) \quad\Longleftrightarrow\quad \gamma = \sigma_z\sqrt{-2\ln P_{fa}},\]

and \(P_d\) follows from the noncentral tail (incoherent_pd; equivalently the Marcum Q function of radar).

Show the code
# The closed forms against a simulated quadrature pair, and the price
# of not knowing the phase.
rng = np.random.default_rng(24)
sz, pfa_i = 1.0, 1e-3
gam = sz * np.sqrt(-2 * np.log(pfa_i))
trials = 200000
z0 = sz * (rng.standard_normal(trials) + 1j * rng.standard_normal(trials))
A_test = 4.0
z1 = z0 + A_test * np.exp(1j * rng.uniform(0, 2 * np.pi, trials))
pd_meas = np.mean(np.abs(z1) > gam)
pd_closed = incoherent_pd(pfa_i, A_test**2 / sz**2)
print(f"threshold {gam:.3f} sigma_z: measured Pfa {np.mean(np.abs(z0) > gam):.2e}, "
      f"target {pfa_i:.0e}")
print(f"Pd at A = 4 sigma_z (random phase): measured {pd_meas:.3f}, "
      f"closed form {pd_closed:.3f}")

from scipy.optimize import brentq
lam_coh = (norm.isf(pfa_i) - norm.isf(0.9))**2
lam_inc = brentq(lambda l: incoherent_pd(pfa_i, l) - 0.9, 1, 200)
print(f"lambda for Pd = 0.9: phase known {lam_coh:.1f}, "
      f"phase unknown {lam_inc:.1f}: incoherent loss "
      f"{10*np.log10(lam_inc/lam_coh):.2f} dB")
assert abs(np.mean(np.abs(z0) > gam) - pfa_i) < 3e-4
assert abs(pd_meas - pd_closed) < 0.01
assert 0.8 < 10 * np.log10(lam_inc / lam_coh) < 1.1
threshold 3.717 sigma_z: measured Pfa 9.30e-04, target 1e-03
Pd at A = 4 sigma_z (random phase): measured 0.660, closed form 0.661
lambda for Pd = 0.9: phase known 19.1, phase unknown 23.8: incoherent loss 0.96 dB

Note what the detector shrugged off: the signal’s phase was random in the simulation, and the magnitude test never noticed. That robustness costs about 1 dB against a hypothetical detector that knew the phase and could test the in-phase channel coherently, a fixed, modest price (the “incoherent loss”) that every envelope detector, from AM radio to this photometer, pays. For the lock-in photometer of the embedded companion, whose noise floor \(\sigma_z\) is predicted by \(\sqrt{S(f_0)/T}\) and verified on hardware, these two formulas turn “is the light path blocked?” into a design equation: choose the false-alarm rate, read off the threshold, and know the detection probability before building anything.


The workshop’s detectors, re-read

With the theory in hand, the rules of thumb scattered across this site resolve into instances of it:

Where The rule as shipped What it is
Pitch-detection VAD “RMS energy above a multiple of the estimated noise floor” A CA-CFAR energy detector, threshold factor chosen by ear rather than by \(F^{-1}(P_{fa})\)
Statistical-features voice gate Energy and ZCR thresholds An energy detector plus a crude waveform classifier on top
Hampel / MAD fences “Flag beyond \(k\) robust scale units from a robust centre” An order-statistic CFAR: the median-based scale estimate is the contamination-resistant reference window
Goertzel DTMF decision Tone bin energy against a threshold An incoherent single-frequency detector, the \(\chi^2_2\) case above
Lock-in presence check Magnitude above the Rayleigh floor The incoherent detector, now with exact \(P_{fa}\) and \(P_d\)

The re-reading is not a demotion of those pages; the rules work, which is why they shipped. What the theory adds is calibration (a threshold traceable to a false-alarm rate instead of to a demo signal), the guarantee of what cannot be improved (nothing beats the likelihood ratio), and honest limits: all the closed forms on this page assume white Gaussian noise, and real interference is bursty, colored, and non-stationary. The theory then still tells you what to measure, the empirical ROC of the outlier-detection page, even where it can no longer hand you the curve in closed form.


On hardware

Everything in the CFAR section runs comfortably on a microcontroller: block energies are a multiply-accumulate, the reference average is a running sum, and the threshold is one multiply, with no calibration constant anywhere because the CFAR ratio cancels the ADC’s gain, the amplifier’s gain, and the noise level all at once. The embedded companion builds the burst detector of the streaming figure on both ADR-005 platforms, timer-paced ADC and all, and confirms the designed false-alarm rate on live hardware data.


Going further

Sequential detection. Everything here decides on a fixed-length window. Letting the test keep observing until it is sure (Wald’s sequential probability ratio test) or watching for a change in distribution (CUSUM, GLR) is strictly more powerful for monitoring problems. That is now its own page, which picks up the CFAR’s re-learning transient and the composite-hypothesis thread below and runs both to their sequential conclusions; Allan variance remains this arc’s other deferred item.

Composite hypotheses. This page’s \(H_1\) always had known parameters (or none). When amplitude, frequency, or arrival time are unknown, the generalised likelihood ratio test estimates them first (usually by maximum likelihood, tying back to estimation basics) and detects with the estimate plugged in; the matched-filter bank of LIGO is exactly a GLRT over a gridded parameter space.

Random signals. For a Gaussian random signal in Gaussian noise the optimal statistic becomes a quadratic form (the estimator-correlator), interpolating between the matched filter and the energy detector; Kay’s Volume II (Kay 1998) covers the whole ladder.

M-ary decisions. Choosing among more than two hypotheses (which symbol, which keyword) generalises everything here to a bank of correlators and a maximum; the archive chapter closes with it, and the MFCC wakeword detector is a working example wearing feature-space clothes.

References

Kay, Steven M. 1998. Fundamentals of Statistical Signal Processing, Volume II: Detection Theory. Upper Saddle River, NJ: Prentice Hall PTR.
Neyman, Jerzy, and Egon S. Pearson. 1933. “On the Problem of the Most Efficient Tests of Statistical Hypotheses.” Philosophical Transactions of the Royal Society of London. Series A 231: 289–337. https://doi.org/10.1098/rsta.1933.0009.
Rohling, Hermann. 1983. “Radar CFAR Thresholding in Clutter and Multiple Target Situations.” IEEE Transactions on Aerospace and Electronic Systems AES-19 (4): 608–21. https://doi.org/10.1109/TAES.1983.309350.
Urkowitz, Harry. 1967. “Energy Detection of Unknown Deterministic Signals.” Proceedings of the IEEE 55 (4): 523–31. https://doi.org/10.1109/PROC.1967.5573.