--- title: "sandwichrand: Allocation Sequences by Sandwich Mixed Randomization" author: "Bingshun Wang" date: "`r Sys.Date()`" output: rmarkdown::html_vignette: toc: true vignette: > %\VignetteIndexEntry{sandwichrand: Allocation Sequences by Sandwich Mixed Randomization} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") library(sandwichrand) ``` # Motivation Randomization is the cornerstone of the randomized controlled trial (RCT). A randomization procedure must satisfy two competing objectives simultaneously: it must produce comparable treatment groups (allocation **balance**), and it must prevent anyone from guessing the next allocation (allocation **unpredictability**). The permuted block design (PBD), still the dominant procedure in practice, achieves near-perfect balance at the price of predictability: once an investigator infers the block structure, the final assignment inside a block becomes foreseeable. The **Sandwich Mixed Randomization** (SMR) scheme of Wang et al. (2026) resolves this tension. An SMR sequence is a "sandwich": ``` |<--- n1 --->|<-------- N - 2*n1 -------->|<--- n1 --->| +------------+----------------------------+------------+ | block 1 | blocks 2 .. K | block K+1 | | imbalanced | permuted blocks of | complement | | | varying sizes | of block 1 | +------------+----------------------------+------------+ ``` The two end blocks are deliberately and controllably imbalanced, so that the first part of the sequence remains unpredictable; the middle part uses permuted blocks with varying block sizes; and the last block is the exact complement of the first, so that overall balance is guaranteed by construction. The `sandwichrand` package is an efficient, reproducible R implementation of this scheme. # Methods ## Module I: the two complementary unbalanced blocks The head and tail blocks both have size `n1`. The size `n1` is a multiple of `ratio_sum` (the sum of the allocation-ratio units, e.g. 2 for 1:1, 5 for 2:2:1) and is sampled uniformly at random from the admissible values in `[2*ratio_sum, N/2]`, keeping at most 10 candidate values. This means the block structure is not fixed in advance and cannot be predicted. A pool of `2*n1` slots is built in exact proportion to the target allocation and shuffled by Fisher--Yates. The first `n1` slots form the head block and the remaining `n1` slots form the tail block, so the two are complementary and their union is exactly proportional. The shuffle is repeated (replacement randomization) until the head block's imbalance is acceptable. The imbalance is measured by the **spread** of the deviations from the target allocation, ``` spread = max_g( count_g - n * mult_g / ratio_sum ) - min_g( count_g - n * mult_g / ratio_sum ). ``` For a 1:1 allocation this is exactly the difference between the two group sizes, i.e. the definition used in the paper; for unequal ratios it is the natural generalization (`spread = 0` means exactly proportional). The default admissible window is `[1, 4]`; 4 is the "safety factor" discussed in the paper. The head block must be *strictly* imbalanced (`spread >= 1`), otherwise the scheme would degenerate into a plain permuted-block design. ## Module II: permuted blocks with varying block sizes The middle section holds `N - 2*n1` slots, split into blocks whose sizes are drawn at random from `2*ratio_sum`, `3*ratio_sum` and `4*ratio_sum` (the "doubled, tripled or quadrupled" sizes of the paper). At every step only feasible sizes are drawn, so no "crumb" block is created. Each block is filled in exact proportion to the target allocation (largest-remainder apportionment) and then shuffled. ## Boundary paths * **Path 1** — `N < 4*ratio_sum`: the trial is too small for a sandwich, so a single block of size `N` is produced by complete randomization. * **Path 3** — `N == 2*n1`: head and tail blocks only, no middle section. # Implementation `sandwichrand` is a pure R package (no compiled code) depending only on base R and the `parallel`, `stats`, `graphics` and `utils` packages. ```{r} library(sandwichrand) ``` The public interface is small and orthogonal: | Function | Purpose | |--------------------|----------------------------------------------------------------| | `smr_alloc()` | Generate one allocation sequence. | | `smr_streams()` | Derive independent random streams for high-frequency calling. | | `smr_reproduce()` | Regenerate a sequence from its stored specification. | | `smr_verify()` | Re-check every structural invariant. | | `smr_balance()` | Arm counts and deviations from target. | | `smr_blocks()` | Block structure (the statistician's copy). | | `smr_imbalance()` | Running imbalance along the sequence. | | `smr_spread()` | Spread of deviations (the imbalance measure). | | `smr_simulate()` | Assess a design by replication. | | `smr_rtf()` | Write an RTF randomization report. | | `smr_write_csv()` | Write a CSV randomization list. | # Usage ## Two-arm 1:1 trial ```{r} x <- smr_alloc(N = 48, arms = c("TestTrt", "Placebo"), seed = 84529052) print(x, n = 8) ``` The arm balance is exact (24 vs 24) and the block structure is stored in `$blocks`: ```{r} x$balance x$blocks ``` ## Unequal allocation ratio ```{r} y <- smr_alloc(N = 150, arms = c("A", "A", "B", "B", "C"), seed = 123456) y$balance ``` The `arms` argument accepts a character vector (one token per ratio unit), a single blank-separated string, or a named ratio vector: ```{r} z <- smr_alloc(N = 60, arms = c(TestTrt = 2, Placebo = 2, Control = 1), seed = 7) z$design[c("arms", "ratio", "ratio_sum")] ``` ## Releasing the list (ICH E9 / GCP) ```{r, eval = FALSE} x <- smr_alloc(N = 48, arms = c("TestTrt", "Placebo"), seed = 84529052, keep.blocks = FALSE) names(x$alloc) # "id" "arm" only -- no block information smr_write_csv(x, "randomization.csv") smr_rtf(x, "randomization.rtf") ``` ## Multi-center (center-stratified) randomization In a multi-centre RCT the treatment groups must be balanced **within every centre**, not only overall, otherwise centre and arm become confounded. The standard remedy is **stratification by centre**: run `smr_alloc()` independently in each centre and combine the per-centre lists. Each centre may have its own sample size (and its own allocation ratio, if required). To keep the whole multi-centre schedule reproducible from a single number, derive one independent stream per centre with `smr_streams()` and hand one to each call. ```{r} centers <- data.frame( center = c("Site 01", "Site 02", "Site 03"), n = c(48, 36, 24) # planned enrolment per centre ) streams <- smr_streams(seed = 20260301, n = nrow(centers)) ## One independent SMR run per centre, then one merged list rand_list <- do.call(rbind, lapply(seq_len(nrow(centers)), function(i) { x <- smr_alloc(N = centers$n[i], arms = c("TestTrt", "Placebo"), stream = streams[[i]]) cbind(center = centers$center[i], x$alloc) })) rand_list$subject <- seq_len(nrow(rand_list)) # global sequential id head(rand_list[, c("center", "subject", "id", "arm")], 10) ``` ```{r} ## Balance is exact within every centre (and therefore overall) with(rand_list, table(center, arm)) ``` Each centre's block structure is still available in its own `smr_alloc()` object for quality control. The released list drops it and is written with `write.csv()`: ```{r, eval = FALSE} release <- rand_list[, c("center", "subject", "id", "arm")] write.csv(release, "randomization_multicenter.csv", row.names = FALSE) ``` # Validation Every structural invariant promised by the scheme is checked by `smr_verify()`, and a randomized check of the design matrix is run by the test suite. ```{r} v <- smr_verify(x) v all(v$status == "PASS") ``` The running imbalance starts at the head block's deliberate imbalance and returns to at most 1 by the end of the sequence: ```{r, fig.width = 6, fig.height = 4} plot(x, type = "imbalance") ``` A design can be assessed by replication on the two key performance metrics of a randomization procedure — **allocation balance** (the absolute imbalance `E|D(i)|`, with `D(i) = n_A(i) - n_B(i)`) and **allocation unpredictability** (the Blackwell–Hodges proportion of correct guesses, PCG): ```{r} s <- smr_simulate(N = 48, arms = c("A", "B"), reps = 1000, seed = 11) s ``` The per-step correct guesses and their mean are also available for a single sequence. Under the convergence strategy the next assignment is predicted to go to the arm with the fewer prior allocations (`0.5` on ties); a PCG close to `0.5` means the sequence is about as unpredictable as complete randomization: ```{r} cg <- smr_correct_guesses(x) smr_pcg(x) # proportion of correct guesses (0.5 = complete randomization) ``` # Reproducibility Every sequence is a deterministic function of a single seed. The seed actually used is stored and can be reused: ```{r} x1 <- smr_alloc(N = 48, arms = c("A", "B"), seed = 2026) x2 <- smr_alloc(N = 48, arms = c("A", "B"), seed = 2026) identical(x1$alloc, x2$alloc) # TRUE identical(smr_reproduce(x1)$alloc, x1$alloc) # TRUE ``` For **high-frequency calling** (e.g. a simulation loop), `smr_streams()` derives a family of non-overlapping L'Ecuyer-CMRG sub-streams from one base seed; each call is handed one stream, so no two calls ever draw from the same random numbers, while the whole loop remains reproducible: ```{r} ss <- smr_streams(seed = 42, n = 3) a <- smr_alloc(N = 48, arms = c("A", "B"), stream = ss[[1]]) b <- smr_alloc(N = 48, arms = c("A", "B"), stream = ss[[2]]) !identical(a$alloc, b$alloc) # independent streams -> different sequences ``` A call to `smr_alloc()` never changes the random number state of the calling session. # Computational details * **Random streams.** The package uses the L'Ecuyer-CMRG generator, the only generator for which `parallel::nextRNGStream()` guarantees non-overlapping sub-streams (each about $2^{127}$ draws long). One call derives four module sub-streams (selection of `n1`, Module I, Module II, Path 1), which are therefore statistically independent. This mirrors and strengthens the `CALL STREAMINIT` mechanism of the SAS macro. * **Shuffling.** Fisher--Yates with all uniforms drawn in a single `runif()` call, identical to the naive loop but about an order of magnitude faster for long vectors. * **Apportionment.** Blocks are filled by the largest-remainder rule with ties broken at random, which is exactly proportional for a multiple of `ratio_sum` and never more than one slot from the quota otherwise. * **Timing.** The generator is pure R; generating a sequence of length $N = 2000$ takes a few milliseconds. # Summary `sandwichrand` provides an efficient, reproducible and auditable R implementation of the Sandwich Mixed Randomization scheme. It simultaneously guarantees overall allocation balance and allocation unpredictability, supports any number of arms and any allocation ratio, samples the head-block size at random, and uses a spread-based imbalance measure that is a natural generalization of the between-group difference used for 1:1 designs. The package is ready for use in clinical trials and for simulation studies that evaluate randomization procedures.