Skip to contents

The basic workflow

samplyr separates a sampling design from the frame on which it runs. You describe the design first, validate or inspect it, and then execute it against one or more frames.

The smallest complete example is a simple random sample without replacement.

library(samplyr)
library(dplyr)

data(bfa_eas)

design <- sampling_design(title = "Burkina Faso EA sample") |>
  draw(n = 100)

sample <- execute(design, bfa_eas, seed = 24)
sample |>
  as.data.frame() |>
  dplyr::select(ea_id, region, households, .weight) |>
  head(4)
#>   ea_id      region households .weight
#> 1  5866 Centre-Nord        146   445.7
#> 2 24905        Nord         32   445.7
#> 3  5145 Centre-Nord        156   445.7
#> 4 32861         Est         22   445.7

The frame has one row per enumeration area. The result keeps its frame columns and adds the design information needed for analysis.

.weight is the overall design weight. In a one-stage sample it equals .weight_1. A multi-stage sample also carries one weight column per stage and multiplies them into .weight.

The design grammar

Six functions cover the ordinary workflow.

Function Role
sampling_design() Start a reusable design
stratify_by() Divide a stage into sampling strata
cluster_by() Name the units selected as clusters
draw() Choose the size and selection method
add_stage() Start another sampling stage
execute() Run the design against its frame or frames

Column names are stored in the design and resolved when a frame is supplied. This lets the same design be validated and reused against compatible frame vintages.

stratified_design <- sampling_design(title = "Stratified EA sample") |>
  stratify_by(region, alloc = "proportional") |>
  draw(n = 260)

validate_frame(stratified_design, bfa_eas)
#> Stage 1: `alloc = "proportional"` shares `n = 260` across 13 strata.
#> ℹ Stratum takes: "Boucle du Mouhoun" = 29, "Cascades" = 15, "Centre" = 23,
#>   "Centre-Est" = 17, "Centre-Nord" = 20, "Centre-Ouest" = 22, "Centre-Sud" = 9,
#>   "Est" = 32, and 5 more.
#> ℹ Without `alloc`, `n = 260` would be taken in each stratum.

validate_frame() returns invisibly when the required columns and values are usable. It reports problems before selection begins, and under an allocation method it states how n was shared across the strata.

Stratified sampling

Stratification samples independently within groups. Here n = 260 is a total allocated across regions in proportion to their frame sizes.

stratified_sample <- execute(stratified_design, bfa_eas, seed = 25)

stratified_sample |>
  count(region, name = "n_sampled")
#> # A tibble: 13 × 2
#>    region            n_sampled
#>    <fct>                 <int>
#>  1 Boucle du Mouhoun        29
#>  2 Cascades                 15
#>  3 Centre                   23
#>  4 Centre-Est               17
#>  5 Centre-Nord              20
#>  6 Centre-Ouest             22
#>  7 Centre-Sud                9
#>  8 Est                      32
#>  9 Hauts-Bassins            28
#> 10 Nord                     17
#> 11 Plateau-Central          10
#> 12 Sahel                    24
#> 13 Sud-Ouest                14

If stratify_by() has no allocation rule, a scalar n is interpreted per stratum. You can also pass a named vector or a svyplan::n_alloc() result to draw() when each stratum needs its own take.

Equal allocation gives comparable precision within each stratum only when the strata vary about equally, so it suits strata that are reporting domains of similar variability. Proportional allocation divides the total by the number of units each stratum holds at that stage, which makes an element sample self-weighting. At a clustered stage it counts clusters, not the households inside them. Given importance, such as each stratum’s household total, it allocates clusters in proportion to that size instead. Neyman and cost-weighted allocations need prior variability or cost information and optimize the criterion supplied for that planning variable. They are not universal optima for every outcome and domain.

Sample-size bounds can protect small reporting domains.

bounded_design <- sampling_design() |>
  stratify_by(region, alloc = "proportional") |>
  draw(n = 260, min_n = 12, max_n = 30)

bounded_sample <- execute(bounded_design, bfa_eas, seed = 26)
bounded_sample |>
  count(region, name = "n_sampled")
#> # A tibble: 13 × 2
#>    region            n_sampled
#>    <fct>                 <int>
#>  1 Boucle du Mouhoun        29
#>  2 Cascades                 14
#>  3 Centre                   22
#>  4 Centre-Est               17
#>  5 Centre-Nord              20
#>  6 Centre-Ouest             21
#>  7 Centre-Sud               12
#>  8 Est                      30
#>  9 Hauts-Bassins            28
#> 10 Nord                     17
#> 11 Plateau-Central          12
#> 12 Sahel                    24
#> 13 Sud-Ouest                14

For without-replacement sampling, the population of a stratum remains a hard upper bound. If a requested allocation cannot be reached, execute() reports the affected pools and uses the attainable take.

Cluster and PPS sampling

cluster_by() changes the sampling unit. The next example selects enumeration areas with probability related to their household counts.

pps_design <- sampling_design(title = "PPS EA sample") |>
  cluster_by(ea_id) |>
  draw(n = 80, method = "pps_brewer", mos = households)

pps_sample <- execute(pps_design, bfa_eas, seed = 27)
nrow(pps_sample)
#> [1] 80

mos is the measure of size. A large measure gives a unit a larger selection probability. If a resolved probability reaches one, that unit is selected with certainty and contributes no variance at that stage. Certainty at one stage does not make the final multi-stage weight equal to one.

pps_brewer is one fixed-size PPS method. Method choice also depends on the second-order information required for variance estimation, whether a random sample size is acceptable, and whether permanent random numbers are needed. The comparison is in ?selection_methods.

cluster_by() also names each unit’s identifier, and a frame with one row per unit can use it too. With the same seed, the design below selects the same EAs with the same weights as draw(n = 200) alone, and it records ea_id as the identity of each selected unit.

screening <- sampling_design(title = "Screening sample") |>
  cluster_by(ea_id) |>
  draw(n = 200)

screened <- execute(screening, bfa_eas, seed = 25)
nrow(screened)
#> [1] 200

Declare it whenever a second phase may later subsample this sample. The two phases are linked through an identifier both designs declare. An ID column the design does not name does not link them, so the survey export of the second phase would be refused. The examples in ?execute select a second phase, and ?as_svydesign gives its export.

A three-stage household sample

add_stage() starts another selection within each selected parent. This section selects communes within regions, enumeration areas (EAs) within those communes, and households within those EAs. Selection pauses while the household listing is prepared, then continues under the same design.

bfa_eas is a derived Burkina Faso frame. Its modeled EAs, household counts and historical administrative labels are teaching inputs, not a current official household register. The household listing and interview outcomes below are synthetic.

Give each register its own sampling unit and size

ea_register <- bfa_eas |>
  mutate(
    region,
    commune_id = paste(region, province, commune, sep = "/"),
    ea_id,
    ea_households = households,
    .keep = "none"
  )

commune_register <- ea_register |>
  summarise(commune_households = sum(ea_households),
            .by = c(region, commune_id))

head(commune_register, 3)
#> # A tibble: 3 × 3
#>   region            commune_id                     commune_households
#>   <fct>             <chr>                                       <int>
#> 1 Boucle du Mouhoun Boucle du Mouhoun/Bale/Bagassi               5089
#> 2 Boucle du Mouhoun Boucle du Mouhoun/Bale/Bana                  2586
#> 3 Boucle du Mouhoun Boucle du Mouhoun/Bale/Boromo                5857

The measure of size belongs to the unit being selected, so each level has its own register. Their order in execute() maps them to stages, and names on a list are labels, not a join specification.

Declare all three stages

household_design <- sampling_design("Three-stage household survey") |>
  add_stage("Communes") |>
    stratify_by(region) |>
    cluster_by(commune_id) |>
    draw(n = 4, method = "pps_brewer", mos = commune_households) |>
  add_stage("EAs") |>
    cluster_by(ea_id) |>
    draw(n = 3, method = "pps_brewer", mos = ea_households) |>
  add_stage("Households") |>
    draw(n = 12)

household_design
#> ── Sampling Design: Three-stage household survey ───────────────────────────────
#> 
#> ℹ 3 stages
#> 
#> ── Stage 1: Communes ───────────────────────────────────────────────────────────
#> • Strata: region
#> • Cluster: commune_id
#> • Draw: n = 4 (per stratum), method = pps_brewer, mos = commune_households
#> 
#> ── Stage 2: EAs ────────────────────────────────────────────────────────────────
#> • Cluster: ea_id
#> • Draw: n = 3 (per commune_id), method = pps_brewer, mos = ea_households
#> 
#> ── Stage 3: Households ─────────────────────────────────────────────────────────
#> • Draw: n = 12 (per ea_id), method = srswor

The takes are four communes per region, three EAs per selected commune, and twelve households per selected EA. The first stage has no allocation rule, so n = 4 is per stratum. A take that differs by EA is a table keyed by ea_id, described under n in ?draw.

Select areas, then continue from the listing

selected_eas <- execute(
  household_design,
  list(commune_register, ea_register),
  stages = 1:2,
  seed = 91
)

selected_eas |>
  as.data.frame() |>
  count(region, name = "selected_eas")
#>               region selected_eas
#> 1  Boucle du Mouhoun           12
#> 2           Cascades           12
#> 3             Centre           12
#> 4         Centre-Est           12
#> 5        Centre-Nord           12
#> 6       Centre-Ouest           12
#> 7         Centre-Sud           12
#> 8                Est           12
#> 9      Hauts-Bassins           12
#> 10              Nord           12
#> 11   Plateau-Central           12
#> 12             Sahel           12
#> 13         Sud-Ouest           12

The EA register covers all communes, and execution restricts it to the selected parents. In fieldwork, a listing team then enumerates households in the selected EAs. Here an artificial listing gives each selected EA as many households as its modeled count.

household_listing <- selected_eas |>
  as.data.frame() |>
  reframe(hh_id = seq_len(ea_households), .by = c(commune_id, ea_id))

A real listing rarely finds exactly the modeled count. That does not bias the weights, because the household stage uses the take over the listed count. Passing the listing to execute() with the partial sample continues the design.

issued <- execute(selected_eas, household_listing, seed = 92)
#> Warning: Stage 3: sample size exceeded the pool population in 3 of 156 pools.
#> ✖ Requested 1872 units, selected 1859.
#> ℹ Capped pools: "Boucle du Mouhoun/Kossi/Nouna > 33237", "Est/Tapoa/Kantchari >
#>   31552", and "Sahel/Oudalan/Tin-Akoff > 34718".
#> ℹ Inspect with `frame_summary(sample, detail = "pool")` and the capped column.

issued |>
  as.data.frame() |>
  select(commune_id, ea_id, hh_id, .weight_1, .weight_2, .weight_3, .weight) |>
  head(4)
#>                      commune_id ea_id hh_id .weight_1 .weight_2 .weight_3
#> 1 Boucle du Mouhoun/Kossi/Dokui 41769    66  11.85575  24.46053  6.333333
#> 2 Boucle du Mouhoun/Kossi/Dokui 41769    55  11.85575  24.46053  6.333333
#> 3 Boucle du Mouhoun/Kossi/Dokui 41769    10  11.85575  24.46053  6.333333
#> 4 Boucle du Mouhoun/Kossi/Dokui 41769    13  11.85575  24.46053  6.333333
#>    .weight
#> 1 1836.653
#> 2 1836.653
#> 3 1836.653
#> 4 1836.653

The warning names the EAs listed with fewer households than the take. Each is taken whole, which keeps its weight exact but makes the sample non-self-weighting. The final weight is the reciprocal of the product of the three conditional selection probabilities. Continuation keeps the earlier factors, whereas starting a new design on selected_eas would declare a new sampling phase.

Join observations and hand off to analysis

Keep every issued row and join the collected outcomes to it. The artificial consumption values stand for an outcome collected in the field.

set.seed(93)
interviews <- issued |>
  as.data.frame() |>
  mutate(ea_id, hh_id,
         consumption = round(rlnorm(n(), log(100000), 0.5)),
         .keep = "none")

observed <- issued |>
  left_join(interviews, by = c("ea_id", "hh_id"), relationship = "one-to-one")

survey::svymean(~consumption, as_svydesign(observed))
#>               mean     SE
#> consumption 113266 1529.6

This export uses Brewer’s variance approximation for the PPS stages. ?variance_estimation gives the alternatives, including RWYB replicates, and vignette("survey-analysis") continues with outcome joins, domains and nonresponse.

Fractions, replacement, and ordering

draw() also accepts frac. Fixed-size methods convert the fraction to an integer take, while Bernoulli sampling keeps a random realized size.

fraction_sample <- sampling_design() |>
  draw(frac = 0.01) |>
  execute(bfa_eas, seed = 29)

nrow(fraction_sample)
#> [1] 446

With-replacement methods may select the same population unit several times. Their samples carry .draw_1, .draw_2, and later-stage equivalents so every draw occurrence remains distinct.

Systematic and sequential methods depend on frame order. Use control when an explicit ordering is part of the design. Their variance properties depend on that ordering, so an SRS variance approximation for systematic selection need not be conservative.

Coordinate draws with permanent random numbers

A permanent random number (PRN) is a uniform value stored with each unit in the register. Methods that accept one select deterministically from it, so a later draw that reuses the PRNs selects the same units, and a draw on shifted PRNs avoids them. bernoulli, pps_poisson, pps_sps, pps_pareto and scps accept a prn column.

set.seed(30)
register <- bfa_eas |>
  mutate(prn = runif(n()))

coordinated <- sampling_design() |>
  draw(n = 100, method = "pps_sps", mos = households, prn = prn)

first <- execute(coordinated, register, seed = 1)
second <- execute(coordinated, register, seed = 2)
setequal(first$ea_id, second$ea_id)
#> [1] TRUE

The seeds differ, but the samples do not. ?draw gives the methods that accept prn and what fixes an scps selection. The book chapter Coordinate samples with permanent random numbers covers negative coordination, register updates and response burden.

Inspecting the result

A tbl_sample is a tibble with a design and an execution receipt attached. Ordinary one-to-one joins and new analysis columns preserve it. Removing or duplicating rows changes the realized sample and prevents survey export.

summary(stratified_sample)
#> ── Sample Summary: Stratified EA sample ────────────────────────────────────────
#> 
#> ℹ n = 260 of 44,570 | stages = 1/1 | seed = 25
#> 
#> ── Stage 1 ─────────────────────────────────────────────────────────────────────
#> • srswor, by region (proportional)
#> • 13 strata: N_h 1,612-5,505, n_h 9-32, f_h 0.0056-0.0060
#> 
#> ── Weights ─────────────────────────────────────────────────────────────────────
#> • Mean 171.42 [166.2, 179.11] | CV 0.01 | Kish DEFF 1 | n_eff 260

frame_summary() reports what the execution resolved in each selection pool.

frame_summary(stratified_sample, detail = "pool") |>
  select(stage, region, N, n_target, n_realized) |>
  slice_head(n = 6)
#> # A tibble: 6 × 5
#>   stage region                N n_target n_realized
#>   <int> <fct>             <dbl>    <dbl>      <dbl>
#> 1     1 Boucle du Mouhoun  5009       29         29
#> 2     1 Cascades           2508       15         15
#> 3     1 Centre             3888       23         23
#> 4     1 Centre-Est         2941       17         17
#> 5     1 Centre-Nord        3402       20         20
#> 6     1 Centre-Ouest       3723       22         22

For domain estimates, convert the complete sample first and subset the survey design afterwards. See vignette("survey-analysis") for that workflow.

Choose your next step

This article covered the design grammar, a first sample, and a three-stage household sample followed from area registers to households and observations. The next step on the main path is to analyze the sample: vignette("survey-analysis") covers outcome joins, domain estimates and variance choices.

Use the other articles when their task arises:

Task Where
Choose sizes and allocations before selection vignette("svyplan", package = "svyplan"), then Plan a sample with svyplan
Coordinate new draws with permanent random numbers ?draw, argument prn, and Coordinate samples with permanent random numbers
Schedule panels and refreshment cohorts ?panel_assignment, ?rotation_program, and Plan and run a rotating panel
Save or replay a design vignette("serialization")

For a method lookup, use ?selection_methods, and for how each method’s variance is estimated, ?variance_estimation. Consult vignette("design-semantics") for the underlying contracts and Validation on synthetic populations for simulation diagnostics. The book Survey Sampling Design with R collects these chapters with the svyplan planning guides.

A compact checklist

  1. Make sure one row represents the unit selected at the current stage.
  2. Declare strata, clusters, and selection methods in their sampling order.
  3. Validate the design against the intended frame vintage.
  4. Use a recorded seed when the selection must be reproducible.
  5. Keep the returned design columns unchanged.
  6. Archive the executed sample and its frame provenance.

Continue with vignette("survey-analysis") to analyze the selected sample.