03 · Sample selection with weasel

The analytic sample keeps every respondent observed on at least three of the seven analysis waves, and estimates under full-information maximum likelihood. This vignette expresses that rule as an explicit, named, auditable scenario with weasel, shows the audit outputs the article draws on, and demonstrates the machinery live on synthetic data.

Semantics first

weasel counts a respondent as observed at a wave when a row with that (id, wave) pair exists in the long data. The study’s notion of observed is substantive: at least one focal measure (BMI, fruit and vegetable consumption, or sport hours) reported at that wave. The bridge is a presence filter applied before planning:

presence <- liss %>%
  dplyr::filter(
    t %in% analysis_waves,
    !is.na(bmi) | !is.na(fv) | !is.na(pa)
  )

Rows with a wave record but no focal measurement therefore do not count as observed, exactly as in the pipeline’s own rule.

The rule as a scenario

Three defaults ship with weasel_plan() (anchored_strict, anchored_balanced, lenient_info_max); the study’s rule is a fourth, custom one. Within the full seven-wave span, at least three observed waves means at most four missing; endpoints are not required, and interior-gap limits are set non-binding.

ws_plan <- weasel::weasel_plan(
  presence[c("id", "t", "female", "age")],
  id = "id", wave = "t", span = "full",
  scenarios = data.frame(
    scenario = "min3_of7",
    require_endpoints = FALSE,
    max_missing = 4L,
    n_gap_max = 5L,
    max_gap_max = 5L
  )
)
analysis_ids <- unique(weasel::weasel_apply(ws_plan, "min3_of7")$id)

The pipeline (chunk cb112 of analysis/reciprocal-health-dynamics.R) runs this plan alongside its own one-liner and asserts setequal() between the two id sets, so the transcript proves at every render that the scenario and the rule are the same selection: 5,676 respondents.

The audits the article uses

Three diagnostics turn the rule from a stipulation into an argued choice.

Tolerance sensitivity. weasel_sensitivity() sweeps the minimum-wave tolerance and reports the retained sample at each level, which is the sample-size trade-off behind choosing three as the threshold:

weasel::weasel_sensitivity(
  ws_plan,
  require_endpoints = FALSE, max_missing = 0:6,
  n_gap_max = 5L, max_gap_max = 5L
)

Attrition selectivity. weasel_selectivity() compares retained and excluded respondents on covariates via standardised mean differences, the empirical check on whether completeness-based selection skews the sample that the missing-at-random discussion in the article leans on:

weasel::weasel_selectivity(ws_plan, "min3_of7")

Prose justification. weasel_justify_subset() generates methods-section text stating the window, the constraints, the retained count, and the coverage achieved, regenerable from the same inputs:

cat(weasel::weasel_justify_subset(ws_plan, "min3_of7"))

scripts/02_select_sample.R runs all of the above against the real panel and writes the id list and the justification to output/.

Live demonstration on synthetic data

Everything above executes here on generated data with the study’s exact scenario, so the outputs can be inspected without any LISS access.

set.seed(42)
d <- do.call(rbind, lapply(1:400, function(i) {
  w <- sort(sample(1:7, sample(1:7, 1)))
  data.frame(id = i, t = w, female = stats::rbinom(1, 1, 0.5),
             age = stats::rnorm(1, 50, 15))
}))

sc <- data.frame(
  scenario = "min3_of7", require_endpoints = FALSE,
  max_missing = 4L, n_gap_max = 5L, max_gap_max = 5L
)
p <- weasel::weasel_plan(d, id = "id", wave = "t", span = "full", scenarios = sc)
✔ plan ready: span 1:7 (full, L = 7)
p
<weasel_plan>
  span: 1:7 (full, L = 7)
  respondents observed in span: 400
 scenario n_ids mean_prop_present endpoint_rate score recommended
 min3_of7   297             0.696         0.549 2.396        TRUE
  data: 1604 row(s) attached
weasel::weasel_print_table(
  weasel::weasel_sensitivity(p, require_endpoints = FALSE, max_missing = 0:6,
                             n_gap_max = 5L, max_gap_max = 5L),
  title = "sample size by minimum-wave tolerance"
)
── sample size by minimum-wave tolerance ───────────────────────────────────────
 require_endpoints max_missing n_gap_max max_gap_max n_ids prop_ids
             FALSE           0         5           5    56    0.140
             FALSE           1         5           5   103    0.258
             FALSE           2         5           5   165    0.412
             FALSE           3         5           5   233    0.583
             FALSE           4         5           5   297    0.743
             FALSE           5         5           5   350    0.875
             FALSE           6         5           5   400    1.000
 mean_prop_present
             1.000
             0.935
             0.852
             0.770
             0.696
             0.634
             0.573
weasel::weasel_print_table(
  weasel::weasel_selectivity(p, "min3_of7"),
  title = "retained vs excluded"
)
── retained vs excluded ────────────────────────────────────────────────────────
 variable n_retained n_excluded mean_retained mean_excluded   diff    smd
   female        297        103         0.488         0.544 -0.055 -0.111
      age        297        103        50.252        48.855  1.398  0.090
cat(weasel::weasel_justify_subset(p, "min3_of7"))
To construct a longitudinal analysis sample, we selected respondents whose wave participation satisfied explicit structural criteria using the WEASEL framework (Wave-based Extraction and Selection for Longitudinal Data) (R package weasel). Specifically, we focused on waves 1 to 7 (L = 7) and did not require observed endpoints, allowing unanchored participation, allowed up to 4 missing waves within the window, and restricted the missingness structure (at most 5 interior missing block(s), each no longer than 5 wave(s)). This strategy retained 297 respondent(s), reflecting an explicit trade-off between sample size and within-window completeness. In the resulting subset, mean within-window coverage was 0.696, endpoint coverage was 0.549. The analysis window was selected using the package's span rule (full), which prioritizes a coherent window with comparatively strong participation. All selection decisions were rule-based and reproducible, and can be regenerated from the same inputs and parameters using the weasel workflow.
sub <- weasel::weasel_apply(p, "min3_of7")
identical(
  sort(unique(sub$id)),
  sort(as.integer(names(which(table(d$id) >= 3))))
)
[1] TRUE

The final identical() is the same equivalence the pipeline asserts on the real data: the scenario is the rule.

Interactive exploration

Before committing to any rule, the scope pipeline maps the participation landscape:

weasel::set_weasel_scope(presence, "id", "t", gap = 1)
weasel::evaluate_weasel_scope()
weasel::weasel_summarize_waves()
weasel::weasel_print_table(weasel::weasel_filter_wave_summary(), n = 10)
weasel::weasel_scope_info()
weasel::weasel_clear_scope()

Panels on non-consecutive schedules (biennial waves, waves recorded as years) are handled with grid = "observed" in both pipelines.