9  Plan a sample with svyplan

9.1 From a precision target to a draw

This chapter covers the step before selection, when the sample size or allocation still has to be chosen. Chapter 10 follows the selection itself through a three-stage fieldwork example.

Planning answers how many units to issue and how to distribute them. Selection then applies that plan to a frame. svyplan owns the first calculation and samplyr owns the second.

We use the synthetic ken_enterprises frame. Suppose the main estimate is the proportion of exporting enterprises, with a margin of error of three percentage points at 95 percent confidence.

library(samplyr)
library(svyplan)
library(dplyr)

data(ken_enterprises)

p_export <- mean(ken_enterprises$exporter)
target <- n_prop(p = p_export, moe = 0.03)
target
#> Sample size for proportion (wald)
#> n = 504 (p = 0.14, moe = 0.030, deff = 1)
#> expected cases = 68.9

The result is a svyplan_n object. draw() reads its planned sample size directly.

sample <- sampling_design(title = "Enterprise survey") |>
  draw(n = target) |>
  execute(ken_enterprises, seed = 1)

nrow(sample)
#> [1] 504

Use a plausible planning value from earlier rounds or a pilot when the true population value is unknown. For a proportion, p = 0.5 is the conservative choice under the ordinary Wald variance because it maximizes p * (1 - p).

9.2 Means, response, and design effects

For a mean, supply the population variance or a prior estimate of it.

revenue_target <- n_mean(
  var = var(ken_enterprises$revenue_millions),
  moe = 30
)
revenue_target
#> Sample size for mean
#> n = 611 (var = 143091.44, moe = 30.000, deff = 1)

Expected nonresponse increases the number issued. Here the target remains the same, but only 85 percent of issued units are expected to respond.

response_target <- n_prop(
  p = p_export,
  moe = 0.03,
  resp_rate = 0.85
)

c(
  full_response = as.integer(target),
  issued_at_85_percent = as.integer(response_target)
)
#>        full_response issued_at_85_percent 
#>                  504                  593

deff expresses the variance relative to simple random sampling under the planning model. It can reflect clustering, unequal weights, or another design feature, but the value should match the estimand being planned.

n_prop(p = p_export, moe = 0.03, deff = 1.5, resp_rate = 0.85)
#> Sample size for proportion (wald)
#> n = 890 gross (net: 756) (p = 0.14, moe = 0.030, deff = 1.50, resp_rate = 0.85)
#> expected cases = 103.3

A cross-sectional design effect does not automatically describe a change or a pooled longitudinal estimate. Correlation across occasions can help one estimand and hurt another.

9.3 Read the calculation in reverse

The prec_*() functions evaluate a sample size rather than solving for it.

prec_prop(p = p_export, n = 600)
#> Sampling precision for proportion (wald)
#> n = 600
#> se = 0.0140, moe = 0.0275, cv = 0.1026, rmoe = 0.2011
#> expected cases = 82.0
prec_mean(
  var = var(ken_enterprises$revenue_millions),
  n = 300,
  mu = mean(ken_enterprises$revenue_millions)
)
#> Sampling precision for mean
#> n = 300
#> se = 21.8397, moe = 42.8050, cv = 0.2485, rmoe = 0.4870

This is useful when budget fixes the take. confint() gives the corresponding planning interval, while predict() can compare supported assumptions such as response rates and design effects.

plan <- n_prop(p = p_export, moe = 0.03, deff = 1.5)

predict(
  plan,
  expand.grid(
    deff = c(1, 1.5, 2),
    resp_rate = c(0.8, 0.9, 1)
  )
)
#>   deff resp_rate         n        se  moe        cv      rmoe
#> 1  1.0       0.8  629.7685 0.0153064 0.03 0.1119441 0.2194065
#> 2  1.5       0.8  944.6527 0.0153064 0.03 0.1119441 0.2194065
#> 3  2.0       0.8 1259.5370 0.0153064 0.03 0.1119441 0.2194065
#> 4  1.0       0.9  559.7942 0.0153064 0.03 0.1119441 0.2194065
#> 5  1.5       0.9  839.6913 0.0153064 0.03 0.1119441 0.2194065
#> 6  2.0       0.9 1119.5884 0.0153064 0.03 0.1119441 0.2194065
#> 7  1.0       1.0  503.8148 0.0153064 0.03 0.1119441 0.2194065
#> 8  1.5       1.0  755.7222 0.0153064 0.03 0.1119441 0.2194065
#> 9  2.0       1.0 1007.6296 0.0153064 0.03 0.1119441 0.2194065

9.4 Several indicators or domains

n_multi() finds the binding requirement across several means and proportions.

indicators <- data.frame(
  name = c("exporter_rate", "mean_revenue"),
  p = c(p_export, NA),
  var = c(NA, var(ken_enterprises$revenue_millions)),
  moe = c(0.03, 30)
)

multi_target <- n_multi(indicators)
multi_target
#> Multi-indicator sample size
#> n = 611 (binding: mean_revenue)
#> 
#>  name          .n  .cv_target .cv_achieved .binding
#>  exporter_rate 504 0.1119441  0.1016724            
#>  mean_revenue  611        NA         NA    *

A domain-specific plan needs enough observations within every reporting domain. When n_multi() returns a domain table, the sampling design needs a matching stratify_by() so those counts have an operational meaning.

domain_indicators <- data.frame(
  name = "exporter_rate",
  size_class = c("Small", "Medium", "Large"),
  p = c(0.10, 0.22, 0.45),
  cv = 0.10
)

domain_target <- n_multi(domain_indicators, domains = "size_class")

domain_sample <- sampling_design() |>
  stratify_by(size_class) |>
  draw(n = domain_target) |>
  execute(ken_enterprises, seed = 2)

count(domain_sample, size_class, name = "n_sampled")
#> # A tibble: 3 × 2
#>   size_class n_sampled
#>   <fct>          <int>
#> 1 Small            900
#> 2 Medium           355
#> 3 Large            123

9.5 Allocate a total across strata

When strata are already defined, n_alloc() distributes a total using the chosen planning criterion. This example uses Neyman allocation for mean revenue, with at least 20 enterprises in every sector.

allocation_frame <- ken_enterprises |>
  summarise(
    N = n(),
    sd = sd(revenue_millions),
    .by = sector
  ) |>
  mutate(stratum = sector, N, sd, .keep = "none")

allocation <- n_alloc(
  allocation_frame,
  n = 600,
  alloc = "neyman",
  min_n_stratum = 20
)

as.data.frame(allocation)[, c("stratum", "N", "n_int", "weight")]
#>                         stratum    N n_int   weight
#> 1 Chemicals & Chemical Products  282    26 10.95380
#> 2                  Construction 2343    77 30.54684
#> 3                          Food  856    62 13.79354
#> 4        Hotels and Restaurants 3065    60 50.63273
#> 5           Other Manufacturing 2113   118 17.91983
#> 6                Other Services 6187   158 39.08919
#> 7                        Retail 2158    99 21.84907

The result maps its stratum names to the values used by stratify_by().

allocated_sample <- sampling_design(title = "Allocated enterprise survey") |>
  stratify_by(sector) |>
  draw(n = allocation) |>
  execute(ken_enterprises, seed = 3)

count(allocated_sample, sector, name = "n_sampled")
#> # A tibble: 7 × 2
#>   sector                        n_sampled
#>   <fct>                             <int>
#> 1 Food                                 62
#> 2 Chemicals & Chemical Products        26
#> 3 Other Manufacturing                 118
#> 4 Construction                         77
#> 5 Retail                               99
#> 6 Hotels and Restaurants               60
#> 7 Other Services                      158

Do not also request an allocation in stratify_by(). Either svyplan supplies the stratum takes or samplyr computes them, not both.

Neyman allocation minimizes the planned variance for the variable represented by sd, subject to the stated constraints (Cochran 1977, sec. 5.5). A cost-weighted allocation adds the declared costs. Neither choice is automatically optimal for other variables or domains. When each stratum is also a reporting domain, power allocation (alloc = "power") compromises between the precision of the overall estimate and that of each stratum (Bankier 1988).

A stratum small enough to enumerate can be taken whole while the rest of the total is still allocated by the criterion. A logical take_all column in the planning frame marks it, and draw(n = plan) fields the census and the Neyman takes together. stratify_by() has no take-all rule of its own, so this is the route for such a design.

take_all_plan <- allocation_frame |>
  mutate(take_all = stratum == "Chemicals & Chemical Products") |>
  n_alloc(n = 600, alloc = "neyman", min_n_stratum = 20)

as.data.frame(take_all_plan)[, c("stratum", "N", "n_int", "take_all")]
#>                         stratum    N n_int take_all
#> 1 Chemicals & Chemical Products  282   282     TRUE
#> 2                  Construction 2343    42    FALSE
#> 3                          Food  856    34    FALSE
#> 4        Hotels and Restaurants 3065    34    FALSE
#> 5           Other Manufacturing 2113    65    FALSE
#> 6                Other Services 6187    88    FALSE
#> 7                        Retail 2158    55    FALSE

The census stratum has weight one and contributes no sampling variance, and the remaining takes are the Neyman allocation of what is left of the total.

9.6 Allocate for several indicators at once

Given per-stratum moments for several indicators and a precision target for each, n_alloc() finds the cheapest allocation that satisfies all of them together. measures carries the moments, one row per stratum and indicator, and targets carries one requirement per indicator.

sector_moments <- ken_enterprises |>
  summarise(
    N = n(),
    mean = mean(revenue_millions),
    sd = sd(revenue_millions),
    p = mean(exporter),
    .by = sector
  )

joint_measures <- bind_rows(
  mutate(sector_moments, stratum = sector, name = "revenue", mean, sd, .keep = "none"),
  mutate(sector_moments, stratum = sector, name = "exporter", p, .keep = "none")
)

joint_plan <- n_alloc(
  mutate(sector_moments, stratum = sector, N, .keep = "none"),
  measures = joint_measures,
  targets = data.frame(
    name = c("revenue", "exporter"),
    cv = c(0.10, 0.05)
  )
)

joint_plan
#> Joint constrained allocation (Bethel)
#> question: cheapest design meeting every precision target
#> field design: n = 1998, cost = 1998 (2 targets, all pass)
#> continuous optimum: n = 1997.659, cost = 1998 (integerizing costs +0.02%)
#> binding: exporter@.overall:cv (target 0.05, achieved 0.05)

One requirement determines the sample size and the others have slack. The result reports which.

joint_plan$constraints[, c("name", ".target", ".achieved", ".binding")]
#>       name .target  .achieved .binding
#> 1  revenue    0.10 0.08855868    FALSE
#> 2 exporter    0.05 0.05000000     TRUE

The exporter proportion binds. Mean revenue was asked for a CV of 0.10 and the design delivers about 0.089, because the sample the exporter target requires is larger than the one revenue needs. A total sample size on its own would not show that.

9.7 Check a plan against the design that runs it

A plan states the precision it expects. Executing the design repeatedly against a known population shows the precision it delivers. Comparing the two is the most direct check that the variance model, the allocation, and the executed design agree with each other.

joint_design <- sampling_design(title = "Enterprise indicators") |>
  stratify_by(sector) |>
  draw(n = joint_plan)

estimate_once <- function(seed) {
  smp <- execute(joint_design, ken_enterprises, seed = seed)
  c(
    revenue = sum(smp$.weight * smp$revenue_millions) / sum(smp$.weight),
    exporter = sum(smp$.weight * smp$exporter) / sum(smp$.weight)
  )
}

replicates <- 200
estimates <- vapply(seq_len(replicates), estimate_once, numeric(2))

Fix the acceptance band before reading the result. The Monte Carlo error on an estimated CV is about 1 / sqrt(2 * (replicates - 1)) in relative terms, so three of those is a defensible band for a check of this size.

truth <- c(
  revenue = mean(ken_enterprises$revenue_millions),
  exporter = mean(ken_enterprises$exporter)
)
tolerance <- 3 / sqrt(2 * (replicates - 1))

check <- data.frame(
  indicator = joint_plan$constraints$name,
  planned_cv = joint_plan$constraints$.achieved,
  empirical_cv = apply(estimates, 1, sd) / abs(rowMeans(estimates)),
  rel_bias = rowMeans(estimates) / truth[joint_plan$constraints$name] - 1,
  row.names = NULL
)
check$ratio <- check$empirical_cv / check$planned_cv
check$within_tolerance <- abs(check$ratio - 1) < tolerance

check
#>   indicator planned_cv empirical_cv      rel_bias     ratio
#> 1   revenue 0.08855868   0.08863628  0.0039991635 1.0008763
#> 2  exporter 0.05000000   0.04875711 -0.0006051831 0.9751422
#>   within_tolerance
#> 1             TRUE
#> 2             TRUE

Compare each indicator against the precision the plan plans for it, which is .achieved, and not against the target it was asked for. Revenue is checked against 0.089 rather than 0.10 for the reason the previous section gives.

This validates the planning model, not the survey. The frame is used as its own population, so response, coverage, and measurement play no part, and the moments the plan was given are true by construction. It cannot tell you whether those moments describe the population that will actually be surveyed.

9.8 Plan a clustered design

Clustering usually increases variance because units in the same cluster are similar. For a one-stage approximation with equal cluster size, the familiar design effect is determined by the homogeneity measure icc and the take per cluster (Kish 1965).

design_effect(icc = 0.05, n_per_psu = 10)
#> Planning design effect: 1.4500

n_cluster() can trade the number of clusters against the number of units per cluster under declared costs and homogeneity. Its result is optimal only under that planning model.

cluster_plan <- n_cluster(
  stage_cost = c(500, 50),
  icc = 0.05,
  budget = 30000
)

cluster_plan
#> Optimal 2-stage allocation
#> field design: n_psu = 25 | n_per_psu = 14 -> total n = 350
#> cv = 0.0687, cost = 30000
#> continuous optimum: n_psu = 25.22699 | n_per_psu = 13.78405 (cv = 0.0687, cost = 30000)
#> design df = 24
as.integer(cluster_plan)
#>     n_psu n_per_psu 
#>        25        14

In a two-stage samplyr design, the cluster plan supplies the stage-1 cluster count and the within-cluster take, so the same object goes to both draw() calls.

sampling_design() |>
  add_stage() |>
    cluster_by(ea_id) |>
    draw(n = cluster_plan, method = "pps_brewer", mos = households) |>
  add_stage() |>
    draw(n = cluster_plan)
#> ── Sampling Design ──────────────────────────────────────────────
#> 
#> ℹ 2 stages
#> 
#> ── Stage 1 ──────────────────────────────────────────────────────
#> • Cluster: ea_id
#> • Draw: n = 25, method = pps_brewer, mos = households
#> 
#> ── Stage 2 ──────────────────────────────────────────────────────
#> • Draw: n = 14 (per ea_id), method = srswor

A stratified plan allocated on the ultimate units comes from n_alloc() with a cluster model. The frame gives each stratum’s households N, its PSU count N_psu, a fixed take n_per_psu and the stage costs, and the measures give the within-PSU homogeneity icc_psu. The plan then sets each stratum’s PSU count at stage 1 and its take at stage 2, where the stage repeats the stratification because strata do not carry over between stages.

cluster_frame <- bfa_eas |>
  summarise(N = sum(households), N_psu = n(), .by = urban_rural) |>
  rename(stratum = urban_rural) |>
  mutate(n_per_psu = 12, cost_psu = 400, cost_ssu = 40)

cluster_alloc <- n_alloc(
  cluster_frame,
  measures = data.frame(stratum = cluster_frame$stratum, name = "poverty",
                        p = 0.3, icc_psu = 0.05),
  targets = data.frame(name = "poverty", cv = 0.08)
)

sampling_design() |>
  add_stage() |>
    stratify_by(urban_rural) |>
    cluster_by(ea_id) |>
    draw(n = cluster_alloc, method = "pps_brewer", mos = households) |>
  add_stage() |>
    stratify_by(urban_rural) |>
    draw(n = cluster_alloc)
#> ── Sampling Design ──────────────────────────────────────────────
#> 
#> ℹ 2 stages
#> 
#> ── Stage 1 ──────────────────────────────────────────────────────
#> • Strata: urban_rural
#> • Cluster: ea_id
#> • Draw: n = <2 values, per stratum>, method = pps_brewer, mos = households
#> 
#> ── Stage 2 ──────────────────────────────────────────────────────
#> • Strata: urban_rural
#> • Draw: n = <2 values, per stratum, per ea_id>, method = srswor

See ?svyplan::n_cluster and ?svyplan::n_alloc for the feasibility checks and Chapter 10 for multi-stage execution.

9.9 Field a certainty-aware allocation

A few large PSUs often dominate a stratum. Under proportional-to-size selection their inclusion probability reaches one, so a plan that pretends they are sampled understates what the field work fixes in advance. Given a register with one row per PSU, n_alloc() separates the certainty part from the sampled remainder and sizes both together. Here the ultimate units are households and the register carries each enumeration area’s household count.

ea_register <- data.frame(
  psu_id = c(sprintf("U%02d", 1:8), sprintf("R%02d", 1:8)),
  stratum = rep(c("urban", "rural"), each = 8),
  N = c(600, rep(200, 7), 450, 220, 165, rep(153, 5))
)

certainty_plan <- n_alloc(
  data.frame(stratum = c("urban", "rural"), N = c(2000, 1600), n_per_psu = 10),
  measures = data.frame(
    stratum = c("urban", "rural"), name = "literacy",
    p = c(0.5, 0.4), icc_psu = 0.05
  ),
  targets = data.frame(name = "literacy", cv = 0.10),
  psu = ea_register
)

certainty_plan$detail[, c("stratum", "n_int", "n_psu_certain", "n_psu_draw", "threshold")]
#>   stratum n_int n_psu_certain n_psu_draw threshold
#> 1   urban    86             1          6  238.9199
#> 2   rural    69             1          5  243.0680

The plan’s $psu table classifies every PSU and states the take the operational design fields in it. A certainty PSU carries its own whole take. The remainder carries n_per_psu.

filter(certainty_plan$psu, certainty)
#>   psu_id stratum   N certainty n_take .certainty_source
#> 1    U01   urban 600      TRUE     26         threshold
#> 2    R01   rural 450      TRUE     19         threshold
#>   .threshold .distance
#> 1   238.9199 1.5113020
#> 2   243.0680 0.8513335

Passing the plan to draw() fields that classification as stored. Stage 1 must be stratified and clustered, with an exact-pik fixed-size PPS method and the register’s own sizes as mos: every certainty PSU enters with probability one, and exactly n_psu_draw more are drawn per stratum. The same plan at stage 2 applies each PSU’s take. The frame here is the household listing the register describes.

households <- ea_register[rep(seq_len(nrow(ea_register)), ea_register$N), ] |>
  mutate(household = row_number(), .by = psu_id)

certainty_sample <- sampling_design(title = "Certainty-aware household survey") |>
  stratify_by(stratum) |>
  cluster_by(psu_id) |>
  draw(n = certainty_plan, method = "pps_systematic", mos = N) |>
  add_stage() |>
  draw(n = certainty_plan) |>
  execute(households, seed = 4)

count(certainty_sample, stratum, name = "n_sampled")
#> # A tibble: 2 × 2
#>   stratum n_sampled
#>   <chr>       <int>
#> 1 rural          69
#> 2 urban          86

The totals equal the plan’s n_int exactly, because both stages field the plan’s own whole-unit numbers. Before anything is drawn, execute() reconciles the frame against the register on PSU identity, stratum, and size, and refuses a frame the plan was not solved for. It also refuses a plan whose remainder draw would push another PSU to probability one, naming the PSU to flag in the register’s certainty column before refitting. The plan’s classification is never silently altered.

Stratifying within PSUs at stage 2 is allowed when an allocation method states how each take splits. A bare stratify_by() is refused there, because a scalar size at a stratified stage means that size in every stratum.

households$sex <- c("f", "m")[households$household %% 2 + 1]

stratified_takes <- sampling_design() |>
  stratify_by(stratum) |>
  cluster_by(psu_id) |>
  draw(n = certainty_plan, method = "pps_systematic", mos = N) |>
  add_stage() |>
  stratify_by(sex, alloc = "proportional") |>
  draw(n = certainty_plan) |>
  execute(households, seed = 4)

count(stratified_takes, stratum, name = "n_sampled")
#> # A tibble: 2 × 2
#>   stratum n_sampled
#>   <chr>       <int>
#> 1 rural          69
#> 2 urban          86

n_alloc() refuses a register in which a PSU holds fewer units than the take, unless the register flags it as certainty. Small enumeration areas are usually merged with a neighbor before the register is built. svyplan’s merge_psus() merges them in row order within each group, so sort the listing first, with serp() for a geographic order, and group it by stratum. The frame then carries the merged ids, so the register and the frame agree.

listing <- data.frame(
  stratum = rep(c("urban", "rural"), c(7, 6)),
  ea = c(sprintf("U%d", 1:7), sprintf("R%d", 1:6)),
  households = c(120, 6, 4, 95, 110, 3, 80, 70, 8, 60, 5, 2, 90)
)

listing |>
  group_by(stratum) |>
  mutate(psu_id = merge_psus(ea, min_size = 10, size = households)) |>
  ungroup() |>
  count(stratum, psu_id, wt = households, name = "N")
#> # A tibble: 8 × 3
#>   stratum psu_id     N
#>   <chr>   <chr>  <dbl>
#> 1 rural   R1        78
#> 2 rural   R3        67
#> 3 rural   R6        90
#> 4 urban   U1       120
#> 5 urban   U2        10
#> 6 urban   U4        95
#> 7 urban   U5       113
#> 8 urban   U7        80

A stratum whose remainder draws one PSU leaves its variance inestimable, and a stratum that draws several gives no control over where they fall. With n_psu_per_zone = 2, n_alloc() sorts each stratum’s remainder (by size, unless the register has a zone_order column), cuts it into zones of about equal total size, and draws two PSUs from every zone (Valliant et al. 2018, sec. 9.5). A zone of two PSUs or fewer would be a census, so its PSUs become certainty. Zones need more PSUs per stratum than the register above holds.

zone_register <- data.frame(
  psu_id = c(sprintf("U%02d", 1:24), sprintf("R%02d", 1:18)),
  stratum = rep(c("urban", "rural"), c(24, 18)),
  N = c(400, 60 + 5 * (1:23), 300, 50 + 4 * (1:17))
)

zoned_plan <- n_alloc(
  data.frame(stratum = c("urban", "rural"), N = c(3160, 1762), n_per_psu = 8),
  measures = data.frame(
    stratum = c("urban", "rural"), name = "literacy",
    p = c(0.5, 0.4), icc_psu = 0.05
  ),
  targets = data.frame(name = "literacy", cv = 0.12),
  psu = zone_register,
  n_psu_per_zone = 2
)

zoned_plan$detail[, c("stratum", "n_int", "n_psu_certain", "n_psu_draw", "n_zone")]
#>   stratum n_int n_psu_certain n_psu_draw n_zone
#> 1   urban    73             1          8      4
#> 2   rural    39             1          4      2

The plan records each remainder PSU’s zone in $psu$.zone. draw() fields the zones as stored. It draws two PSUs from every zone, each with probability twice its share of the zone’s size, and records the zone in .zone_1.

zone_households <- zone_register[rep(seq_len(nrow(zone_register)), zone_register$N), ] |>
  mutate(household = row_number(), .by = psu_id)

zoned_sample <- sampling_design(title = "Zoned household survey") |>
  stratify_by(stratum) |>
  cluster_by(psu_id) |>
  draw(n = zoned_plan, method = "pps_brewer", mos = N) |>
  add_stage() |>
  draw(n = zoned_plan) |>
  execute(zone_households, seed = 4)

zoned_sample |>
  distinct(stratum, .zone_1, psu_id) |>
  count(stratum, .zone_1, name = "n_psu")
#> # A tibble: 8 × 3
#>   stratum .zone_1 n_psu
#>   <chr>     <int> <int>
#> 1 rural         1     2
#> 2 rural         2     2
#> 3 rural        NA     1
#> 4 urban         1     2
#> 5 urban         2     2
#> 6 urban         3     2
#> 7 urban         4     2
#> 8 urban        NA     1

The NA rows are the certainty PSUs. as_svydesign() takes stratum by zone as the variance strata and puts the certainty PSUs in take-all strata of their own, so every stratum that contributes variance holds two sampled PSUs.

With n_psu_per_zone = 1 the plan draws one PSU from every zone, which usually gives the smallest sample for the same number of visits. A zone then has no variance of its own, so n_alloc() also fixes, before selection, groups of two or three zones to collapse for variance, recorded in $psu$.pair. A stratum cut into a single zone joins the zones of a neighboring stratum. draw() records each PSU’s group in .pair_1.

paired_plan <- n_alloc(
  data.frame(stratum = c("urban", "rural"), N = c(3160, 1762), n_per_psu = 8),
  measures = data.frame(
    stratum = c("urban", "rural"), name = "literacy",
    p = c(0.5, 0.4), icc_psu = 0.05
  ),
  targets = data.frame(name = "literacy", cv = 0.12),
  psu = zone_register,
  n_psu_per_zone = 1
)

paired_sample <- sampling_design(title = "One PSU per zone") |>
  stratify_by(stratum) |>
  cluster_by(psu_id) |>
  draw(n = paired_plan, method = "pps_brewer", mos = N) |>
  add_stage() |>
  draw(n = paired_plan) |>
  execute(zone_households, seed = 4)

paired_sample |>
  distinct(stratum, .zone_1, .pair_1, psu_id) |>
  count(.pair_1, name = "n_psu")
#> # A tibble: 7 × 2
#>   .pair_1 n_psu
#>     <int> <int>
#> 1       1     2
#> 2       2     2
#> 3       3     2
#> 4       4     2
#> 5       5     2
#> 6       6     2
#> 7      NA     2

as_svydesign() takes the groups as the variance strata. Each zone is a single draw, so its PSUs carry no finite population correction, and the collapsed variance errs on the large side (Valliant et al. 2018, sec. 15.5.3).

Designs holding a certainty plan serialize with write_design() like any other and replay identically. The file carries the register, so an edited copy is refused at execution rather than fielded.

9.10 Plan a repeated survey

A repeated survey often needs precision for a change. That calculation depends on how much of the responding sample appears at both occasions and how strongly the outcome is associated within those shared units.

design_overlap() computes issued-sample overlap from a rotation schedule. A cohort interviewed on four consecutive occasions produces 75 percent overlap between adjacent steady-state samples.

membership <- design_overlap("4")
membership[1]
#> [1] 0.75

At complete response, this issued overlap can be passed directly to n_change(). Below complete response, convert it to the expected overlap among respondents under a stated response-persistence assumption. For illustration, assume response is independent between occasions at rate 0.80. A shared issued unit then responds again with probability 0.80, so respondent overlap is issued overlap multiplied by that rate.

change_response_rate <- 0.80
respondent_overlap <- membership[1] * change_response_rate

change_target <- n_change(
  p = c(p_export, p_export + 0.05),
  moe = 0.02,
  overlap = respondent_overlap,
  overlap_cor = 0.6
)

change_target
#> Sample size for change (proportion scale)
#> n = 1667 per occasion (p = 0.137 to 0.187, moe = 0.02, deff = 1)
#> overlap = 0.6, overlap_cor = 0.6 (64.3% of the independent variance)

overlap_cor is an outcome assumption, not another name for membership overlap. Use prior rounds or a sensitivity range rather than deriving it from the rotation pattern.

Chapter 12 takes up recruitment, retention and refreshment. It covers n_panel() and the distinction between the start-up sample and each later intake, then draws the start-up sample and the first refreshment cohort. Chapter 11 treats the case where overlap is controlled through PRNs instead of a panel schedule.

9.11 What planning does not decide

Planning results depend on their inputs. Before treating a number as a field requirement, record:

  • the estimand and reporting domains
  • the source of variances, proportions, and homogeneity assumptions
  • whether the count means issued units or expected respondents
  • the response and retention model
  • the design effect and the estimand it describes
  • the rounding rule and any finite-frame limit

svyplan does not choose a sampling algorithm or validate a register. samplyr does not decide the precision target. Keeping that boundary explicit makes it possible to revisit assumptions without silently changing the selection design.

9.12 From plan to fieldwork

Chapter 10 carries a design through fieldwork and ends with the handoff to analysis after data collection. Chapter 12 makes the corresponding handoff for a longitudinal survey. The planning questions this chapter only touches have their own chapters or sections: power in Chapter 8, two-phase designs in Chapter 6, stratum boundaries in Section 3.6, and pooled estimates across occasions in Chapter 7.