Introduction to weasel: Wave and Respondent Selection

What weasel does

Longitudinal panel studies rarely deliver the data you wish you had. Respondents skip waves, drop out, or return after long absences. Before any substantive analysis you must decide which respondents and which waves to keep, and that decision shapes everything downstream.

The weasel package (Wave-based Extraction and Selection for Longitudinal Data) turns this selection step into an explicit, inspectable, and reproducible workflow. Throughout the package, a respondent counts as observed at a wave when a row with that (id, wave) pair exists in the long-format data; a missed wave is simply an absent row, which is how real panel files are usually delivered.

It offers two complementary pipelines:

  1. A scope pipeline for interactive exploration of wave patterns.
  2. A plan pipeline for comparing predefined selection scenarios and generating methods-section text.

A simple rule of thumb: start in the scope pipeline when you do not yet know what participation looks like in your panel, and move to the plan pipeline as soon as you are choosing between selection rules you intend to defend in writing. Both pipelines use the same constraint vocabulary (min_present, max_missing, n_gap_max, max_gap_len, require_endpoints), so a pattern you isolate while exploring translates directly into a scenario.

library(weasel)

Synthetic example data

We start with the built-in generator, which layers several realistic participation mechanisms (random skips, attention decay, permanent attrition, and block dropout) and removes the corresponding rows:

long_data <- generate_weasel_dummy_data(
  n_ids = 300,
  n_times = 12,
  seed = 2026
)
#> seed: 2026
str(long_data)
#> 'data.frame':    3207 obs. of  7 variables:
#>  $ id  : int  800001 800001 800001 800001 800001 800001 800001 800001 800001 800001 ...
#>  $ time: int  1 2 3 4 5 6 7 8 9 10 ...
#>  $ var1: int  1 11 5 1 9 0 10 9 4 6 ...
#>  $ var2: num  19.5 7.1 11.7 25.3 10.5 17.9 20.8 7.6 6.8 NA ...
#>  $ var3: int  1 1 1 1 1 1 1 1 1 1 ...
#>  $ var4: int  6 3 5 1 6 6 3 NA 7 2 ...
#>  $ var5: num  13.6 5.8 14.1 8 8.1 13.6 7.6 10.7 10.7 6.1 ...

Note that the grid is incomplete by design:

nrow(long_data)
#> [1] 3207
300 * 12
#> [1] 3600

A small amount of item nonresponse (NA values inside outcome columns of observed rows) is also injected via prop_item_missing; it is distinct from wave-level missingness and does not affect selection.

The scope pipeline

The scope pipeline holds its state inside the package itself, not in your global environment, so it never interferes with your own objects.

set_weasel_scope(
  long_data,
  id = "id",
  wave = "time",
  lower = 2,
  upper = 11
)
#> ✔ WEASEL scope set
evaluate_weasel_scope()
weasel_reshape_to_wide()
#> rows not used for participation: 0 with missing id, 0 with missing time, 528 outside span 2:11.
weasel_summarize_waves()

At any point, weasel_scope_info() shows what the scope contains and how far the pipeline has progressed:

weasel_scope_info()
#> weasel scope
#>   data:        3207 rows; id = 'id', wave = 'time'
#>   grid:        consecutive
#>   span:        2:11 (10 waves)
#>   constraints: none
#>   stage:       set -> evaluated -> reshaped (297 kept) -> summarized (54 patterns)

The pattern summary groups respondents by their participation pattern. In the table, waves shows the pattern (a . marks a missing wave), n is the number of observed waves in that pattern, and ids counts the respondents who share it:

weasel_print_table(weasel_filter_wave_summary(),
                   title = "Wave patterns", n = 10)
#> ── Wave patterns ───────────────────────────────────────────────────────────────
#>  pattern                 waves  n ids
#>        1 2 3 4 5 6 7 8 9 10 11 10 130
#>        2  2 3 4 5 6 7 8 9 10 .  9  23
#>        3 2 3 4 5 6 7 8 . 10 11  9  19
#>        4 2 3 4 5 6 7 . 9 10 11  9  18
#>        5  2 3 4 5 6 7 8 9 . 11  9  13
#>        6 2 . 4 5 6 7 8 9 10 11  9  10
#>        7 . 3 4 5 6 7 8 9 10 11  9   7
#>        8 2 3 4 5 . 7 8 9 10 11  9   7
#>        9 2 3 4 . 6 7 8 9 10 11  9   6
#>       10 2 3 . 5 6 7 8 9 10 11  9   5

weasel_filter_wave_summary() narrows the table. For instance, patterns with at least 9 observed waves that are shared by at least 5 respondents:

weasel_print_table(
  weasel_filter_wave_summary(n_range = c(9, 10), ids_range = c(5, Inf)),
  title = "Dense, common patterns"
)
#> ── Dense, common patterns ──────────────────────────────────────────────────────
#>  pattern                 waves  n ids
#>        1 2 3 4 5 6 7 8 9 10 11 10 130
#>        2  2 3 4 5 6 7 8 9 10 .  9  23
#>        3 2 3 4 5 6 7 8 . 10 11  9  19
#>        4 2 3 4 5 6 7 . 9 10 11  9  18
#>        5  2 3 4 5 6 7 8 9 . 11  9  13
#>        6 2 . 4 5 6 7 8 9 10 11  9  10
#>        7 . 3 4 5 6 7 8 9 10 11  9   7
#>        8 2 3 4 5 . 7 8 9 10 11  9   7
#>        9 2 3 4 . 6 7 8 9 10 11  9   6
#>       10 2 3 . 5 6 7 8 9 10 11  9   5

The pattern column is a stable id: it survives filtering, so a value you spot in a filtered table can be passed straight to weasel_get_data_by_row() (the pattern string from waves works as a selector too). That function pulls the original long-format rows behind any pattern (or several at once), ready for analysis:

subset_row1 <- weasel_get_data_by_row(1)
weasel_print_table(head(subset_row1, 8), title = "Most common pattern, data")
#> ── Most common pattern, data ───────────────────────────────────────────────────
#>      id time var1 var2 var3 var4 var5
#>  800003    1    4  5.4    0    4  7.9
#>  800003    2    9  9.1    0    3  8.8
#>  800003    3    0 23.7    1   NA  7.9
#>  800003    4    2 22.3    1    4  7.9
#>  800003    5    4 12.4    0    3 12.8
#>  800003    6   10  5.7    0    4  6.7
#>  800003    7   15 15.7    1    4  9.0
#>  800003    8    5   NA    0    1  9.1

You can also constrain patterns structurally when setting the scope. The max_gap_len argument limits the length of interior gaps (runs of missing waves strictly between a respondent’s first and last observed wave), and n_gap_max limits how many such gaps are allowed; the same names are used by the plan pipeline’s scenario tables:

set_weasel_scope(long_data, "id", "time", max_gap_len = 1, n_gap_max = 1)
#> ✔ WEASEL scope set
evaluate_weasel_scope()
weasel_reshape_to_wide()
#> 41 respondent(s) dropped by the scope constraints; 259 kept.
weasel_summarize_waves()
weasel_print_table(weasel_filter_wave_summary(),
                   title = "Gap-constrained", n = 5)
#> ── Gap-constrained ─────────────────────────────────────────────────────────────
#>  pattern                      waves  n ids
#>        1 1 2 3 4 5 6 7 8 9 10 11 12 12 103
#>        2  1 2 3 4 5 6 7 8 9 10 11 . 11  18
#>        3 1 2 3 4 5 6 7 8 . 10 11 12 11  17
#>        4  1 2 3 4 5 6 7 8 9 10 . 12 11  17
#>        5 1 2 3 4 5 6 7 . 9 10 11 12 11  13

When you are done, clear the scope:

weasel_clear_scope()

The plan pipeline

For confirmatory work you usually want a small number of named, defensible scenarios rather than free exploration. weasel_plan() evaluates three defaults:

  • anchored_strict: observed endpoints, no missing waves.
  • anchored_balanced: observed endpoints, at most one missing wave and one interior gap.
  • lenient_info_max: no endpoint requirement, up to two missing waves and two interior gaps.
plan_obj <- weasel_plan(long_data, id = "id", wave = "time", span = "core")
#> rows not used for participation: 0 with missing id, 0 with missing time, 1539 outside span 1:6.
#> ✔ plan ready: span 1:6 (core, L = 6)
plan_obj
#> <weasel_plan>
#>   span: 1:6 (core, L = 6)
#>   respondents observed in span: 300
#>   population: 300 of 300 distinct ids observed in span (denominator: observed_in_span)
#>           scenario n_ids mean_prop_present endpoint_rate score recommended
#>    anchored_strict   210             1.000         1.000 3.775        TRUE
#>  anchored_balanced   248             0.974         1.000 3.708       FALSE
#>   lenient_info_max   292             0.943         0.866 3.503       FALSE
#>   data: 3207 row(s) attached

With span = "core" the package slides a window of core_len consecutive waves across the full wave range and picks the window with the highest average per-wave coverage; every candidate window is kept in plan_obj$span_candidates, and exact coverage ties warn. To supply your own window, pass explicit lower/upper bounds instead of span. The justification text describes these bounds as user-supplied, without inferring when or why they were chosen.

comparison <- weasel_compare_scenarios(plan_obj)
weasel_print_table(comparison, title = "Scenario comparison", digits = 3)
#> ── Scenario comparison ─────────────────────────────────────────────────────────
#>           scenario require_endpoints max_missing n_gap_max max_gap_len n_ids
#>    anchored_strict              TRUE           0         0           0   210
#>  anchored_balanced              TRUE           1         1           1   248
#>   lenient_info_max             FALSE           2         2           2   292
#>  mean_present mean_prop_present endpoint_rate mean_n_gap mean_max_gap
#>         6.000             1.000         1.000      0.000        0.000
#>         5.847             0.974         1.000      0.153        0.153
#>         5.661             0.943         0.866      0.171        0.182
#>  worst_missing lower upper L span_reason                                note
#>              0     1     6 6        core          cleanest panel, smallest N
#>              1     1     6 6        core    good balance, anchored endpoints
#>              2     1     6 6        core largest N, endpoints not guaranteed
#>  score recommended near_tie
#>  3.775        TRUE    FALSE
#>  3.708       FALSE    FALSE
#>  3.503       FALSE    FALSE
#> (list column(s) not shown: ids)

The score column is computed from observed subset quality (coverage, endpoint rate, relative sample size, worst-case missingness, and gap structure), never from a scenario’s configured tolerances; see ?weasel_compare_scenarios for the exact formula and how to override the weights. recommended flags the highest composite score under the declared weights, nothing more: near_tie warns when scenarios are practically indistinguishable, and the active weights plus the per-scenario score decomposition travel with the table as attributes (attr(comparison, "weights"), attr(comparison, "score_components")). A sentence version is available too:

cat(weasel_compare_to_sentence(comparison), "\n")
#> Recommended scenario (highest composite score under the declared weights): "anchored_strict". "anchored_strict" keeps 210 respondent(s), coverage 1.000, endpoints 1.000. "anchored_balanced" keeps 248 respondent(s), coverage 0.974, endpoints 1.000. "lenient_info_max" keeps 292 respondent(s), coverage 0.943, endpoints 0.866.

Audit a scenario before committing to it:

s <- weasel_summarize_subset(plan_obj, "anchored_balanced")
weasel_print_table(s$headline, title = "Headline", digits = 3)
#> ── Headline ────────────────────────────────────────────────────────────────────
#>           scenario lower upper L n_ids n_rows mean_present mean_missing
#>  anchored_balanced     1     6 6   248   1450        5.847        0.153
#>  endpoint_rate min_present max_present
#>              1           5           6
weasel_print_table(s$per_wave_coverage, title = "Per-wave coverage")
#> ── Per-wave coverage ───────────────────────────────────────────────────────────
#>  wave n_ids
#>     1   248
#>     2   237
#>     3   235
#>     4   242
#>     5   240
#>     6   248
cat(weasel_subset_to_sentence(s), "\n")
#> Scenario "anchored_balanced" selects 248 respondent(s) and yields 1450 row(s) in long format for waves 1 to 6 (L = 6). Mean observed waves: 5.847 (missing: 0.153). Endpoint rate: 1.000. Missingness: 210 respondent(s) have 0 missing wave(s); 38 respondent(s) have 1 missing wave(s). Lowest wave coverage is wave 3 with 235 respondent(s).

Finally, extract the analysis-ready subset:

analysis_data <- weasel_apply(plan_obj, "anchored_balanced")
dim(analysis_data)
#> [1] 1450    7

Justification text for your methods section

Documenting the selection is as important as making it. The package drafts that paragraph for you:

cat(weasel_justify_subset(plan_obj, "anchored_balanced"), "\n")
#> 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) (van den Bosch, 2026). Specifically, we focused on waves 1 to 6 (L = 6) and required observed endpoints to ensure temporal anchoring, allowed at most one missing wave within the window, and restricted the missingness structure (at most 1 interior missing block(s), each no longer than 1 wave(s)). This strategy retained 248 respondent(s), reflecting an explicit trade-off between sample size and within-window completeness. The planning population comprised 300 respondent(s) observed at least once within the analysis window, out of 300 distinct respondent(s) in the supplied data; retention figures are relative to this in-window population. In the resulting subset, mean within-window coverage was 0.974, endpoint coverage was 1.000. This scenario is characterized as: good balance, anchored endpoints. The analysis window was selected using the core span rule: the highest total count of distinct respondent-wave pairs among contiguous windows of the same length on the wave grid; ties are resolved in favour of the earliest window. All selection decisions were rule-based and reproducible, and can be regenerated from the same inputs and parameters using the weasel workflow.

See the “Advanced usage” vignette for custom scenarios, scoring weights, non-consecutive wave schedules (biennial panels), the sensitivity and selectivity diagnostics, and writing style options.