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:
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.
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:
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 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 5weasel_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 5The 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.1You 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 13When you are done, clear the scope:
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) attachedWith 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:
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.