This RPubs page is designed for students and researchers in basic sciences who want a visual and computational introduction to filtering, especially particle filtering.
The scientific setting is common:
Typical examples:
| Domain | Hidden state | Measurement | Why filtering is needed |
|---|---|---|---|
| Astronomy | true position, brightness, orbit, source state | noisy telescope image or flux | sequential tracking under measurement error |
| Geoscience | river flow regime, seismic stress, subsurface contamination | sensor readings, catalogues, sparse surveys | hidden process evolves through time |
| Soft matter / materials | latent microstructural state | microscopy, spectroscopy, scattering | observation is indirect and noisy |
| Biophysics | cell state, voltage, molecular conformation | fluorescence, patch-clamp, imaging | signal is stochastic and partially observed |
| Epidemiology | true infection process | reported cases | reporting is noisy and delayed |
The main lesson is:
Filtering is not merely smoothing a curve. It is sequential probabilistic inference about a hidden state.
Let
\[ X_t = \text{hidden state at time }t, \qquad Z_t = \text{observed measurement at time }t. \]
A state-space model has two parts:
\[ \textbf{State transition:}\qquad X_t \sim p(x_t\mid x_{t-1}), \]
\[ \textbf{Observation model:}\qquad Z_t \sim p(z_t\mid x_t). \]
The filtering target is the posterior distribution
\[ p(x_t\mid z_{1:t}), \]
where
\[ z_{1:t}=(z_1,z_2,\ldots,z_t). \]
In words: after seeing all measurements up to time \(t\), what do we believe about the current hidden state?
The Kalman filter is elegant and exact when the system is linear and Gaussian:
\[ X_t = aX_{t-1}+\eta_t, \qquad Z_t = bX_t+\epsilon_t, \]
where \(\eta_t\) and \(\epsilon_t\) are Gaussian noises.
The particle filter is more general. It represents the filtering posterior by many simulated particles:
\[ \widehat{p}_N(dx_t\mid z_{1:t})= \sum_{i=1}^N w_t^{(i)}\delta_{x_t^{(i)}}(dx_t), \]
where:
The effective sample size is
\[ \mathrm{ESS}_t = \frac{1}{\sum_{i=1}^N (w_t^{(i)})^2}. \]
If ESS is small, only a few particles are carrying most of the probability mass; resampling is needed.
alg <- tibble::tribble(
~Step, ~Question, ~Mathematical_object, ~Scientific_meaning,
"Predict", "Where could the system move next?", "simulate x_t^(i) from p(x_t | x_{t-1}^(i))", "propagate physics/dynamics",
"Update", "Which particles agree with the measurement?", "w_t^(i) proportional to p(z_t | x_t^(i))", "score particles by sensor likelihood",
"Resample", "How do we remove bad guesses?", "sample particles with probability w_t^(i)", "keep plausible hypotheses alive"
)
knitr::kable(alg)
| Step | Question | Mathematical_object | Scientific_meaning |
|---|---|---|---|
| Predict | Where could the system move next? | simulate x_t^(i) from p(x_t | x_{t-1}^(i)) | propagate physics/dynamics |
| Update | Which particles agree with the measurement? | w_t^(i) proportional to p(z_t | x_t^(i)) | score particles by sensor likelihood |
| Resample | How do we remove bad guesses? | sample particles with probability w_t^(i) | keep plausible hypotheses alive |
Some hosted platforms sanitize or escape raw JavaScript. If raw
<script>, <canvas>,
<button>, or custom HTML is escaped, then the page
shows HTML tags as text rather than running the animation.
For RPubs, the safer route is to use an R
htmlwidget, such as plotly, because it is knitted
and published through the standard R Markdown widget pipeline.
So below we create an animation completely from R.
This animation shows a target moving in two dimensions. The true state is hidden. A noisy sensor gives measurements. Particles represent possible locations of the target.
simulate_pf2d <- function(T = 35, N = 350, sensor_sd = 25, process_sd = 9) {
target <- c(x = 80, y = 70, vx = 5.5, vy = 3.2)
width <- 760
height <- 430
particles <- tibble::tibble(
id = seq_len(N),
x = runif(N, 0, width),
y = runif(N, 0, height),
w = rep(1 / N, N)
)
all_frames <- list()
true_path <- tibble::tibble(x = numeric(), y = numeric())
est_path <- tibble::tibble(x = numeric(), y = numeric())
frame_no <- 1
systematic_resample <- function(w) {
# Robust systematic resampling.
# This protects against tiny numerical errors in cumulative weights
# and against accidental NA/Inf weights after likelihood underflow.
N <- length(w)
w[!is.finite(w)] <- 0
sw <- sum(w)
if (!is.finite(sw) || sw <= 0) {
w <- rep(1 / N, N)
} else {
w <- w / sw
}
cw <- cumsum(w)
cw <- cummax(cw) # enforce non-decreasing cumulative probabilities
cw[N] <- 1 # ensure the last interval closes exactly at 1
u0 <- runif(1, 0, 1 / N)
u <- u0 + (0:(N - 1)) / N
# Avoid findInterval() failures on hosted knitting environments.
idx <- integer(N)
j <- 1L
for (i in seq_len(N)) {
while (u[i] > cw[j] && j < N) {
j <- j + 1L
}
idx[i] <- j
}
idx
}
for (tt in seq_len(T)) {
# true motion
target["x"] <- target["x"] + target["vx"]
target["y"] <- target["y"] + target["vy"]
if (target["x"] < 0 || target["x"] > width) target["vx"] <- -target["vx"]
if (target["y"] < 0 || target["y"] > height) target["vy"] <- -target["vy"]
z <- c(
x = target["x"] + rnorm(1, 0, sensor_sd),
y = target["y"] + rnorm(1, 0, sensor_sd)
)
# PREDICT
particles <- particles |>
dplyr::mutate(
x = pmin(pmax(x + target["vx"] + rnorm(N, 0, process_sd), 0), width),
y = pmin(pmax(y + target["vy"] + rnorm(N, 0, process_sd), 0), height),
phase = "1 Predict"
)
pred_est <- particles |>
dplyr::summarise(x = mean(x), y = mean(y))
true_path <- dplyr::bind_rows(true_path, tibble::tibble(x = target["x"], y = target["y"]))
est_path <- dplyr::bind_rows(est_path, pred_est)
all_frames[[frame_no]] <- dplyr::bind_rows(
particles |> dplyr::transmute(frame = frame_no, phase, kind = "particles", x, y, size = 3, alpha = 0.35, text = paste0("particle ", id)),
tibble::tibble(frame = frame_no, phase = "1 Predict", kind = "true target", x = target["x"], y = target["y"], size = 12, alpha = 1, text = "true target"),
tibble::tibble(frame = frame_no, phase = "1 Predict", kind = "sensor", x = z["x"], y = z["y"], size = 10, alpha = 1, text = "noisy sensor"),
tibble::tibble(frame = frame_no, phase = "1 Predict", kind = "estimate", x = pred_est$x, y = pred_est$y, size = 11, alpha = 1, text = "particle mean estimate")
)
frame_no <- frame_no + 1
# UPDATE
dist2 <- (particles$x - z["x"])^2 + (particles$y - z["y"])^2
lw <- -dist2 / (2 * sensor_sd^2)
lw <- lw - max(lw)
raw_w <- exp(lw)
sw <- sum(raw_w)
if (!is.finite(sw) || sw <= 0) {
particles$w <- rep(1 / N, N)
} else {
particles$w <- raw_w / sw
}
ess <- 1 / sum(particles$w^2)
upd_est <- particles |>
dplyr::summarise(x = sum(w * x), y = sum(w * y))
all_frames[[frame_no]] <- dplyr::bind_rows(
particles |>
dplyr::mutate(weight_size = 2 + 12 * w / max(w)) |>
dplyr::transmute(frame = frame_no, phase = paste0("2 Update: ESS = ", round(ess, 1)), kind = "particles", x, y, size = weight_size, alpha = 0.55, text = paste0("weight = ", signif(w, 3))),
tibble::tibble(frame = frame_no, phase = paste0("2 Update: ESS = ", round(ess, 1)), kind = "true target", x = target["x"], y = target["y"], size = 12, alpha = 1, text = "true target"),
tibble::tibble(frame = frame_no, phase = paste0("2 Update: ESS = ", round(ess, 1)), kind = "sensor", x = z["x"], y = z["y"], size = 10, alpha = 1, text = "noisy sensor"),
tibble::tibble(frame = frame_no, phase = paste0("2 Update: ESS = ", round(ess, 1)), kind = "estimate", x = upd_est$x, y = upd_est$y, size = 11, alpha = 1, text = "weighted estimate")
)
frame_no <- frame_no + 1
# RESAMPLE
idx <- systematic_resample(particles$w)
particles <- particles[idx, ] |>
dplyr::mutate(id = seq_len(N), w = 1 / N)
res_est <- particles |>
dplyr::summarise(x = mean(x), y = mean(y))
all_frames[[frame_no]] <- dplyr::bind_rows(
particles |> dplyr::transmute(frame = frame_no, phase = "3 Resample", kind = "particles", x, y, size = 3, alpha = 0.35, text = paste0("resampled particle ", id)),
tibble::tibble(frame = frame_no, phase = "3 Resample", kind = "true target", x = target["x"], y = target["y"], size = 12, alpha = 1, text = "true target"),
tibble::tibble(frame = frame_no, phase = "3 Resample", kind = "sensor", x = z["x"], y = z["y"], size = 10, alpha = 1, text = "noisy sensor"),
tibble::tibble(frame = frame_no, phase = "3 Resample", kind = "estimate", x = res_est$x, y = res_est$y, size = 11, alpha = 1, text = "resampled estimate")
)
frame_no <- frame_no + 1
}
dplyr::bind_rows(all_frames) |>
dplyr::mutate(
kind = factor(kind, levels = c("particles", "sensor", "estimate", "true target")),
frame_label = paste0("Frame ", frame, ": ", phase)
)
}
pf_anim <- simulate_pf2d(T = 32, N = 320, sensor_sd = 28, process_sd = 9)
plotly::plot_ly(
data = pf_anim,
x = ~x,
y = ~y,
frame = ~frame_label,
type = "scatter",
mode = "markers",
color = ~kind,
colors = c("particles" = "#777777",
"sensor" = "blue",
"estimate" = "green",
"true target" = "red"),
size = ~size,
sizes = c(3, 16),
text = ~text,
hoverinfo = "text",
marker = list(line = list(width = 0.5, color = "white"))
) |>
plotly::layout(
title = "Particle Filter: Predict → Update → Resample",
xaxis = list(title = "x position", range = c(0, 760)),
yaxis = list(title = "y position", range = c(0, 430), scaleanchor = "x"),
legend = list(orientation = "h", x = 0.05, y = -0.12),
margin = list(l = 50, r = 20, b = 80, t = 60)
) |>
plotly::animation_opts(frame = 650, transition = 250, redraw = FALSE) |>
plotly::animation_slider(currentvalue = list(prefix = "Step: ")) |>
plotly::animation_button(x = 1, xanchor = "right", y = 0, yanchor = "bottom")
A famous nonlinear filtering example uses
\[ X_t = 0.5X_{t-1}+\frac{25X_{t-1}}{1+X_{t-1}^2}+8\cos(1.2t)+\eta_t, \]
\[ Z_t = \frac{X_t^2}{20}+\epsilon_t. \]
This is difficult because the observation depends on \(X_t^2\). Therefore a positive and a negative state may produce similar observations. A Gaussian linear filter is not naturally suited for this.
simulate_nonlinear_ssm <- function(T = 80, process_sd = 1.2, obs_sd = 1.8) {
x <- numeric(T)
z <- numeric(T)
x[1] <- rnorm(1, 0, 2)
z[1] <- x[1]^2 / 20 + rnorm(1, 0, obs_sd)
for (t in 2:T) {
x[t] <- 0.5 * x[t - 1] + 25 * x[t - 1] / (1 + x[t - 1]^2) + 8 * cos(1.2 * t) + rnorm(1, 0, process_sd)
z[t] <- x[t]^2 / 20 + rnorm(1, 0, obs_sd)
}
tibble::tibble(t = seq_len(T), x = x, z = z)
}
particle_filter_1d <- function(z, N = 2000, process_sd = 1.2, obs_sd = 1.8) {
T <- length(z)
particles <- rnorm(N, 0, 5)
weights <- rep(1 / N, N)
out <- tibble::tibble(
t = integer(), mean = double(), q05 = double(), q50 = double(), q95 = double(), ess = double()
)
systematic_resample <- function(w) {
# Robust systematic resampling.
# This protects against tiny numerical errors in cumulative weights
# and against accidental NA/Inf weights after likelihood underflow.
N <- length(w)
w[!is.finite(w)] <- 0
sw <- sum(w)
if (!is.finite(sw) || sw <= 0) {
w <- rep(1 / N, N)
} else {
w <- w / sw
}
cw <- cumsum(w)
cw <- cummax(cw) # enforce non-decreasing cumulative probabilities
cw[N] <- 1 # ensure the last interval closes exactly at 1
u0 <- runif(1, 0, 1 / N)
u <- u0 + (0:(N - 1)) / N
# Avoid findInterval() failures on hosted knitting environments.
idx <- integer(N)
j <- 1L
for (i in seq_len(N)) {
while (u[i] > cw[j] && j < N) {
j <- j + 1L
}
idx[i] <- j
}
idx
}
for (tt in seq_len(T)) {
if (tt > 1) {
particles <- 0.5 * particles + 25 * particles / (1 + particles^2) + 8 * cos(1.2 * tt) + rnorm(N, 0, process_sd)
}
logw <- dnorm(z[tt], mean = particles^2 / 20, sd = obs_sd, log = TRUE)
logw <- logw - max(logw)
weights <- exp(logw)
sw <- sum(weights)
if (!is.finite(sw) || sw <= 0) {
weights <- rep(1 / N, N)
} else {
weights <- weights / sw
}
ess <- 1 / sum(weights^2)
# weighted quantiles by resampling an auxiliary representation
idx_tmp <- sample(seq_len(N), size = N, replace = TRUE, prob = weights)
px <- particles[idx_tmp]
out <- dplyr::bind_rows(out, tibble::tibble(
t = tt,
mean = sum(weights * particles),
q05 = as.numeric(quantile(px, 0.05)),
q50 = as.numeric(quantile(px, 0.50)),
q95 = as.numeric(quantile(px, 0.95)),
ess = ess
))
if (ess < N / 2) {
idx <- systematic_resample(weights)
particles <- particles[idx]
weights <- rep(1 / N, N)
}
}
out
}
ssm <- simulate_nonlinear_ssm(T = 90)
pf1 <- particle_filter_1d(ssm$z, N = 2500)
ssm_pf <- dplyr::left_join(ssm, pf1, by = "t")
ggplot(ssm_pf, aes(x = t)) +
geom_ribbon(aes(ymin = q05, ymax = q95), fill = "skyblue", alpha = 0.28) +
geom_line(aes(y = x, color = "Hidden true state"), linewidth = 0.9) +
geom_line(aes(y = mean, color = "Particle-filter mean"), linewidth = 0.9) +
labs(
title = "Nonlinear/non-Gaussian filtering by particle filter",
subtitle = "The shaded band is a 90% particle-filter uncertainty band",
x = "time", y = "state"
) +
scale_color_manual(values = c("Hidden true state" = "black", "Particle-filter mean" = "red")) +
theme_minimal(base_size = 13)
ggplot(pf1, aes(x = t, y = ess)) +
geom_line(color = "purple", linewidth = 0.9) +
geom_hline(yintercept = 2500 / 2, linetype = 2, color = "gray40") +
labs(
title = "Effective sample size over time",
subtitle = "Low ESS means particle degeneracy; resampling restores diversity",
x = "time", y = "ESS"
) +
theme_minimal(base_size = 13)
Now we deliberately use the wrong observation noise. The true observation noise is larger, but the filter acts as if the sensor is very precise. It overtrusts noisy observations.
pf_good <- particle_filter_1d(ssm$z, N = 2000, process_sd = 1.2, obs_sd = 1.8) |>
dplyr::mutate(model = "Correct observation noise")
pf_bad <- particle_filter_1d(ssm$z, N = 2000, process_sd = 1.2, obs_sd = 0.45) |>
dplyr::mutate(model = "Misspecified: overtrust sensor")
compare_pf <- dplyr::bind_rows(pf_good, pf_bad) |>
dplyr::left_join(ssm |> dplyr::select(t, x), by = "t") |>
dplyr::mutate(abs_error = abs(mean - x))
compare_pf |>
dplyr::group_by(model) |>
dplyr::summarise(
RMSE = sqrt(mean((mean - x)^2)),
mean_abs_error = mean(abs_error),
mean_ESS = mean(ess),
.groups = "drop"
) |>
knitr::kable(digits = 3)
| model | RMSE | mean_abs_error | mean_ESS |
|---|---|---|---|
| Correct observation noise | 4.422 | 2.229 | 1281.202 |
| Misspecified: overtrust sensor | 5.636 | 3.042 | 472.224 |
ggplot(compare_pf, aes(x = t)) +
geom_line(aes(y = x), color = "black", linewidth = 0.9) +
geom_line(aes(y = mean, color = model), linewidth = 0.8) +
labs(
title = "Filtering failure under misspecification",
subtitle = "A filter can be mathematically correct for the wrong model and scientifically misleading",
x = "time", y = "state"
) +
theme_minimal(base_size = 13)
This is where statistics and machine learning meet.
The built-in co2 series records monthly atmospheric CO2
concentration at Mauna Loa.
A naive local-level filter is misspecified because the data contain:
A simple filter without seasonality will confuse seasonal oscillation with state noise.
co2_df <- tibble::tibble(
t = seq_along(co2),
year = as.numeric(time(co2)),
month = factor(cycle(co2)),
co2 = as.numeric(co2)
)
p_raw <- ggplot(co2_df, aes(x = year, y = co2)) +
geom_line(color = "darkgreen", linewidth = 0.7) +
labs(title = "Monthly atmospheric CO2 at Mauna Loa", x = "year", y = "CO2") +
theme_minimal(base_size = 13)
p_raw
r_co2 <- var(diff(co2_df$co2), na.rm = TRUE) / 2
q_co2 <- r_co2 * 0.05
co2_naive <- kalman_local_level(co2_df$co2, q = q_co2, r = r_co2) |>
dplyr::bind_cols(co2_df) |>
dplyr::mutate(model = "Naive local level")
ggplot(co2_naive, aes(x = year)) +
geom_line(aes(y = co2), color = "gray40", linewidth = 0.6) +
geom_line(aes(y = filtered_mean), color = "red", linewidth = 1) +
labs(
title = "Misspecified filter: seasonal signal is mistaken for random movement",
x = "year", y = "CO2"
) +
theme_minimal(base_size = 13)
seasonal_model <- lm(co2 ~ month, data = co2_df)
co2_df$season_adj <- residuals(seasonal_model) + mean(co2_df$co2)
r_adj <- var(diff(co2_df$season_adj), na.rm = TRUE) / 2
q_adj <- r_adj * 0.08
co2_adj_fit <- kalman_local_level(co2_df$season_adj, q = q_adj, r = r_adj) |>
dplyr::bind_cols(co2_df)
co2_summary <- tibble::tibble(
Model = c("Naive local-level filter", "Seasonally adjusted local-level filter"),
Residual_SD = c(
sd(co2_naive$co2 - co2_naive$filtered_mean),
sd(co2_adj_fit$season_adj - co2_adj_fit$filtered_mean)
)
)
knitr::kable(co2_summary, digits = 3)
| Model | Residual_SD |
|---|---|
| Naive local-level filter | 1.711 |
| Seasonally adjusted local-level filter | 0.387 |
ggplot(co2_adj_fit, aes(x = year)) +
geom_line(aes(y = season_adj), color = "gray55", linewidth = 0.55) +
geom_ribbon(aes(ymin = lwr, ymax = upr), fill = "orange", alpha = 0.22) +
geom_line(aes(y = filtered_mean), color = "darkred", linewidth = 1.0) +
labs(
title = "After seasonal adjustment: filtering the underlying CO2 trend",
subtitle = "A better statistical model separates trend from seasonal structure",
x = "year", y = "seasonally adjusted CO2"
) +
theme_minimal(base_size = 13)
A flexible ML curve might fit this dataset visually, but the scientific question is sharper:
Statistical modelling forces these distinctions.
Solar activity is a dynamical process. If we randomly split a time series, the future leaks into the training distribution. For forecasting, chronological validation is more honest.
sun_df <- tibble::tibble(
year = as.numeric(time(sunspot.year)),
sunspot = as.numeric(sunspot.year)
) |>
dplyr::mutate(
lag1 = dplyr::lag(sunspot, 1),
lag2 = dplyr::lag(sunspot, 2),
lag3 = dplyr::lag(sunspot, 3)
) |>
tidyr::drop_na()
train <- sun_df |> dplyr::filter(year <= 1950)
test <- sun_df |> dplyr::filter(year > 1950)
lm_ar <- lm(sunspot ~ lag1 + lag2 + lag3, data = train)
tree_ar <- rpart::rpart(sunspot ~ lag1 + lag2 + lag3, data = train, control = rpart::rpart.control(cp = 0.01))
test_pred <- test |>
dplyr::mutate(
pred_linear_AR = as.numeric(predict(lm_ar, newdata = test)),
pred_tree_ML = as.numeric(predict(tree_ar, newdata = test))
)
rmse <- function(a, b) sqrt(mean((a - b)^2))
perf <- tibble::tibble(
Model = c("Statistical AR regression", "ML regression tree"),
Test_RMSE = c(
rmse(test_pred$sunspot, test_pred$pred_linear_AR),
rmse(test_pred$sunspot, test_pred$pred_tree_ML)
)
)
knitr::kable(perf, digits = 3)
| Model | Test_RMSE |
|---|---|
| Statistical AR regression | 24.462 |
| ML regression tree | 34.648 |
ggplot(test_pred, aes(x = year)) +
geom_line(aes(y = sunspot, color = "Observed"), linewidth = 0.9) +
geom_line(aes(y = pred_linear_AR, color = "Statistical AR"), linewidth = 0.85) +
geom_line(aes(y = pred_tree_ML, color = "ML tree"), linewidth = 0.85) +
labs(
title = "Sunspot forecasting: statistics and ML under chronological validation",
subtitle = "The task is not random interpolation; it is prediction of future dynamics",
x = "year", y = "sunspot number"
) +
theme_minimal(base_size = 13)
comp <- tibble::tribble(
~Question, ~Statistical_inference, ~Machine_learning, ~Best_scientific_practice,
"What is the hidden state?", "Posterior/filtering distribution", "Latent representation or predictor", "Use a probabilistic state-space model with flexible components",
"How uncertain is the answer?", "Confidence/credible/prediction intervals", "Often not automatic", "Calibrate ML using statistical uncertainty tools",
"Can the model extrapolate?", "Model assumptions visible", "Can fail silently under shift", "Use physical constraints and honest validation",
"Is the mechanism interpretable?", "Parameters tied to scientific quantities", "High predictive accuracy possible", "Combine interpretable structure with flexible residual learning",
"What if the model is wrong?", "Model checking and sensitivity", "Stress testing and external validation", "Do both"
)
knitr::kable(comp)
| Question | Statistical_inference | Machine_learning | Best_scientific_practice |
|---|---|---|---|
| What is the hidden state? | Posterior/filtering distribution | Latent representation or predictor | Use a probabilistic state-space model with flexible components |
| How uncertain is the answer? | Confidence/credible/prediction intervals | Often not automatic | Calibrate ML using statistical uncertainty tools |
| Can the model extrapolate? | Model assumptions visible | Can fail silently under shift | Use physical constraints and honest validation |
| Is the mechanism interpretable? | Parameters tied to scientific quantities | High predictive accuracy possible | Combine interpretable structure with flexible residual learning |
| What if the model is wrong? | Model checking and sensitivity | Stress testing and external validation | Do both |
A modern scientific data-science pipeline may look like this:
In formula form, one may replace a rigid transition model by a learned component:
\[ X_t = f_{\mathrm{ML}}(X_{t-1},u_t)+\eta_t, \]
but still keep an observation model
\[ Z_t\sim p(z_t\mid X_t), \]
and still infer
\[ p(x_t\mid z_{1:t}). \]
That is the useful synthesis:
ML learns complicated structure; statistics keeps uncertainty honest.
Nile,
co2, and sunspot.year.