{samplyr}

A tidy grammar for survey sampling design

Ahmadou Dicko, PhD

useR! 2026 / Warsaw / 06-09 July

How many beds sleep under a net in The Gambia?

Choropleth map of rural Gambia, a narrow country along its river, with its three regions shaded by bed-net coverage: West 48 percent, Centre 75 percent (darkest), East 51 percent. The central region clearly stands out.

National survey in rural Gambia. D’Alessandro et al. (1994), Bull. WHO 72(3): 391–394.

We run the survey. Answer: ≈ 58 %

Malaria hits children first, and the insecticide-treated bed net is one of our cheapest tools.

Before a national programme, we need to know what share of the population sleeps under a net.

Two more questions steer the roll-out:

  • How does coverage vary by region?
  • It runs through local health centres, so do villages with one differ from those without?

This is a textbook example

Excerpt from Sharon Lohr's textbook Sampling: Design and Analysis showing Example 7.1, which describes the multistage Gambia bed-net survey design.

Sharon L. Lohr, Sampling: Design and Analysis, example 7.1.

D’Alessandro’s survey became example 7.1 in Lohr, a standard sampling textbook.

  • Writing such a complex design in R tends to drift far from the plain description of the plan.
  • To my surprise, the ones that stay closest are SAS PROC SURVEYSELECT and SPSS CSPLAN, whose dedicated survey-design syntax reads almost like the plan.
  • Hence {samplyr}: express a design in R as plainly as you describe it.

Why not just base::sample()?

To express a design as plainly as we describe it, we first need to know what a design has to capture. On the ground, four constraints rule out that one-liner. The plan stays random all the way down, but it stops being simple, and each constraint names an idea.

  • A figure per region, and by presence of a health centre -> strata
  • Limit travel, cut costs -> group visits by village clusters
  • No list of compounds available -> sample in stages stages
  • A large village weighs more -> sample proportional to size PPS

Remember the colours. They follow us all the way to the code.

The design, step by step

The Gambia bed-net survey draw, stage by stage Four panels. Regions: the country split into three strata, all covered. Districts: within each region, five districts drawn, bigger circles more likely. Villages: within each chosen district, two villages with a clinic and two without, again proportional to size. Compounds: within each chosen village, six identical compounds drawn with equal chance. Regions the whole country, no draw West Centre East strata: every region is covered Districts same draw in each of the 3 regions clusters: 5 whole districts bigger → more likely Villages same draw in each of the 15 districts + clinic pick 2 no clinic pick 2 2 with a clinic + 2 without again, bigger → more likely Compounds same draw in each of the 60 villages 6 per village, purely at random same size → same chance in the pool selected size = population

In 3 stages: 3 regions × 5 districts × 4 villages × 6 compounds = 360 visits. Bigger -> more likely to be drawn: that is PPS, probability proportional to size.

What the survey report says

The Gambian survey protocol, as published:

Villages were stratified by region (East, Centre, West) and by presence of a health centre. In each region, five districts were drawn proportional to their population. In each district, four villages, again proportional to population. Finally, six compounds were drawn in each village.

strata clusters stages PPS

The same design in {sampling} and in SPSS

* SPSS Complex Samples.
CSPLAN SAMPLE /PLAN FILE='gambia.csplan'
  /DESIGN STAGELABEL='districts'
    STRATA=region CLUSTER=district
  /METHOD TYPE=PPS_WOR
  /MOS VARIABLE=district_pop
  /SIZE VALUE=5
  /DESIGN STAGELABEL='villages'
    STRATA=phc CLUSTER=village
  /METHOD TYPE=PPS_WOR
  /MOS VARIABLE=village_pop
  /SIZE VALUE=2
  /DESIGN STAGELABEL='compounds'
   CLUSTER=compound_id
  /METHOD TYPE=SIMPLE_WOR
  /SIZE VALUE=6.
library(sampling)
# Stage 1: 5 districts per region, PPS (no Brewer here: systematic)
d <- unique(gmb_frame[c("region", "district", "district_pop")])
d <- d[order(d$region), ]
s1 <- strata(d, "region", size = rep(5, 3), method = "systematic",
             pik = d$district_pop)
# Stage 2: in each sampled district, 2 PHC + 2 non-PHC villages, PPS
v <- unique(gmb_frame[c("district", "phc", "village", "village_pop")])
v <- v[v$district %in% getdata(d, s1)$district, ]
v <- v[order(v$district, v$phc), ]
s2 <- strata(v, c("district", "phc"), size = rep(2, 30),
             method = "systematic", pik = v$village_pop)
# Stage 3: 6 compounds per sampled village (take all if fewer)
cpd <- gmb_frame[gmb_frame$village %in% getdata(v, s2)$village, ]
cpd <- cpd[order(cpd$village), ]
s3 <- strata(cpd, "village", size = pmin(6, table(droplevels(cpd$village))),
             method = "srswor")
# then getdata() three times, and compose the weights by hand...

SPSS keeps the plan closer to the description. In R using {sampling}, the stages become three hand-chained draws, and strata, clusters, sizes and probabilities are yours to keep aligned.

{samplyr}: the grammar follows the colours

The idea In the report In {samplyr}
Stratum “stratified by region” stratify_by(region)
Cluster “five districts drawn together” cluster_by(district)
Stage “then, in each district…” add_stage()
Sampling “proportional to population” draw(n, method, mos)

These four verbs describe the design. The weight is not written, it results from the design composed by execute() across the stages (.weight).

Back to Gambia, the report and the code

In each region, draw five districts proportional to population (PPS).

In each district, by health centre, two villages PPS.

In each village, draw six compounds.

design <- sampling_design("Gambia bed nets") |>
  add_stage(label = "Districts") |>
    stratify_by(region) |>
    cluster_by(district) |>
    draw(n = 5, method = "pps_brewer",
         mos = district_pop) |>
  add_stage(label = "Villages") |>
    stratify_by(phc) |>
    cluster_by(village) |>
    draw(n = 2, method = "pps_brewer",
         mos = village_pop) |>
  add_stage(label = "Compounds") |>
    draw(n = 6)

The code mirrors the report, colour for colour, verb for verb.

The design is a readable object

design
── Sampling Design: Gambia bed nets ────────────────

ℹ 3 stages

── Stage 1: Districts ──────────────────────────────
• Strata: region
• Cluster: district
• Draw: n = 5 (per stratum), method = pps_brewer, mos = district_pop

── Stage 2: Villages ───────────────────────────────
• Strata: phc
• Cluster: village
• Draw: n = 2 (per stratum), method = pps_brewer, mos = village_pop

── Stage 3: Compounds ──────────────────────────────
• Draw: n = 6, method = srswor

It shows the three stages, each with its strata, clusters and draw. Defined without any data so it can be reviewed and versioned before the frame exists.

glimpse(gmb_frame)
Rows: 19,344
Columns: 8
$ region       <fct> Eastern, Easter…
$ district     <fct> Eas-D01, Eas-D0…
$ phc          <fct> PHC, PHC, PHC, …
$ village      <fct> V0001, V0001, V…
$ compound_id  <int> 1, 2, 3, 4, 5, …
$ population   <int> 18, 19, 16, 19,…
$ district_pop <int> 9062, 9062, 906…
$ village_pop  <int> 506, 506, 506, …

The frame is synthetic, built to mirror the survey. It holds 19,344 compounds with their village and size, but no nets or beds yet.

Then we execute it, the design meets the frame

sample <- execute(design, gmb_frame, seed = 27)   # the design meets the frame
sample |> select(region, village, compound_id, phc, population, .weight)
# A tbl_sample: 360 × 6 | Gambia bed nets
# Weights:      53.65 [32.53, 77.69]
  region  village compound_id phc   population .weight
* <fct>   <fct>         <int> <fct>      <int>   <dbl>
1 Eastern V0007           242 PHC           22    60.5
2 Eastern V0007           224 PHC            8    60.5
3 Eastern V0007           236 PHC           27    60.5
4 Eastern V0007           241 PHC           19    60.5
5 Eastern V0007           234 PHC           22    60.5
# ℹ 355 more rows

execute() lists the 360 compounds to visit and already composes their weights across the three stages. But beds and nets are not there yet, and need to be collected on field.

From the field to the estimate

# back from visiting the 360 sampled compounds
sample <- sample |> left_join(terrain, join_by(compound_id))  # beds and nets measured on site
sample |> select(village, compound_id, nets, beds, .weight) |> slice_head(n = 3)
# A tbl_sample: 3 × 5 | Gambia bed nets
# Weights:      60.54 [60.54, 60.54]
  village compound_id  nets  beds .weight
* <fct>         <int> <int> <int>   <dbl>
1 V0007           242     6    18    60.5
2 V0007           224     4     9    60.5
3 V0007           236     0     4    60.5
svy <- as_svydesign(sample)        # samplyr to survey, everything filled in
r <- svyratio(~nets, ~beds, svy)   # share of protected beds
cbind(estimate = coef(r), confint(r))
          estimate     2.5 %    97.5 %
nets/beds 0.584394 0.5078807 0.6609073

There are the 58 % promised at the start, with a margin of error that accounts for the plan.

And the health-centre villages? Let’s use {srvyr} to find out

library(srvyr)
as_survey_design(sample) |>
  summarise(coverage = survey_ratio(nets, beds, vartype = "ci"), .by = phc)
# A tibble: 2 × 4
  phc     coverage coverage_low coverage_upp
  <fct>      <dbl>        <dbl>        <dbl>
1 PHC        0.591        0.504        0.678
2 non-PHC    0.577        0.465        0.689

59 % versus 58 %, almost identical. No marked coverage gap in villages without a health centre. The question we asked earlier now has its answer, with a margin of error.

Under the hood, nothing simplified

Sampling methods

  • SRS, systematic, Bernoulli
  • 8 PPS methods (Brewer, Pareto, Chromy…)
  • Balanced sampling (cube)
  • Custom methods (sondage::register_method())

Allocation and coordination

  • Proportional, Neyman, optimal, power
  • min_n / max_n bounds, PRN coordination

Migrating from SAS / SPSS

sampling_design() |>
  stratify_by(region) |>
  cluster_by(school) |>
  draw(n = 50, method = "pps_brewer",
       mos = enrollment) |>
  execute(frame, seed = 1)
* SAS proc surveyselect;
proc surveyselect data=frame
    method=PPS_SAMPFORD n=50 seed=1;
  strata region; cluster school;
  size enrollment;
run;
* SPSS Complex Sample
CSPLAN SAMPLE
  /DESIGN STRATA=region CLUSTER=school
  /METHOD TYPE=PPS_SAMPFORD
  /MOS VARIABLE=enrollment
  /SIZE VALUE=50.

Maps directly onto SAS PROC SURVEYSELECT (strata / cluster / size) and SPSS CSPLAN (STRATA / CLUSTER / MOS).

Take-aways

  1. Declare a design, strata clusters stages, a sampling method, and the weights follow.
  2. With {samplyr}, the design stays readable, from protocol to code.
  3. From a one-line teaching draw to a production multi-stage plan.
  4. Export samples to {survey} / {srvyr} for analysis.

Thank you

Documentation: dickoa.gitlab.io/samplyr

Installation:

pak::pkg_install("gitlab::dickoa/samplyr")

Contact: mail@ahmadoudicko.com

Questions?