Moving Average Smoothing
The simplest smoothing method, averaging neighbouring points
The idea of smoothing data by averaging neighbouring values is older than its name. In 1901 the English statistician R. H. Hooker published a study relating marriage rates to trade prosperity in which he computed what he called "instantaneous averages": each year's figure was replaced by the mean of itself and the years around it, so that the long-term trend could be seen through the year-to-year fluctuations [7]. In 1909 G. U. Yule described Hooker's averages as "moving-averages" in the Journal of the Royal Statistical Society [8], and the term spread after W. I. King used it in his textbook The Elements of Statistical Method (1912) [9].
The practice itself is older still. In 1870 the actuary W. S. B. Woolhouse published a method for "graduating" mortality tables that replaced each death rate by a weighted average of the rates at neighbouring ages, to iron out irregularities caused by small numbers [11]. In 1884 the physicist J. H. Poynting averaged successive years of wheat prices and of cotton and silk imports to see the trend beneath the year-to-year fluctuations [10]. Both used the procedure long before it had a name.
What makes the moving average so durable is its simplicity. When digital signal processing appeared in the mid-20th century and engineers needed filters that the earliest computers could run, the moving average (called a boxcar filter in that world, because its weights form a flat rectangular box) was the obvious first choice. It needs only additions and one division. That is why it is the first smoothing method most scientists learn, and the natural starting point before more refined methods such as Savitzky-Golay or Whittaker smoothing.
Real spectroscopic data are noisy. When you record a fluorescence emission spectrum, an IR absorption scan or a Raman spectrum, the result is not a smooth curve. It wiggles up and down, even when the same sample is measured twice under identical conditions.
This is not a flaw of your instrument. It is physics. A spectrometer counts photons (or electrons), their arrival is random, and the detector and electronics add their own thermal noise, so even the best instruments produce noisy data. We discussed where measurement uncertainty comes from in Least Squares: your first step into chemometrics, and the different kinds of noise in the noise reduction overview.
What your instrument actually measures
A fluorescence emission spectrum measured once. The dashed curve is the true spectrum; the dots are what the detector reports. Draw a new measurement to see that the noise changes every time while the spectrum underneath does not.
Your brain sees through the noise and recognises a band here and a baseline there. A computer only sees numbers: 0.421, 0.398, 0.447, 0.389... Random fluctuations can create false peaks or hide real ones, they add uncertainty to band heights and areas, and they can be fitted by calibration models as if they were information. To reduce these problems we use smoothing: methods that remove random fluctuations while keeping the underlying chemical information.
The moving average rests on one observation:
Noise is independent from point to point. Signal, very often, is continuous.
Many spectroscopic signals come from physical phenomena (molecular vibrations, electronic transitions, fluorescence emission) that tend to change gradually with wavelength. A molecule's absorbance does not jump wildly from one nanometre to the next. If the absorbance at 500 nm is 0.40, the absorbance at 501 nm will be close to 0.40, not 0.95 or 0.02. Neighbouring points carry almost the same information.
Noise does not behave like that. If it pushes the value at 500 nm up, it is just as likely to push the value at 501 nm down.
Suppose the true signal at 499, 500 and 501 nm is 0.39, 0.40 and 0.41, but noise makes you measure 0.35, 0.46 and 0.38. The average of the three measurements is 0.397, very close to the true value at 500 nm. The errors (some positive, some negative) partly cancelled; the signal, nearly the same at the three points, survived.
Not every signal is continuous, though. Mass spectra or atomic emission lines can be so sharp that neighbouring points are not similar, and averaging them destroys the feature. Keep the nature of your signal in mind before smoothing anything.
Moving average smoothing (also called running mean or boxcar filter) applies this principle by replacing each point with the average of itself and its neighbours:
where:
- is the smoothed value at point ,
- is the window size (the number of points averaged),
- is the number of neighbours taken on each side.
That is the whole method.
Before going further, watch the window slide along a spectrum:
Moving average: the sliding window
The highlighted points are averaged to give the smoothed value at the ringed position. Then the window moves one point to the right.
Try this:
- Click Slide the window to watch the window travel across the spectrum, building the smoothed curve point by point.
- Use the second slider to step through the points by hand.
- Change the window size and observe the smoothing effect.
- Notice how larger windows give smoother curves but lower and widen the bands.
- Move to the first or last points and see the window shrink, because there are no neighbours beyond the edge.
The ringed red points are the neighbours being averaged, and the orange ring is the smoothed value they produce.
For a spectrum with intensities :
-
Choose a window size
This decides how many points take part in each average. It is usually odd, for reasons explained below.
-
For each point, gather its neighbours
Collect the values from to : the point itself and neighbours on each side.
-
Average them
Add the values and divide by .
-
Store the result as the smoothed value
Always average the original values, never values that have already been smoothed in the same pass.
Suppose you measured these intensities:
Original: [10.2, 15.1, 12.3, 19.8, 18.2, 15.9, 14.1, 18.7, 17.2, 15.3]With a window of 3, each interior point is averaged with its two immediate neighbours (counting positions from 0):
- Position 1:
- Position 2:
- Position 3:
- Position 4:
Continuing to the end gives:
Smoothed (positions 1 to 8): [12.5, 15.7, 16.8, 18.0, 16.1, 16.2, 16.7, 17.1]The jump from 12.3 to 19.8 has become a gentler climb. The first and last positions have no neighbour on one side; we come back to them in the section on edge effects.
We can say exactly how much, for random noise. Assume each point carries independent noise with standard deviation . The smoothed value is the sum of noisy values divided by . Variances of independent errors add, so
and the noise standard deviation after smoothing is
A 9-point window divides the noise by 3, a 25-point window by 5 [4],[6]. This is the same square-root law that governs averaging repeated scans, with one crucial difference: repeated scans average measurements of the same point, while the moving average mixes different points. As long as the signal is nearly constant across the window that does not matter. When it is not, the signal is distorted too.
There is a stronger result hiding here. Among all averaging filters of the same length whose weights sum to one, equal weights give the smallest output variance, : the moving average is the best possible noise remover for a given window [12]. (Minimising with gives for every point.) Its weakness is not noise, but what it does to the signal.
That distortion is the price of the noise reduction. Averaging a sharp band with the baseline around it pulls its top down and spreads it sideways. The panel below makes the trade-off measurable. The metrics compare the noise left after smoothing with the prediction, and show how much of each band's height survives. Because the panel knows the true signal and the noise it added, it can measure both without the noise getting in the way.
The price of a wider window
The noise falls as 1/√w. The narrow band pays for it as soon as the window becomes comparable to its width. The noise is measured on a long stretch of the same kind of noise and the band heights on the noise-free signal, so the numbers do not jump with each noise draw.
Notice what happens as you widen the window:
- The noise follows the law closely.
- The broad band barely changes until the window becomes large.
- The narrow band starts losing height as soon as the window approaches its width at half height (the FWHM, full width at half maximum). When the window is about as wide as the band, roughly a fifth of its height is gone.
So the right window depends less on the noise than on your narrowest feature that matters.
Choosing the window size
Keep the window clearly smaller than the FWHM of the narrowest band you care about, and large enough to reduce the noise to an acceptable level. Think in data points per band, not in absolute numbers.
Rule of thumb: for a band of Gaussian shape, a window of half the FWHM costs about 5 % of the band height; a window equal to the FWHM costs about 20 %.
Approach: start small, plot the result and the residuals, increase gradually, and stop as soon as band heights begin to fall or the residuals show band-shaped structure.
The same chemistry can be sampled with 100 points or with 10,000, so window sizes only make sense relative to band width:
- A spectrum with 100 points and bands about 10 to 20 points wide: start with or 5; very noisy data may tolerate 7.
- A high-resolution spectrum with bands about 100 points wide: start around to 15, and go up to 31 or more if the noise demands it.
- Very dense data (tens of thousands of points): windows of 101 or 201 points can be perfectly reasonable. The ratio to the band width is what counts.
With an odd window (3, 5, 7, ...) there are equally many neighbours on each side of the central point:
- : 2 left, the centre, 2 right
- : 3 left, the centre, 3 right
With an even window (4, 8, ...) the window cannot be centred on a point, so the result belongs halfway between two points. If it is stored at one of them, the whole smoothed spectrum shifts by half a point. Odd windows keep everything centred and band positions unchanged.
At the start and end of the spectrum there are not enough neighbours on one side to fill the window. Software handles this in different ways, and it is worth knowing which one yours uses:
- Leave the edges out: only the interior points with complete windows are smoothed, and the first and last points are either kept unsmoothed or marked as missing. R's
stats::filterreturnsNAthere, for example. - Zero padding: the spectrum is extended with zeros. This is what
np.convolve(..., mode='same')in NumPy andconv(..., 'same')in MATLAB do, and it pulls the edge values towards zero, which is usually wrong for spectra. - Edge padding: the first and last values are repeated to create artificial neighbours.
- Reflection: the spectrum is mirrored at the edges.
- Shrinking window: near the edges only the neighbours that exist are used. MATLAB's
movmeanandsmoothdatado this by default with a truncated, one-sided window (at the very first point of a 5-point window they average points 1 to 3). The panels on this page use a smaller symmetric window instead, so the very first and last points are left unsmoothed.
The interesting features are often in the middle of the spectrum, so edges are rarely critical. But if you integrate up to the end of the range, or the spectrum is short, check what your software does.
Everything so far assumes the whole data set is available. In offline spectroscopy you record the full spectrum first and process it afterwards, so a window can reach both backwards and forwards.
In real-time monitoring that is impossible: the future has not been measured yet. Typical examples are:
- PAT (Process Analytical Technology): real-time NIR or Raman monitoring of pharmaceutical manufacturing.
- Online quality control of production streams.
- Process monitoring of reactors, fermentations or polymerisations.
- Kinetic measurements, where a property is followed over time.
Here we smooth a time series (for example the concentration predicted from each new spectrum), and we need a causal, or one-sided, moving average that uses only the current point and the past:
The window now extends only backwards in time. In the panel below the window appears only to the left of the current point. Increase the window size, slide the window through the changes in the process, and watch the smoothed curve lag further behind the true signal: this delay is the fundamental cost of real-time smoothing. Here even window sizes are fine, because there is no centre to preserve.
Causal moving average: only the past is available
In real-time monitoring the window can only look backwards. The smoothed trend is quieter, but it reacts late to every change.
A causal moving average delays the signal. For a steady trend, the smoothed value lags the true one by
because the average of the last time points sits at the middle of that stretch of time.
Example: with and one measurement every 30 seconds, the delay is 4 measurements, so 2 minutes. When the process changes, the smoothed signal shows it about 2 minutes later.
Practical implications:
- Larger windows reduce more noise but delay more.
- In feedback control, delays can make a control loop unstable.
- In alarm systems, delayed detection can be critical.
Choosing the window for real time:
- Fast processes (reactions, mixing): small windows (for example to 5).
- Slow processes (fermentation, curing): larger windows (for example 7 to 11) are acceptable.
- Critical alarms: favour fast detection over smoothness.
- Trending and reporting: delay matters less, so stronger smoothing is fine.
def causal_moving_average(y, window_size):
"""
One-sided moving average for real-time applications.
Only uses the current and past values of a time series.
"""
smoothed = []
for i in range(len(y)):
start = max(0, i - window_size + 1) # window extends backwards only
# (the first w-1 outputs average fewer points, because there is no earlier data)
window_values = y[start:i + 1]
smoothed.append(sum(window_values) / len(window_values))
return smoothed
# PAT example: one predicted concentration per NIR spectrum, every 30 s
predicted_concentration = [2.31, 2.35, 2.29, 2.40, 2.38, 2.52, 2.47]
trend = causal_moving_average(predicted_concentration, window_size=5)In a running process you would not recompute the whole list each time: keep the last values in a buffer and update the average as each new value arrives.
Real-time versus offline smoothing
Never use a centred moving average in real time. It needs future data that do not exist yet. Use a causal (one-sided) filter for online monitoring, real-time quality control, feedback control and live time series.
Use a centred moving average for batch analysis, post-processing of stored spectra, and any offline work where all the data are available.
You can, and it is not as silly as it sounds: applying a boxcar twice is the same as applying a single triangular weighting, and after several passes the combined weights look more and more like a bell curve. That is one route to Gaussian smoothing. But each pass also blurs more, so if what you want is simply more smoothing, choose a larger window or a better method rather than stacking passes.
Yes. If you build a calibration model or compare spectra, every spectrum (calibration, validation and test) must be processed with the same window. Inconsistent smoothing creates differences that have nothing to do with chemistry [2],[3],[5].
Check the residuals:
- Smooth your data.
- Subtract: .
- Plot the residuals.
Good smoothing: the residuals look like random noise, with no pattern and centred on zero.
Over-smoothing: the residuals show structure, such as the shape of half a band. You are removing signal; use a smaller window.
Averaging a sharp band with its neighbouring baseline pulls the top down. The band becomes shorter and broader, although its area stays the same (exactly, away from the edges), because the intensity is redistributed rather than removed. This is why the moving average is not ideal for quantitative work based on the heights of sharp bands. Savitzky-Golay smoothing preserves band heights much better [1].
Here is how to implement a moving average in Python, MATLAB and R. The example spectrum has one point every 2 nm, so its main band (at 480 nm) is about 16 points wide at half height: a window of 15 is close to the FWHM of that band, so expect it to lose a clear part of its height (about 13 % here; a 7-point window loses about 3 %).
import numpy as np
import matplotlib.pyplot as plt
def moving_average(y, window_size):
"""
Apply moving average smoothing to a 1D signal.
Parameters:
-----------
y : array-like
Input signal to be smoothed (e.g., spectrum intensities)
window_size : int
Number of points in the averaging window (use odd numbers!)
Returns:
--------
y_smooth : array
Smoothed signal (same length as input)
"""
# Create the averaging kernel (array of 1/w values)
window = np.ones(window_size) / window_size
# Apply convolution. Note: mode='same' pads with zeros, so the first and
# last (w-1)/2 values are pulled towards zero (see 'Edge effects')
y_smooth = np.convolve(y, window, mode='same')
return y_smooth
# Example: noisy fluorescence emission spectrum
np.random.seed(42)
# Wavelength range typical for fluorescence (nm)
wavelength = np.linspace(400, 600, 100)
# Simulate a realistic fluorescence spectrum with vibronic structure
true_signal = (0.85 * np.exp(-((wavelength - 480)**2) / 400) +
0.35 * np.exp(-((wavelength - 510)**2) / 300) +
0.15 * np.exp(-((wavelength - 545)**2) / 250))
# Add realistic shot noise (proportional to sqrt of signal)
noise = np.random.normal(0, 0.08 * np.sqrt(true_signal))
noisy_spectrum = true_signal + noise
# Try different window sizes
smooth_w5 = moving_average(noisy_spectrum, window_size=5)
smooth_w9 = moving_average(noisy_spectrum, window_size=9)
smooth_w15 = moving_average(noisy_spectrum, window_size=15)
# Plot and compare
plt.figure(figsize=(12, 6))
plt.plot(wavelength, noisy_spectrum, 'o-', alpha=0.4,
label='Noisy data', color='gray', markersize=3)
plt.plot(wavelength, smooth_w5, linewidth=2.5,
label='Window = 5')
plt.plot(wavelength, smooth_w9, linewidth=2.5,
label='Window = 9')
plt.plot(wavelength, smooth_w15, linewidth=2.5,
label='Window = 15')
plt.xlabel('Wavelength (nm)', fontsize=12)
plt.ylabel('Fluorescence Intensity (a.u.)', fontsize=12)
plt.title('Moving Average Smoothing: Window Size Comparison', fontsize=14)
plt.legend(fontsize=10)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Check residuals
residuals = noisy_spectrum - smooth_w9
plt.figure(figsize=(12, 4))
plt.plot(wavelength, residuals, 'o-', markersize=3)
plt.axhline(y=0, color='red', linestyle='--', alpha=0.5)
plt.xlabel('Wavelength (nm)')
plt.ylabel('Residual (Original - Smoothed)')
plt.title('Residuals: Should look like random noise with no patterns')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()% Example: fluorescence emission spectrum
% (moving_average is defined at the end: MATLAB requires local functions
% to come after the script code)
rng(42);
% Typical fluorescence wavelength range (nm)
wavelength = linspace(400, 600, 100);
% Simulate realistic fluorescence with vibronic bands
true_signal = 0.85 * exp(-((wavelength - 480).^2) / 400) + ...
0.35 * exp(-((wavelength - 510).^2) / 300) + ...
0.15 * exp(-((wavelength - 545).^2) / 250);
% Add realistic shot noise
noise = 0.08 * sqrt(true_signal) .* randn(size(wavelength));
noisy_spectrum = true_signal + noise;
% Try different window sizes
smooth_w5 = moving_average(noisy_spectrum, 5);
smooth_w9 = moving_average(noisy_spectrum, 9);
smooth_w15 = moving_average(noisy_spectrum, 15);
% Visualize the comparison
figure('Position', [100 100 1000 500]);
plot(wavelength, noisy_spectrum, 'o-', 'Color', [0.7 0.7 0.7], ...
'MarkerSize', 3, 'DisplayName', 'Noisy data');
hold on;
plot(wavelength, smooth_w5, 'LineWidth', 2.5, 'DisplayName', 'Window = 5');
plot(wavelength, smooth_w9, 'LineWidth', 2.5, 'DisplayName', 'Window = 9');
plot(wavelength, smooth_w15, 'LineWidth', 2.5, 'DisplayName', 'Window = 15');
xlabel('Wavelength (nm)', 'FontSize', 12);
ylabel('Fluorescence Intensity (a.u.)', 'FontSize', 12);
title('Moving Average: Window Size Comparison', 'FontSize', 14);
legend('Location', 'northeast', 'FontSize', 10);
grid on;
% Check residuals
figure('Position', [100 100 1000 300]);
residuals = noisy_spectrum - smooth_w9;
plot(wavelength, residuals, 'o-', 'MarkerSize', 3);
yline(0, '--r', 'LineWidth', 1.5);
xlabel('Wavelength (nm)');
ylabel('Residual');
title('Residuals: Should be random noise, no structure');
grid on;
function y_smooth = moving_average(y, window_size)
% Apply moving average smoothing to a 1D signal
%
% Parameters:
% y - Input signal to smooth (column or row vector)
% window_size - Number of points in averaging window (use odd!)
%
% Returns:
% y_smooth - Smoothed signal (same size as input)
% Create the averaging kernel
window = ones(1, window_size) / window_size;
% Apply convolution. 'same' pads with zeros at the edges;
% movmean(y, window_size) shrinks the window there instead
y_smooth = conv(y, window, 'same');
endmoving_average <- function(y, window_size) {
# Apply moving average smoothing to a 1D signal
#
# Parameters:
# y - Input signal to smooth (numeric vector)
# window_size - Number of points in window (use odd numbers!)
#
# Returns:
# y_smooth - Smoothed signal
# Create averaging kernel
window <- rep(1 / window_size, window_size)
# Apply convolution with centered window
y_smooth <- stats::filter(y, window, sides = 2)
# Handle edge NAs by using original edge values
# (R's filter leaves NAs at edges by default)
y_smooth[is.na(y_smooth)] <- y[is.na(y_smooth)]
return(as.vector(y_smooth))
}
# Example: fluorescence emission spectrum
set.seed(42)
# Wavelength range for fluorescence (nm)
wavelength <- seq(400, 600, length.out = 100)
# Simulate realistic fluorescence with vibronic structure
true_signal <- 0.85 * exp(-((wavelength - 480)^2) / 400) +
0.35 * exp(-((wavelength - 510)^2) / 300) +
0.15 * exp(-((wavelength - 545)^2) / 250)
# Add realistic shot noise
noise <- 0.08 * sqrt(true_signal) * rnorm(length(wavelength))
noisy_spectrum <- true_signal + noise
# Compare different window sizes
smooth_w5 <- moving_average(noisy_spectrum, 5)
smooth_w9 <- moving_average(noisy_spectrum, 9)
smooth_w15 <- moving_average(noisy_spectrum, 15)
# Visualize
par(mfrow = c(1, 1), mar = c(4, 4, 3, 1))
plot(wavelength, noisy_spectrum, type = 'o', col = 'gray60',
pch = 16, cex = 0.6,
xlab = 'Wavelength (nm)',
ylab = 'Fluorescence Intensity (a.u.)',
main = 'Moving Average: Window Size Comparison')
lines(wavelength, smooth_w5, col = '#2563eb', lwd = 2.5)
lines(wavelength, smooth_w9, col = '#059669', lwd = 2.5)
lines(wavelength, smooth_w15, col = '#dc2626', lwd = 2.5)
legend('topright',
legend = c('Noisy', 'w=5', 'w=9', 'w=15'),
col = c('gray60', '#2563eb', '#059669', '#dc2626'),
lwd = c(1, 2.5, 2.5, 2.5),
pch = c(16, NA, NA, NA),
pt.cex = 0.6,
cex = 0.9)
grid()
# Check residuals
par(mar = c(4, 4, 3, 1))
residuals <- noisy_spectrum - smooth_w9
plot(wavelength, residuals, type = 'o', pch = 16, cex = 0.6,
xlab = 'Wavelength (nm)', ylab = 'Residual',
main = 'Residuals: No patterns = good smoothing')
abline(h = 0, col = 'red', lty = 2, lwd = 2)
grid()Works well for
Quick exploratory analysis. Fast and simple, perfect for a first look at the data. A running sum makes the cost independent of the window size.
Broad, gentle features. Wide, smooth bands such as fluorescence emission survive well.
Predictable noise reduction. For random noise it divides the noise by exactly , the most any filter of that length can do.
Teaching and communication. One parameter, and "average with your neighbours" is easy to explain to anyone.
Rough trend or baseline estimates. A very wide window follows slow drifts and ignores narrow bands.
Use something better for
Sharp Raman or IR bands. Heights drop and widths grow, and close neighbours may merge. Savitzky-Golay or Whittaker preserve them better.
Quantitative work on band heights. The height loss depends on the band width, so it biases calibrations when widths vary.
High-resolution work. Closely spaced bands and fine structure are blurred.
Signals with sharp steps or spikes. The flat weights turn a spike into a rectangle and a step into a straight ramp. In frequency terms they also let some fast noise through (see the sidelobes in Gaussian smoothing).
Whatever you use it for: plot the data before and after smoothing, check the residuals for structure, use the same odd window for every sample in a study, and check how your routine treats the edges.
- The moving average replaces each point by the mean of a window of points.
- For independent random noise it divides the noise standard deviation by .
- The same averaging lowers and broadens any band that is not much wider than the window, so the narrowest important band sets the largest acceptable window.
- In real time, only a one-sided window is possible, and it delays the signal by about samples.
The moving average teaches the core ideas of every smoother: noise reduction versus feature preservation, window size, and edge effects. For most serious spectroscopic work, consider:
- Gaussian smoothing: a weighted average in which close neighbours matter more, giving smoother results with the same simplicity.
- Savitzky-Golay smoothing: fits local polynomials instead of averaging, preserving band heights much better, and computes derivatives.
- Whittaker smoothing: an optimisation approach that balances fidelity to the data against smoothness with a single parameter.