
Composite estimation: borrowing strength from the previous wave
Source:vignettes/composite-estimation.Rmd
composite-estimation.RmdIn a rotating panel most of this month’s sample was also in last month’s. The composite estimator uses that: it adds the previous wave’s labour status to the calibration as an auxiliary variable, with control totals taken from the previous wave’s own composite estimates. The result is a level series that is smoother and, much more to the point, a change that is measured far more precisely – because the estimator is partly built from the same units twice.
step_cre() implements the regression composite estimator
of Fuller and Rao (2001) and Gambino, Kennedy and Singh (2001), in the
form the Canadian LFS and Uruguay’s ECH use. This article covers what it
does, what it costs, and the one thing that makes its variance different
from every other step in the package.
The recursion
The estimator is defined by a recursion, so a chain needs a starting
point. The seed wave has no t-1: with
previous = NULL the composite block is empty and
step_cre() reduces to an ordinary linear calibration to the
demographic totals X. From the second wave on,
previous is the prepped recipe of the wave before, and the
calibration targets X and the composite
totals Zhat built from it.
wv <- lapply(1:3, function(k) {
d <- subset(panel_ine, ola == k & disp == "R"); d$sexo <- factor(d$sexo); d
})
Xtot <- function(d) colSums(d$w_base * model.matrix(~ sexo, data = d))
seed <- weighting_spec(wv[[1]], base_weights = w_base) |>
step_cre(previous = NULL, status = condicion, formula = ~ sexo,
totals = Xtot(wv[[1]]), status_ref = "inact") |>
prep()
wave2 <- weighting_spec(wv[[2]], base_weights = w_base) |>
step_cre(previous = seed, status = condicion, composite = list(NULL, "sexo"),
id_unit = c("id_hogar", "nper"), formula = ~ sexo,
totals = Xtot(wv[[2]]), alpha = 2/3, status_ref = "inact")composite is the list of domain crossings that define
the composite blocks: NULL is the country total,
"sexo" is status crossed by sex, and a character vector is
status crossed by the interaction. The ECH’s own list is
list(NULL, "sex", "department"). status_ref
names the status level left implicit in each block – one level must
always be dropped, or the block is collinear with the intercept of
X; naming it only decides which one is implied.
alpha mixes the MR1 and MR2 variants: 0 targets the level
alone, 1 the change alone, and 2/3 is the Chen and Liu (2002) compromise
both agencies use.
id_unit is how a unit finds its own t-1
status. Units with no previous value – the birth rotation group, new
household members, people newly of working age – are handled by
birth and on_missing_prev; by default they
carry their current status backward.
What it buys
The comparison worth making is against the same recipe with an ordinary calibration, over the same chain, with the same replicates:
EST <- list(unemp_rate = function(w, d) weighted.mean(d$desocupado, w, na.rm = TRUE))
run <- function(composite) {
prev <- NULL; out <- list()
for (k in 1:3) {
sp <- weighting_spec(wv[[k]], base_weights = w_base)
sp <- if (composite)
step_cre(sp, previous = if (k == 1) NULL else out[[k - 1]]$prepped,
status = condicion, composite = list(NULL, "sexo"),
id_unit = c("id_hogar", "nper"), formula = ~ sexo,
totals = Xtot(wv[[k]]), alpha = 2/3, status_ref = "inact")
else
step_calibrate(sp, method = "linear", formula = ~ sexo, totals = Xtot(wv[[k]]))
s <- wave_step(sp, previous = prev, estimands = EST, replicates = 150,
strata = "estrato", psu = "psu", period = paste0("T", k),
seed = 100 + k, progress = FALSE)
out[[k]] <- list(step = s, carry = wave_carry(s), prepped = prep(sp))
prev <- rev(lapply(out, function(z) z$carry))
}
out
}
cre <- run(TRUE)
plain <- run(FALSE)
tab <- function(o, label) {
ch <- o[[3]]$step$change
data.frame(recipe = label, from = ch$from, se = round(ch$se, 5),
rho = round(ch$rho, 3), deff_change = round(ch$deff_change, 3))
}
rbind(tab(cre, "composite (CRE)"), tab(plain, "plain calibration"))
#> recipe from se rho deff_change
#> 1 composite (CRE) T2 0.00699 0.854 0.150
#> 2 composite (CRE) T1 0.00918 0.708 0.292
#> 3 plain calibration T2 0.01087 0.619 0.381
#> 4 plain calibration T1 0.01257 0.460 0.540Against the previous month the standard error of the change falls by about a third, and the correlation between the two periods’ estimates rises from roughly 0.6 to roughly 0.84. That is the whole point of the method: the composite auxiliaries make consecutive estimates share more of their sampling error, and shared error cancels in a difference.
It is not free. The level of period t now depends on the
previous wave’s estimates, so an error propagates forward, and the
series is smoother than the data alone would justify – which is why
alpha exists and why agencies do not set it to 1.
The variance trap
Here is the part that makes step_cre() different from
every other step. Its control totals Zhat are not
known population figures. They are estimated from the
previous wave, with the previous wave’s weights, and they carry that
wave’s sampling error.
A single-sample bootstrap does not know this. It resamples the
current wave and re-runs the recipe, but Zhat was computed
once, from the previous wave’s frozen point weights, so every replicate
calibrates to the same control totals – as if they were census
figures. The resulting variance is too small, and it is too small in
precisely the direction that matters, because the understated part is
the one the change depends on.
The package refuses to let this happen quietly:
b <- tryCatch(bootstrap_weights(wave2, replicates = 5, strata = "estrato",
psu = "psu", progress = FALSE),
warning = function(w) conditionMessage(w))
b
#> [1] "bootstrap_weights(): the recipe contains step_cre() (composite regression). This single-sample bootstrap treats the composite control totals Zhat as FIXED, so it understates the variance. Use wave_bootstrap() for the coordinated, honest variance of the recursive composite estimator."The honest version re-estimates Zhat inside every
replicate: replicate b of period t rebuilds its
control totals from replicate b of period t-1. That is
what wave_step() and wave_bootstrap() do, and
it is the reason a chain that contains step_cre() needs the
previous period’s replicate weights, not just its
replicate estimates.
The fat carry
Hence the two shapes of the carry. carry = "auto", the
default, inspects the recipe: with no step_cre() it stores
only the replicate values of the declared estimands, and with one it
stores the replicate weight matrix as well.
c(thin_KB = as.numeric(object.size(plain[[1]]$carry)) / 1024,
fat_KB = as.numeric(object.size(cre[[1]]$carry)) / 1024)
#> thin_KB fat_KB
#> 85.88281 2614.17188Thirty times larger, and the difference is entirely the
R * n matrix of replicate weights. For a monthly production
chain that is a few megabytes per period, which is the price of an
honest variance for the composite estimator.
The injection is audited, so you can confirm it happened rather than assuming it:
c(injected = cre[[2]]$step$n_cre_injected, skipped = cre[[2]]$step$n_cre_skipped)
#> injected skipped
#> 1 0n_cre_injected counts the CRE steps that received the
previous period’s replicate weights; n_cre_skipped counts
those that did not – because no carry contained them, or because the
carry was thin. A chain running with skipped > 0 is
producing the anticonservative variance the warning above is about, and
the number is there so that a production run can assert on it.
Equal representation across rotation groups
Both methodologies impose one more constraint: each rotation group
must weight to the same working-age total, N / G (ECH
sec. 8.4.1; LFS sec. 6.3.1). Passing rotation_group adds
it, as G - 1 extra columns in the demographic block – the
last group’s total follows from the others and N, so
constraining it too would be redundant.
Where to look next
?step_cre for the full argument list and the references,
vignette("coordinated-replication") for the mechanism that
pairs the replicates across waves,
vignette("rotating-panels") for the production workflow,
and ?wave_carry for what travels between runs.