Quick summary
Summarize this blog with AI
Introduction
This guide uses dplyr::lag() to shift observed values by a number of positions. It is not the same interface as stats::lag(), which shifts a time-series time base. Neither operation automatically fills missing dates or imputes missing measurements.
Tested with R 4.5.1 and dplyr 1.1.4. Run the following code blocks in order. All inputs are included.
Shift a vector and keep its length
Use the namespace explicitly so an attached package cannot change which lag function you call.
x <- c(10, 20, NA_real_, 40)
previous <- dplyr::lag(x)
print(previous) # NA 10 20 NA
stopifnot(identical(previous, c(NA_real_, 10, 20, NA_real_)))
stopifnot(identical(dplyr::lag(x, n = 2), c(NA_real_, NA_real_, 10, 20)))
stopifnot(identical(dplyr::lag(x, default = 0), c(0, 10, 20, NA_real_)))
The default pads newly exposed positions only; it does not replace the missing measurement already in x. Zero padding is a deliberate policy and is inappropriate when “no prior observation” means unknown.
Sort within groups before computing change
An event ID breaks same-day ties. The fixture includes a date gap to expose the difference between the previous row and the previous calendar day.
sales <- data.frame(store = c("A", "B", "A", "B", "A"),
day = as.Date(c("2026-01-03", "2026-01-01", "2026-01-01", "2026-01-02", "2026-01-04")),
event = c(2L, 4L, 1L, 5L, 3L), amount = c(15, 0, 10, 5, 18))
ordered <- sales |>
dplyr::arrange(store, day, event) |>
dplyr::group_by(store) |>
dplyr::mutate(previous_amount = dplyr::lag(amount),
days_since_previous = as.integer(day - dplyr::lag(day)),
change = amount - previous_amount) |>
dplyr::ungroup()
print(as.data.frame(ordered))
stopifnot(identical(ordered$previous_amount, c(NA_real_, 10, 15, NA_real_, 0)))
stopifnot(identical(ordered$days_since_previous, c(NA_integer_, 2L, 1L, NA_integer_, 1L)))
For A on January 3, the previous observed amount is January 1’s 10, two days earlier. A one-day change is unavailable unless that is the intended previous-day record. A twelve-row lag is a year-over-year lag only when rows actually represent consecutive months without omissions.
Keep invalid growth denominators missing
Absolute change can be defined when the previous amount is zero; percentage change cannot divide by zero. Assign only where the denominator is present and nonzero.
ordered$percent_change <- NA_real_
valid <- !is.na(ordered$previous_amount) & ordered$previous_amount != 0
ordered$percent_change[valid] <- 100 * ordered$change[valid] / ordered$previous_amount[valid]
print(ordered$percent_change) # NA 50 20 NA NA
stopifnot(isTRUE(all.equal(ordered$percent_change, c(NA_real_, 50, 20, NA_real_, NA_real_))))
The B row after a zero baseline has a five-unit increase but an undefined percentage increase. Filling its previous value from a future row would alter the question and could leak future information into a forecast.
Use order_by without assuming it sorts the output
order_by chooses the order for shifting but returns values aligned to the original rows.
days <- c(3, 1, 2)
amounts <- c(30, 10, 20)
print(dplyr::lag(amounts, order_by = days)) # 20 NA 10
stopifnot(identical(dplyr::lag(amounts, order_by = days), c(20, NA_real_, 10)))
stopifnot(length(dplyr::lag(numeric(0))) == 0)
stopifnot(identical(dplyr::lag(5), NA_real_))
stopifnot(all(is.na(dplyr::lag(c(1, 2), n = 5))))
Sort explicitly when several columns define chronology. For prediction, compute features only from records available at the prediction time and respect group boundaries. Lagging is alignment; filling is a separate analytical decision.