CAPIRE — Stratified randomization

Strata construction and block-randomized assignment to treatment and control

Author

FBK

Published

September 9, 2026

Show code
library(tidyverse)
library(here)
library(janitor)
library(randomizr)
library(gt)

# Helper: render a janitor::tabyl as a formatted gt table.
gt_tabyl <- function(x, title = NULL, subtitle = NULL) {
  x |>
    adorn_pct_formatting(digits = 1) |>
    gt() |>
    tab_header(
      title = title,
      subtitle = subtitle
    ) |>
    opt_align_table_header(align = "left") |>
    tab_options(
      table.font.size = px(13),
      heading.title.font.size = px(15),
      heading.subtitle.font.size = px(12),
      data_row.padding = px(4)
    )
}
Info

The file documents the randomization procedure from the raw registration data to the allocation to the two cohorts. Please check each step, in particular the creation of the strata variables!

We could opt for a simple randomization, but using strata - randomizing within blocks of variables - leads to a more balanced randomization, especially with small samples such as in our case and no other baseline information.

The proposal is to create a strata which is the crossproduct of seniority of role (managerial or not) x institution type (teritorial, central or other): - this results in 6 strata, with reasonsable number of participants each. - Within each, we assign roughly half to treatment and half to control. So by construction, our randomization will balance these 2 variables, and their product. - We could add other variables, such as gender, or region (north, center, south-islands), but then our strata become very small which is not ideal. If some participants drop out, we risk not having both treated and controls within a given stratum, which complicates the analysis. Using seniority as a balancing variable also likely balances the sample well in terms of age.

The procedure is as follows:

  1. Clean the participant list (remove incomplete and duplicate registrations).
  2. Build a role stratum and organization type stratum, classifying also open text answers.
  3. Cross the two strata to form randomization blocks.
  4. Randomize to treatment and control within each block, at approximately 50/50.

1 Data import and cleaning

The raw file is the registration export.

Show code
df_raw <- readxl::read_excel(here("data", "capire_participants.xlsx"))

df <- df_raw |>
  set_names(c(
    "timestamp", "email", "name", "surname", "cf", "email2", "org", "role",
    "privacy1", "privacy2", "privacy3", "org_type", "thanks"
  ))

n_raw <- nrow(df)
Show code
df |> 
  select(
    # name, surname, 
    role, org, org_type
    ) |>
      arrange(org_type) |> 
  DT::datatable(filter = "top")
Show code
n_dupes <- df |> get_dupes(name, surname, cf) |> pull(cf) |> unique() |> length()

There are 221 entries, but one is an empty raw and there are also 7 sets of duplicates.

Show code
df <- df |>
  filter(!is.na(email)) |>
  arrange(name, surname, cf, privacy1, desc(privacy1), privacy2, privacy3) |> 
  distinct(name, surname, cf, .keep_all = TRUE)

n_clean <- nrow(df)
Show code
tibble(
  Step = c("Raw registrations", "After dropping empty records and duplicates"),
  N = c(n_raw, n_clean)
) |>
  gt() |>
  tab_header(title = "Participants") |>
  opt_align_table_header(align = "left") |>
  tab_options(table.font.size = px(13), heading.title.font.size = px(15))
Participants
Step N
Raw registrations 221
After dropping empty records and duplicates 213

2 Seniority role stratum

The role field has many responses with free text, which have to be classified.

2.1 Raw role values

Show code
df |>
  tabyl(role) |>
  arrange(desc(n)) |>
  gt_tabyl(
    title = "Raw values"
  )
Raw values
role n percent
Dipendente 154 72.3%
Dirigente 32 15.0%
Consulente 6 2.8%
Collaboratore esterno 2 0.9%
Assessore 1 0.5%
CAS 1 0.5%
Capoufficio sviluppo e innovazione organizzativa 1 0.5%
Componente nucleo 1 0.5%
Coordinatrice 1 0.5%
Eletto 1 0.5%
Funzionario Ufficio Cultura - Biblioteca 1 0.5%
PM Esperti PNRR 1 0.5%
Prima Ricercatrice 1 0.5%
Responsabile Amministrativo Scolastico 1 0.5%
Segretario particolare del sindaco di Alghero 1 0.5%
Segreteria di presidenza 1 0.5%
Vice capo ufficio di gabinetto ministero università e ricerca 1 0.5%
direttore 1 0.5%
direttrice 1 0.5%
funzionario con incarico di componente 1 0.5%
personale tecnico amministrativo - subalterno 1 0.5%
posizione organizzativa 1 0.5%
ricercatore 1 0.5%

2.2 Step 1 — three categories

The three categories are:

  • apicale (senior / managerial). Roles carrying formal managerial or political responsibility: dirigente, direttore / direttrice, capoufficio, coordinatrice, responsabile, posizione organizzativa, vicecapo gabinetto, assessore, eletto, componente nucleo. Please check!!!
  • esterno. Consulente and collaboratore esterno — people who are not employees of the organization they registered under.
  • non_apicale (non-managerial staff). Everything else - dipendente, funzionario, technical-administrative staff, ricercatore etc.
Show code
df <- df |>
  mutate(
    role_clean = str_squish(str_to_lower(role)),
    org_clean  = str_squish(str_to_lower(org_type))
  )

# ---- Step 1: role -> 3 categories ------------------------------------------
df <- df |>
  mutate(
    role3 = case_when(
      str_detect(
        role_clean,
        "dirigent|direttore|direttrice|capo ?ufficio|vice ?capo|coordinatore|coordinatrice|responsabile|posizione organizzativa|assessore|eletto|componente nucleo"
      ) ~ "apicale",
      str_detect(role_clean, "consulente|collaboratore esterno") ~ "esterno",
      TRUE ~ "non_apicale"
    ),
    role3 = factor(role3, levels = c("apicale", "non_apicale", "esterno"))
  )

2.2.1 Check

Please check if OK

Show code
df |>
  tabyl(role, role3) |>
  gt() |>
  tab_header(
    title = "Raw role value to the three-category classification"
  ) |>
  opt_align_table_header(align = "left") |>
  tab_options(
    table.font.size = px(12),
    heading.title.font.size = px(15),
    heading.subtitle.font.size = px(12),
    data_row.padding = px(3)
  )
Raw role value to the three-category classification
role apicale non_apicale esterno
Assessore 1 0 0
Capoufficio sviluppo e innovazione organizzativa 1 0 0
CAS 0 1 0
Collaboratore esterno 0 0 2
Componente nucleo 1 0 0
Consulente 0 0 6
Coordinatrice 1 0 0
Dipendente 0 154 0
direttore 1 0 0
direttrice 1 0 0
Dirigente 32 0 0
Eletto 1 0 0
funzionario con incarico di componente 0 1 0
Funzionario Ufficio Cultura - Biblioteca 0 1 0
personale tecnico amministrativo - subalterno 0 1 0
PM Esperti PNRR 0 1 0
posizione organizzativa 1 0 0
Prima Ricercatrice 0 1 0
Responsabile Amministrativo Scolastico 1 0 0
ricercatore 0 1 0
Segretario particolare del sindaco di Alghero 0 1 0
Segreteria di presidenza 0 1 0
Vice capo ufficio di gabinetto ministero università e ricerca 1 0 0
Show code
df |>
  tabyl(role3) |>
  gt_tabyl(
    title = "Role, three categories"
  )
Role, three categories
role3 n percent
apicale 42 19.7%
non_apicale 163 76.5%
esterno 8 3.8%

2.3 Step 2 — collapse to two categories

Since esterno group is small we merge it into non_apicale**.

Show code
df <- df |>
  mutate(
    role2 = fct_collapse(role3, non_apicale = c("non_apicale", "esterno")),
    role2 = factor(role2, levels = c("apicale", "non_apicale"))
  )
Show code
df |>
  tabyl(role2) |>
  gt_tabyl(
    title = "Role, two-category stratum"
  )
Role, two-category stratum
role2 n percent
apicale 42 19.7%
non_apicale 171 80.3%

3 Institution type stratum

Show code
df |>
  tabyl(org_type) |>
  arrange(desc(n)) |>
  gt_tabyl(
    title = "Raw values of the organization type field",
    subtitle = "As submitted by participants, before any recoding"
  ) |>
  cols_width(org_type ~ px(520))
Raw values of the organization type field
As submitted by participants, before any recoding
org_type n percent
Regione o Provincia (incluse Province autonome) 60 28.2%
Amministrazione centrale dello Stato 50 23.5%
Comune, Città metropolitana, Unione di comuni 33 15.5%
Azienda sanitaria o ospedaliera pubblica (ASL/ATS, AO, IRCCS pubblico) 25 11.7%
Altro ente pubblico (camera di commercio, ordine professionale, ente strumentale, azienda speciale…) 16 7.5%
Società a partecipazione pubblica / in house 9 4.2%
Impresa o società privata (incluse società di consulenza) 2 0.9%
Lavoro come libero/a professionista o consulente 2 0.9%
Sindacato 2 0.9%
Università 2 0.9%
AGENAS 1 0.5%
Commissione Europea 1 0.5%
Corte dei conti 1 0.5%
Ente pubblico di ricerca sociale 1 0.5%
PROVINCIA 1 0.5%
Terzo settore (associazioni, cooperative, fondazioni private, ETS) 1 0.5%
Università degli Studi Trento 1 0.5%
Università degli Studi di Bari 1 0.5%
Università di Trento 1 0.5%
Università di Trento - pta 1 0.5%
docente della scuola sec italiana FVG, email di riferimento istituzionale ilaria.zorino@liceomarinelli.edu.it , studente V anno Sapienza Facoltà Giurisprudenza LMG 01, esperto di debate 1 0.5%
ente pubblico di ricerca 1 0.5%

3.1 Three categories

The organization stratum is reduced to three levels:

  • territoriale (sub-national government). Regions and autonomous provinces, municipalities, metropolitan cities etc.
  • centrale (central government and supra-national). Central state administration, plus the Corte dei conti, AGENAS and the European Commission — bodies that operate at national or supra-national level.
  • sanita_altri (health service and other bodies). Public health authorities and hospitals, other public bodies, publicly-owned and in-house companies, universities and research bodies, trade unions, private firms and freelancers, third sector, and the remaining free-text answers.
Show code
df <- df |>
  mutate(
    org3 = case_when(
      str_detect(
        org_clean,
        "^regione|provincia autonom|^comune|città metropolitana|unione di comuni|^provincia$"
      ) ~ "territoriale",
      str_detect(
        org_clean,
        "amministrazione centrale|corte dei conti|commissione europea|agenas"
      ) ~ "centrale",
      TRUE ~ "sanita_altri"
    ),
    org3 = factor(org3, levels = c("territoriale", "centrale", "sanita_altri"))
  )

3.1.1 CHECK

Please check if all ok!

Show code
df |>
  tabyl(org_type, org3) |>
  gt() |>
  tab_header(
    title = "Raw organization value to the three-category classification"
  ) |>
  opt_align_table_header(align = "left") |>
  cols_width(org_type ~ px(460)) |>
  tab_options(
    table.font.size = px(12),
    heading.title.font.size = px(15),
    heading.subtitle.font.size = px(12),
    data_row.padding = px(3)
  )
Raw organization value to the three-category classification
org_type territoriale centrale sanita_altri
AGENAS 0 1 0
Altro ente pubblico (camera di commercio, ordine professionale, ente strumentale, azienda speciale…) 0 0 16
Amministrazione centrale dello Stato 0 50 0
Azienda sanitaria o ospedaliera pubblica (ASL/ATS, AO, IRCCS pubblico) 0 0 25
Commissione Europea 0 1 0
Comune, Città metropolitana, Unione di comuni 33 0 0
Corte dei conti 0 1 0
docente della scuola sec italiana FVG, email di riferimento istituzionale ilaria.zorino@liceomarinelli.edu.it , studente V anno Sapienza Facoltà Giurisprudenza LMG 01, esperto di debate 0 0 1
ente pubblico di ricerca 0 0 1
Ente pubblico di ricerca sociale 0 0 1
Impresa o società privata (incluse società di consulenza) 0 0 2
Lavoro come libero/a professionista o consulente 0 0 2
PROVINCIA 1 0 0
Regione o Provincia (incluse Province autonome) 60 0 0
Sindacato 0 0 2
Società a partecipazione pubblica / in house 0 0 9
Terzo settore (associazioni, cooperative, fondazioni private, ETS) 0 0 1
Università 0 0 2
Università degli Studi di Bari 0 0 1
Università degli Studi Trento 0 0 1
Università di Trento 0 0 1
Università di Trento - pta 0 0 1
Show code
df |>
  tabyl(org3) |>
  gt_tabyl(
    title = "Organization type, final three-category stratum"
  )
Organization type, final three-category stratum
org3 n percent
territoriale 94 44.1%
centrale 53 24.9%
sanita_altri 66 31.0%

4 Randomization blocks

The two strata are crossed to form the blocks.

Show code
df <- df |>
  mutate(block = interaction(role2, org3, drop = TRUE, sep = "_"))
Show code
df |>
  tabyl(block) |>
  gt_tabyl(
    title = "Size of each randomization block"
  )
Size of each randomization block
block n percent
apicale_territoriale 19 8.9%
non_apicale_territoriale 75 35.2%
apicale_centrale 7 3.3%
non_apicale_centrale 46 21.6%
apicale_sanita_altri 16 7.5%
non_apicale_sanita_altri 50 23.5%

apicale_centrale only have 6 individuals. We should probably combine it with non_apicale_centrale and a single block - centrale. Or we can leave it as it is, and collapse it during the analyses (all fine).

5 Assignment to arms

Each block is assigned to roughly 50% treatment and 50% control. Blocks with odd number of participants are randomly rounded up or down.

Show code
set.seed(20260907)

df <- df |>
  mutate(
    arm = block_ra(
      blocks     = block,
      prob       = 0.5,
      conditions = c("Control", "Treatment")
    )
  )
Show code
df |>
  tabyl(block, arm) |>
  adorn_totals(c("row", "col")) |>
  gt() |>
  tab_header(
    title = "Assignment to arms within each block"
  ) |>
  opt_align_table_header(align = "left") |>
  tab_options(table.font.size = px(13), heading.title.font.size = px(15))
Assignment to arms within each block
block Control Treatment Total
apicale_territoriale 9 10 19
non_apicale_territoriale 37 38 75
apicale_centrale 3 4 7
non_apicale_centrale 23 23 46
apicale_sanita_altri 8 8 16
non_apicale_sanita_altri 25 25 50
Total 105 108 213

5.1 Overall balance

Show code
df |>
  tabyl(arm) |>
  gt_tabyl(
    title = "Overall size of the two arms"
  )
Overall size of the two arms
arm n percent
Control 105 49.3%
Treatment 108 50.7%

5.2 Balance check on the stratifying variables

By construction the arms should be near-identical on role and organization.

Show code
df |>
  tabyl(role2, arm) |>
  adorn_totals("row") |>
  gt() |>
  tab_header(title = "Balance on role") |>
  opt_align_table_header(align = "left") |>
  tab_options(table.font.size = px(13), heading.title.font.size = px(15))
Balance on role
role2 Control Treatment
apicale 20 22
non_apicale 85 86
Total 105 108
Show code
df |>
  tabyl(org3, arm) |>
  adorn_totals("row") |>
  gt() |>
  tab_header(title = "Balance on organization type") |>
  opt_align_table_header(align = "left") |>
  tab_options(table.font.size = px(13), heading.title.font.size = px(15))
Balance on organization type
org3 Control Treatment
territoriale 46 48
centrale 26 27
sanita_altri 33 33
Total 105 108

6 Export

Show code
timestamp <- format(Sys.time(), "%Y-%m-%d_%H-%M-%S")

# 2. Build the output file path
file_name <- paste0("capire_assignment_", timestamp, ".xlsx")
file_path <- here("output", file_name)

# 3. Ensure directory exists and save
if (!dir.exists(here("output"))) {
  dir.create(here("output"), recursive = TRUE)
}

df |>
  select(name, surname, email, org, role, role2, org3, block, arm) |>
  ungroup() |>
  arrange(arm, block, name, surname) |> 
  writexl::write_xlsx(file_path)
Show code
sessionInfo()
R version 4.6.1 (2026-06-24 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 26200)

Matrix products: default
  LAPACK version 3.12.1

locale:
[1] LC_COLLATE=Italian_Italy.utf8  LC_CTYPE=Italian_Italy.utf8   
[3] LC_MONETARY=Italian_Italy.utf8 LC_NUMERIC=C                  
[5] LC_TIME=Italian_Italy.utf8    

time zone: Europe/Rome
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
 [1] gt_1.3.0        randomizr_2.0.1 janitor_2.2.1   here_1.0.2     
 [5] lubridate_1.9.5 forcats_1.0.1   stringr_1.6.0   dplyr_1.2.1    
 [9] purrr_1.2.2     readr_2.2.0     tidyr_1.3.2     tibble_3.3.1   
[13] ggplot2_4.0.3   tidyverse_2.0.0

loaded via a namespace (and not attached):
 [1] sass_0.4.10        generics_0.1.4     xml2_1.6.0         stringi_1.8.9     
 [5] hms_1.1.4          digest_0.6.39      magrittr_2.0.5     evaluate_1.0.5    
 [9] grid_4.6.1         timechange_0.4.0   RColorBrewer_1.1-3 fastmap_1.2.0     
[13] cellranger_1.1.0   rprojroot_2.1.1    jsonlite_2.0.0     writexl_2.0.1     
[17] crosstalk_1.2.2    scales_1.4.0       jquerylib_0.1.4    cli_3.6.6         
[21] rlang_1.3.0        cachem_1.1.0       withr_3.0.3        yaml_2.3.12       
[25] otel_0.2.0         tools_4.6.1        tzdb_0.5.0         DT_0.34.0         
[29] vctrs_0.7.3        R6_2.6.1           lifecycle_1.0.5    snakecase_0.11.1  
[33] fs_2.1.0           htmlwidgets_1.6.4  pkgconfig_2.0.3    bslib_0.12.0      
[37] pillar_1.11.1      gtable_0.3.6       glue_1.8.1         Rcpp_1.1.2        
[41] xfun_0.60          tidyselect_1.2.1   knitr_1.51         farver_2.1.2      
[45] htmltools_0.5.9    rmarkdown_2.32     compiler_4.6.1     S7_0.2.2          
[49] readxl_1.5.0