20.3 – Baseline correction

Introduction.

Signal processing is the analysis and manipulation of signals to extract meaningful information and improve data quality. signal processing is a crucial pre-processing step before analysis, as it involves cleaning and preparing raw data to improve its quality and highlight important features for accurate analysis. Common pre-processing tasks include filtering noise, filling in missing data, and feature extraction, all of which are done before the main analytical steps like feature extraction and classification can be performed.

A baseline refers to an initial measurement before an intervention. A starting point. Provides an objective comparison to judge whether or not an intervention has led to change.

Baseline drift is a gradual, slow shift in the signal’s zero-point over time, while intensity drift is a more general term for a change in a peak’s amplitude, often due to baseline changes. Noise is rapid, random fluctuation that obscures the signal itself. The key differences lie in their speed and effect: drift is a slow, low-frequency, long-term issue that shifts the entire baseline, whereas noise is a fast, high-frequency, short-term issue that adds random variation to the signal

For measures conducted over time, a baseline correction may be applied during initial data processing to correct for signal distortion, background noise, or baseline drift — the gradual change over time in what is expected to be measurement of an unchanging signal.

Note 1. Signal distortion is an unwanted change to the original signal’s waveform, while background noise is an unrelated, external signal that is added to the original signal.

Many applications, numerous examples.

Quantitative PCR, qPCR: baseline correction is the process of identifying and subtracting the background fluorescence noise from the early cycles of a real-time PCR run to accurately measure the signal from specific DNA amplification (see Ruijter et al 2009).

Chromatography: baseline correction is a technique to remove background noise and drift from a chromatogram to make peaks more visible and accurate for analysis (see Niezen et al 2022).

Standard and basal metabolic rate by indirect calorimetry: baseline drift is an error where the instrument’s signal gradually changes over time, independent of the subject’s actual oxygen consumption (\dot{V}O_{2}), while baseline correction is a data processing technique used to computationally adjust the recorded data to counteract this drift and improve accuracy (see Hayes et al 1992; Lighton 2017).

Colorimetric spectrometry: baseline drift is an unwanted phenomenon where the signal’s baseline gradually shifts over time or wavelength due to instrumental or environmental factors, while baseline correction is a data processing technique used to computationally remove this drift and other background noise from the measured spectrum.

Statistical considerations.

When processing a biological signal such as an electromyogram (EMG), it’s important to remember that even the baseline—the part of the recording where we assume “nothing is happening” — is only an estimate of the true baseline noise level. To account for this, choose two kinds of time windows: a baseline window (before the event of interest) and a signal window (during the activity you want to measure).

Note 2. In signal processing, a window function’s purpose is to isolate a portion of a signal for analysis and reduce spectral leakage by smoothing the signal’s boundaries.

The goal is to compare these windows in a way that fairly adjusts for natural fluctuations in the baseline. One common approach is a regression-weighted correction, which simply means using a statistical line that represents how the baseline trends upward or downward over time, then adjusting the signal based on that line rather than assuming the baseline is perfectly flat. Another approach is to use a spline, which is a smooth, flexible curve that adapts to gradual changes in the baseline. Splines can correct for slow drifts in the recording without over-correcting the actual signal. Together, these methods help ensure that any “activity” you detect is more likely to be real muscle activation and not just shifts in the baseline.

Reporting detrended data ensures that your analysis reflects true physiological changes rather than technical artifacts like sensor drift or baseline wander. It establishes an unvarnished baseline that guarantees reproducibility across trials, allowing you to reliably compare a subject’s metabolic capacity over multiple tests regardless of shifting day-to-day instrument errors.

Example, EMG recording.

To illustrate, consider a myogram signal trace (EMG) recorded over several minutes. The trace will show fluctuations in electrical activity over time, which reflects muscle rest, contraction, and relaxation. The trace will exhibit a baseline at rest, spikes or bursts of activity during contractions, and varying levels of intensity depending on the muscle’s effort. An increase in the frequency and amplitude of these spikes indicates a stronger, more forceful contraction, while a period of no activity will show as a flat line. In a real-world scenario with an active muscle or a prolonged recording, the trace is typically corrupted by both noise (high-frequency, random variations) and baseline drift (slow, low-frequency shifts from the zero point). Among several statistics, analyst may calculate from the signal (1) the Root Mean Square (RMS) Amplitude, a measure of the signal’s magnitude and represents the overall intensity of muscle activity, (2) Mean Power Frequency (MPF), or the the frequency domain analysis of myogram signals. The MPF is calculated to assess muscle fatigue (fatigue often causes a shift to lower frequencies), and others (eg, Potvin and Bent 1997, Smilios et al 2010). Principle to the analysis, moving average or LOESS approaches may be used to smooth the data, reduce noise, and identify underlying trends or patterns in muscle activity over the multiple-minute duration.

To demonstrate pre-processing steps to correct for baseline drift, we need a data set. Here, we simulate a couple of myogram-like traces.

R code to simulate myogram data with baseline drift and random walk noise. A random walk tends to wander and works well for simulating biological drift.

# Example
# Define simulation parameters
duration_sec <- 5 # Total duration in seconds
sampling_rate_hz <- 1000 # Sampling rate (1000 Hz = 1 ms interval)
peak_center_time <- 2.5 # Time of the muscle contraction peak
amplitude <- 5 # Peak amplitude of the myogram
drift_magnitude <- 0.01 # Factor to control the magnitude of baseline drift
noise_level_sd <- 0.1 # Standard deviation of the white noise

Generate the data

my_data <- simulate_myogram_with_drift(
duration = duration_sec,
hz = sampling_rate_hz,
peak_time = peak_center_time,
peak_amplitude = amplitude,
drift_factor = drift_magnitude,
noise_sd = noise_level_sd
)
head(my_data)

Plot the simulated data

plot(my_dataTime, my_dataSignal, type = 'l', col = 'blue',
main = "Simulated Myogram Data with Baseline Drift",
xlab = "Time", ylab = "Signal Value")
lines(my_dataTime, my_dataDrift, col = 'red', lty = 2) # Overlay the drift line
legend("topleft", legend = c("Simulated Myogram", "Baseline Drift"),
col = c("blue", "red"), lty = c(1, 2), cex = 0.8)
# ylim = range(myogram_data, baseline_drift)

which gives us graph like Figure 1.

Simulated Myogram data with Baseline Drift.

Figure 1. Simulated myogram data with baseline drift.

Alternatively, use ggplot (Fig 2).

library(ggplot2)
ggplot(my_data, aes(x = Time, y = Signal)) +
geom_line(color = "blue") +
geom_line(aes(y = Drift), color = "red", linetype = "dashed", alpha = 0.6) +
geom_line(aes(y = TrueSignal), color = "green", linetype = "dotted", alpha = 0.8) +
labs(title = "Simulated Myogram Data with Baseline Drift",
x = "Time (seconds)",
y = "Signal Amplitude") +
theme_minimal() +
scale_color_manual(values = c("blue", "red", "green"),
labels = c("Total Signal", "Baseline Drift", "True Myogram")) +
theme(legend.position = "bottom")

# You can access the data for further analysis using the 'my_data' data frame
head(my_data)
}

End R code.

version 2
# R code to simulate myogram data with baseline drift

# 1. Define simulation parameters
set.seed(123) # for reproducibility
n_samples <- 500 # number of data points (time steps)
time <- 1:n_samples
baseline_start <- 5
drift_rate <- 0.01 # slope of the linear drift
signal_amplitude <- 2
noise_sd <- 0.5 # standard deviation of the noise

# 2. Simulate the baseline drift
# A simple linear drift is used here. You could also use a random walk (RW).
baseline_drift <- baseline_start + drift_rate * time

# 3. Simulate the biological signal (myogram activity)
# Using a sinusoidal function as an example of a rhythmic signal
biological_signal <- signal_amplitude * sin(time * 0.1)

# 4. Simulate random noise (white noise)
noise <- rnorm(n_samples, mean = 0, sd = noise_sd)

# 5. Combine all components to get the final simulated myogram data
myogram_data <- baseline_drift + biological_signal + noise

# 6. Create a data frame for plotting and analysis
sim_data <- data.frame(Time = time, Signal = myogram_data)

# 7. Visualize the data using base R graphics or ggplot2
plot(sim_dataTime, sim_dataSignal, type = 'l', col = 'blue',
main = "Simulated Myogram Data with Baseline Drift",
xlab = "Time", ylab = "Signal Value", ylim = range(myogram_data, baseline_drift))
lines(sim_dataTime, baseline_drift, col = 'red', lty = 2) # Overlay the drift line legend("topleft", legend = c("Simulated Myogram", "Baseline Drift"), col = c("blue", "red"), lty = c(1, 2), cex = 0.8)</pre> End R code.  Our results are presented in Figure 2.  <a href="https://biostatistics.letgen.org/mikes-biostatistics-book/additional-topics/baseline-correction/sim_myogram03/" rel="attachment wp-att-7323"><img class="alignnone wp-image-7323 size-medium" src="https://biostatistics.letgen.org/wp-content/uploads/2025/11/sim_myogram03-300x243.png" alt="Simulated myogram data with random walk noise and baseline drift." width="300" height="243" /></a>  <em>Figure 2. Simulated myogram data with random walk noise and baseline drift.</em> <pre># You can also use the ggplot2 package for more sophisticated plotting # install.packages("ggplot2") # library(ggplot2) # ggplot(sim_data, aes(x = Time, y = Signal)) + # geom_line(color = "blue") + # geom_line(aes(y = baseline_drift), color = "red", linetype = "dashed") + # labs(title = "Simulated Myogram Data with Baseline Drift", # y = "Signal Value") + # theme_minimal()</pre> End R code. <h3>Correcting for baseline drift before extracting Zenith or Nadir.</h3> We introduced extracting intervals like nadir and zenith from a physiological trace in <a href="https://biostatistics.letgen.org/mikes-biostatistics-book/additional-topics/peak-detection/">20.2 - Peak detection</a>. Here, we provide R code to calculate the mean zenith and nadir for the raw -- without baseline correction -- and detrended values.  First, without correction for base line drift. <pre># 1. Setup and Simulate Data with Linear Drift set.seed(42) n_points <- 1000 time_sec <- seq(0, 100, length.out = n_points) # 100 seconds total duration  # Base signal from your prompt + a subtle upward linear drift signal <- sin(seq(0, 10, length.out = n_points)) + rnorm(n_points, sd = 0.1) drift <- 0.01 * time_sec  data_stream <- signal + drift  # 2. Identify Global Zenith and Nadir Indicies zenith_idx <- which.max(data_stream) nadir_idx <- which.min(data_stream)  # Get the actual timestamps zenith_time <- time_sec[zenith_idx] nadir_time <- time_sec[nadir_idx]  # 3. Define the 20-second Windows (e.g., +/- 10 seconds around the index) window_half_width <- 10  zenith_window_mask <- (time_sec >= (zenith_time - window_half_width)) &  (time_sec <= (zenith_time + window_half_width))  nadir_window_mask <- (time_sec >= (nadir_time - window_half_width)) &  (time_sec <= (nadir_time + window_half_width))  # 4. Plot using Base Graphics Only plot(time_sec, data_stream, type = "l", col = "gray40", lwd = 1.5, xlab = "Time (seconds)", ylab = "Physiological Signal Intensity", main = "Physiological Extremes & 20-Second Activity Windows")  # Shading the 20-second Zenith Window (Max Stress/Capacity) rect(xleft = zenith_time - window_half_width, ybottom = min(data_stream), xright = zenith_time + window_half_width, ytop = max(data_stream), col = rgb(1, 0, 0, 0.15), border = NA)  # Shading the 20-second Nadir Window (Max Recovery/Baseline) rect(xleft = nadir_time - window_half_width, ybottom = min(data_stream), xright = nadir_time + window_half_width, ytop = max(data_stream), col = rgb(0, 0, 1, 0.15), border = NA)  # Overlay the highlighted window paths to stand out lines(time_sec[zenith_window_mask], data_stream[zenith_window_mask], col = "darkred", lwd = 2.5) lines(time_sec[nadir_window_mask], data_stream[nadir_window_mask], col = "darkblue", lwd = 2.5)  # Points for the exact local maxima/minima peaks points(zenith_time, data_stream[zenith_idx], col = "red", pch = 19, cex = 1.8) points(nadir_time, data_stream[nadir_idx], col = "blue", pch = 19, cex = 1.8)  # Add Legend legend("topleft", legend = c("Signal", "Zenith Window (20s)", "Nadir Window (20s)", "Exact Peak"), col = c("gray40", "darkred", "darkblue", "black"),  lty = c(1, 1, 1, NA), pch = c(NA, NA, NA, 19), lwd = c(1.5, 2.5, 2.5, NA), bty = "n")</pre> End R code.  Here's the plot (Fig 3).  <a href="https://biostatistics.letgen.org/mikes-biostatistics-book/additional-topics/baseline-correction/plot_sim_trace_drift_corrected/" rel="attachment wp-att-7744"><img class="alignnone wp-image-7744 size-full" src="https://biostatistics.letgen.org/wp-content/uploads/2026/10/plot_sim_trace_drift_corrected.png" alt="Zenith (red) and nadir (blue) highlighted for our simulated physiolical trace." width="544" height="428" /></a>  <em>Figure 3. Zenith (red) and nadir (blue) highlighted for our simulated physiolical trace.</em>  To report the mean zenith, eg,\dot{V}O_{2}max, and mean nadir, eg,BMR$, values within those 20-second windows, you need to calculate the average of the raw signal values captured inside the logical masks.

Below is the updated base R code that detrends the data first, identifies the true physiological extremes, computes the window means, and prints them directly to the console alongside the plot.

R code.
# 1. Setup and Simulate Data with Linear Drift
set.seed(42)
n_points <- 1000
time_sec <- seq(0, 100, length.out = n_points)

signal <- sin(seq(0, 10, length.out = n_points)) + rnorm(n_points, sd = 0.1)
drift <- 0.01 * time_sec 
data_stream <- signal + drift

# 2. Detrend the data using linear regression residuals
drift_model <- lm(data_stream ~ time_sec)
detrended_data <- residuals(drift_model)

# 3. Identify indices based on the CLEAN, detrended data
zenith_idx <- which.max(detrended_data)
nadir_idx <- which.min(detrended_data)

zenith_time <- time_sec[zenith_idx]
nadir_time <- time_sec[nadir_idx]

# 4. Define 20-second Windows (+/- 10s around index)
window_half_width <- 10

zenith_window_mask <- (time_sec >= (zenith_time - window_half_width)) & 
(time_sec <= (zenith_time + window_half_width))

nadir_window_mask <- (time_sec >= (nadir_time - window_half_width)) & 
(time_sec <= (nadir_time + window_half_width))

# Compute the mean of the actual signal values within these 20-second blocks
mean_zenith_val <- mean(data_stream[zenith_window_mask])
mean_nadir_val <- mean(data_stream[nadir_window_mask])

# Print metrics to the R console
cat("--- Physiological Window Metrics ---\n")
cat(sprintf("Zenith Window Center Time: %.2f seconds\n", zenith_time))
cat(sprintf("Mean Zenith Window Value: %.4f\n\n", mean_zenith_val))
cat(sprintf("Nadir Window Center Time: %.2f seconds\n", nadir_time))
cat(sprintf("Mean Nadir Window Value: %.4f\n", mean_nadir_val))
cat("------------------------------------\n")

# Expand right margin (5th value) to make clean room for the legend outside the axis box
old_pars <- par(no.readonly = TRUE)
par(mar = c(5, 4, 4, 8) + 0.1, xpd = TRUE)

plot(time_sec, detrended_data, type = "l", col = "purple", lwd = 1.5,
xlab = "Time (seconds)", ylab = "Detrended Residuals",
main = "Clean Detrended Signal & Target Window Averages")

# Shading the windows on the detrended baseline
rect(zenith_time - window_half_width, min(detrended_data),
zenith_time + window_half_width, max(detrended_data),
col = rgb(1, 0, 0, 0.12), border = NA)

rect(nadir_time - window_half_width, min(detrended_data),
nadir_time + window_half_width, max(detrended_data),
col = rgb(0, 0, 1, 0.12), border = NA)

# Highlight window segments
lines(time_sec[zenith_window_mask], detrended_data[zenith_window_mask], col = "darkred", lwd = 2.5)
lines(time_sec[nadir_window_mask], detrended_data[nadir_window_mask], col = "darkblue", lwd = 2.5)

# Plot exact peaks identified after detrending
points(zenith_time, detrended_data[zenith_idx], col = "red", pch = 19, cex = 1.8)
points(nadir_time, detrended_data[nadir_idx], col = "blue", pch = 19, cex = 1.8)

# Overlay horizontal dashed lines tracking detrended window means
abline(h = mean_zenith_detrended, col = "darkred", lty = 2, lwd = 1.5)
abline(h = mean_nadir_detrended, col = "darkblue", lty = 2, lwd = 1.5)

# Position a clean, compact legend cleanly outside the plot area on the right margin
legend("topleft", inset = c(1.02, 0.2), bty = "n",
lty = c(1, 1, 1, 1, 2, 2), pch = c(NA, NA, NA, 19, NA, NA),
lwd = c(1.5, 2.5, 2.5, NA, 1.5, 1.5), 
col = c("purple", "darkred", "darkblue", "black", "darkred", "darkblue"),
legend = c("Detrended Signal", 
"Zenith Window", 
"Nadir Window", 
"Peak Points",
sprintf("Mean Zen: %.2f", mean_zenith_detrended),
sprintf("Mean Nad: %.2f", mean_nadir_detrended)))

# Restore original plotting space settings
par(old_pars)
End R code.

R output.

The mean Zenith Window Value (Raw) was 1.6102; after accounting for the baseline drift, the mean detrended Zenith was 0.6605 -- without correction we would report a value over 200% higher than the degtrended value. Similarly, the mean Nadir Window Value (Raw) was -0.3690; after detrending, mean degtrended Nadir was -1.0276, a value 36% lower than the uncorrected value.

Detrended zenith (red) and nadir (blue) highlighted for our simulated physiolical trace.

Figure 4. Detrended zenith (red) and nadir (blue) highlighted for our simulated physiolical trace.

Questions

  1. Write up three learning outcomes for this page. Hint: Point your favorite generative AI to this page and ask for help

References

Hayes, J. P., Speakman, J. R., & Racey, P. A. (1992). Sampling bias in respirometry. Physiological Zoology, 65, 604–619.

Lighton, J. R. B. (2017). Limitations and requirements for measuring metabolic rates: A mini review. European Journal of Clinical Nutrition, 71(3), 301–305.

Liland, K. H. (2015). 4S Peak Filling–baseline estimation by iterative mean suppression. MethodsX, 2, 135-140.

Liland, K. H., & Mevik, T. A. B. H. (2011). Optimal baseline correction for multivariate calibration using open-source software. Life Science Instruments, (3), 7.

Niezen, L. E., Schoenmakers, P. J., & Pirok, B. W. J. (2022). Critical comparison of background correction algorithms used in chromatography. Analytica Chimica Acta, 1201, 339605.

Ruijter, J. M., Ramakers, C., Hoogaars, W. M. H., Karlen, Y., Bakker, O., van den Hoff, M. J. B., & Moorman, A. F. M. (2009). Amplification efficiency: Linking baseline and bias in the analysis of quantitative PCR data. Nucleic Acids Research, 37(6), e45.

Potvin, J. R., & Bent, L. R. (1997). A validation of techniques using surface EMG signals from dynamic contractions to quantify muscle fatigue during repetitive tasks. Journal of Electromyography and Kinesiology, 7(2), 131–139.

Smilios, I., Hakkinen, K., & Tokmakidis, S. P. (2010). Power Output and Electromyographic Activity During and After a Moderate Load Muscular Endurance Session. The Journal of Strength and Conditioning Research, 24(8), 2122–2131.


Chapter 20 contents

/MD