The hardware and bandwidth for this mirror is donated by dogado GmbH, the Webhosting and Full Service-Cloud Provider. Check out our Wordpress Tutorial.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]dogado.de.

sandwichrand: Allocation Sequences by Sandwich Mixed Randomization

Bingshun Wang

2026-10-01

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

Implementation

sandwichrand is a pure R package (no compiled code) depending only on base R and the parallel, stats, graphics and utils packages.

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

x <- smr_alloc(N = 48, arms = c("TestTrt", "Placebo"), seed = 84529052)
print(x, n = 8)
#> SMR allocation sequence (sandwich mixed randomization)
#>   N             = 48
#>   arms          = TestTrt : Placebo   (ratio 1:1, ratio_sum = 2, 2 arms)
#>   layout        = 6 + 6 x 4/6/8 + 6   (path 2)
#>   first block   = n1 = 6, imbalance 2 (target [1, 4])
#>   middle        = 6 block(s), 36 slots, candidate sizes 4 6 8
#>   seed          = 84529052   (The seed was specified by the user.)
#>   block columns = kept
#> 
#> Allocation list (first 8 of 48 rows)
#>  id block block_size     arm
#>   1     1          6 TestTrt
#>   2     1          6 TestTrt
#>   3     1          6 TestTrt
#>   4     1          6 Placebo
#>   5     1          6 Placebo
#>   6     1          6 TestTrt
#>   7     2          8 TestTrt
#>   8     2          8 TestTrt
#> 
#> Arm balance
#>      arm  n expected deviation
#>  TestTrt 24       24         0
#>  Placebo 24       24         0

The arm balance is exact (24 vs 24) and the block structure is stored in $blocks:

x$balance
#>       arm  n expected deviation
#> 1 TestTrt 24       24         0
#> 2 Placebo 24       24         0
x$blocks
#>   block size first_id last_id n_Placebo n_TestTrt
#> 1     1    6        1       6         2         4
#> 2     2    8        7      14         4         4
#> 3     3    8       15      22         4         4
#> 4     4    6       23      28         3         3
#> 5     5    4       29      32         2         2
#> 6     6    4       33      36         2         2
#> 7     7    6       37      42         3         3
#> 8     8    6       43      48         4         2

Unequal allocation ratio

y <- smr_alloc(N = 150, arms = c("A", "A", "B", "B", "C"), seed = 123456)
y$balance
#>   arm  n expected deviation
#> 1   A 60       60         0
#> 2   B 60       60         0
#> 3   C 30       30         0

The arms argument accepts a character vector (one token per ratio unit), a single blank-separated string, or a named ratio vector:

z <- smr_alloc(N = 60, arms = c(TestTrt = 2, Placebo = 2, Control = 1), seed = 7)
z$design[c("arms", "ratio", "ratio_sum")]
#> $arms
#> [1] "TestTrt" "Placebo" "Control"
#> 
#> $ratio
#> [1] "2:2:1"
#> 
#> $ratio_sum
#> [1] 5

Releasing the list (ICH E9 / GCP)

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.

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)
#>     center subject id     arm
#> 1  Site 01       1  1 TestTrt
#> 2  Site 01       2  2 Placebo
#> 3  Site 01       3  3 Placebo
#> 4  Site 01       4  4 TestTrt
#> 5  Site 01       5  5 Placebo
#> 6  Site 01       6  6 Placebo
#> 7  Site 01       7  7 Placebo
#> 8  Site 01       8  8 Placebo
#> 9  Site 01       9  9 TestTrt
#> 10 Site 01      10 10 TestTrt
## Balance is exact within every centre (and therefore overall)
with(rand_list, table(center, arm))
#>          arm
#> center    Placebo TestTrt
#>   Site 01      24      24
#>   Site 02      18      18
#>   Site 03      12      12

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():

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.

v <- smr_verify(x)
v
#> SMR verification report
#>   [PASS] rows : rows = 48, expected 48
#>   [PASS] arms_complete : unknown or missing arm values = 0
#>   [PASS] ids : distinct ids = 48, range [1, 48], expected 48
#>   [PASS] balance : max |deviation| = 0
#>   [PASS] seed_or_stream : seed = 84529052, stream_used = FALSE
#>   [PASS] blocks_sum : sum of block sizes = 48, expected 48
#>   [PASS] blocks_contiguous : blocks cover ids 1..48 contiguously
#>   [PASS] n1_equal : head block size = 6, tail block size = 6
#>   [PASS] n1_min : head block size = 6, minimum = 4
#>   [PASS] complementarity : combined head+tail deviation from target = [0.000, 0.000]
all(v$status == "PASS")
#> [1] TRUE

The running imbalance starts at the head block’s deliberate imbalance and returns to at most 1 by the end of the sequence:

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):

s <- smr_simulate(N = 48, arms = c("A", "B"), reps = 1000, seed = 11)
s
#> SMR design assessment by simulation
#>   replications        = 1000
#>   final imbalance     : mean 0, median 0, max 0, P(|imb| <= 1) 1
#>   maximum imbalance   : mean 4.793, median 5, 95% quantile 7
#>   correct-guess PCG   : mean 0.5601146, median 0.5625, min 0.5104167, max 0.6666667
#>   first block size n1 : 4 .. 22

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:

cg <- smr_correct_guesses(x)
smr_pcg(x)            # proportion of correct guesses (0.5 = complete randomization)
#> [1] 0.5208333

Reproducibility

Every sequence is a deterministic function of a single seed. The seed actually used is stored and can be reused:

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
#> [1] TRUE
identical(smr_reproduce(x1)$alloc, x1$alloc)   # TRUE
#> [1] 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:

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
#> [1] TRUE

A call to smr_alloc() never changes the random number state of the calling session.

Computational details

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.

These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.
Health stats visible at Monitor.