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:
Clean the participant list (remove incomplete and duplicate registrations).
Build a role stratum and organization type stratum, classifying also open text answers.
Cross the two strata to form randomization blocks.
Randomize to treatment and control within each block, at approximately 50/50.
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.
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.
---title: "CAPIRE — Stratified randomization"subtitle: "Strata construction and block-randomized assignment to treatment and control"author: "FBK"date: todaylang: enformat: html: theme: cosmo toc: true toc-depth: 3 toc-location: left number-sections: true code-fold: true code-summary: "Show code" code-tools: true embed-resources: true df-print: defaultexecute: warning: false message: falseeditor: source---```{r}#| label: setuplibrary(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) )}```::: {.callout-tip title="Info" collapse="false"}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 withsmall 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.4. Cross the two strata to form randomization **blocks**.5. Randomize to treatment and control **within each block**, at approximately 50/50.:::# Data import and cleaningThe raw file is the registration export.```{r}#| label: importdf_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)``````{r}df |>select(# name, surname, role, org, org_type ) |>arrange(org_type) |> DT::datatable(filter ="top")n_dupes <- df |>get_dupes(name, surname, cf) |>pull(cf) |>unique() |>length()```There are `r n_raw` entries, but one is an empty raw and there are also `r n_dupes` sets of duplicates.```{r}#| label: cleandf <- 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)``````{r}#| label: clean-summarytibble(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))```# Seniority role stratumThe role field has many responses with free text, which have to be classified. ## Raw role values```{r}#| label: tabyl-role-rawdf |>tabyl(role) |>arrange(desc(n)) |>gt_tabyl(title ="Raw values" )```## Step 1 — three categoriesThe 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.```{r}#| label: role3df <- 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")) )```### CheckPlease check if OK```{r}#| label: tabyl-role-crossdf |>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) )``````{r}#| label: tabyl-role3df |>tabyl(role3) |>gt_tabyl(title ="Role, three categories" )```## Step 2 — collapse to two categoriesSince `esterno` group is small we merge it into `non_apicale`**.```{r}#| label: role2df <- df |>mutate(role2 =fct_collapse(role3, non_apicale =c("non_apicale", "esterno")),role2 =factor(role2, levels =c("apicale", "non_apicale")) )``````{r}#| label: tabyl-role2df |>tabyl(role2) |>gt_tabyl(title ="Role, two-category stratum" )```# Institution type stratum```{r}#| label: tabyl-org-rawdf |>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))```## Three categoriesThe 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.```{r}#| label: org3df <- 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")) )```### CHECKPlease check if all ok!```{r}#| label: tabyl-org-crossdf |>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) )``````{r}#| label: tabyl-org3df |>tabyl(org3) |>gt_tabyl(title ="Organization type, final three-category stratum" )```# Randomization blocksThe two strata are crossed to form the blocks. ```{r}#| label: blocksdf <- df |>mutate(block =interaction(role2, org3, drop =TRUE, sep ="_"))``````{r}#| label: tabyl-blocksdf |>tabyl(block) |>gt_tabyl(title ="Size of each randomization block" )````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).# Assignment to arms**Each block is assigned to roughly 50% treatment and 50% control**. Blocks withodd number of participants are randomly rounded up or down.```{r}#| label: assignset.seed(20260907)df <- df |>mutate(arm =block_ra(blocks = block,prob =0.5,conditions =c("Control", "Treatment") ) )``````{r}#| label: tabyl-block-armdf |>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))```## Overall balance```{r}#| label: tabyl-armdf |>tabyl(arm) |>gt_tabyl(title ="Overall size of the two arms" )```## Balance check on the stratifying variablesBy construction the arms should be near-identical on role and organization.```{r}#| label: balancedf |>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))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))```# Export```{r}#| label: export#| eval: truetimestamp <-format(Sys.time(), "%Y-%m-%d_%H-%M-%S")# 2. Build the output file pathfile_name <-paste0("capire_assignment_", timestamp, ".xlsx")file_path <-here("output", file_name)# 3. Ensure directory exists and saveif (!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)``````{r}#| label: sessionsessionInfo()```