User guide¶
This guide runs one experiment from start to finish on the bundled National Supported Work data: assign 445 men to two groups, check the balance, compare the design with complete randomization, and test for an effect. Every output below comes from running the code shown.
Assign units¶
An FSM run takes two inputs: the covariates and a selection order matrix (SOM),
which says which group chooses at each turn. som draws the order at random.
For two groups the default method, SCOMARS, keeps each group’s count of turns
within one of its expected share at every stage.
import numpy as np
import finite_selection as fs
x = fs.lalonde()
rng = np.random.default_rng(42)
order = fs.som([222, 223], method="scomars", rng=rng)
print(order.treat[:10])
result = fs.fsm(x, order, rng=rng)
print(np.bincount(result.assignment)[1:])
[1 2 1 2 2 1 1 2 2 1]
[222 223]
result.assignment holds each unit’s group label (1 or 2), in the row order of
x. result.selected lists the units in the order they were chosen.
Check balance¶
One allocation says little about a design. Draw many, and compare them with
complete randomization (crd) on TASMD: the distance between a group’s
covariate means and the full-sample means, in standard deviations.
fsm_draws = np.array(
[fs.fsm(x, fs.som([222, 223], rng=rng), rng=rng).assignment for _ in range(200)]
)
crd_draws = np.array([fs.crd([222, 223], rng=rng) for _ in range(200)])
balance = fs.tasmd_rand(x, {"CRD": crd_draws, "FSM": fsm_draws})
print(balance.mean.round(3))
CRD FSM
Age 0.039 0.005
Education 0.035 0.006
Black 0.036 0.007
Hispanic 0.037 0.008
Married 0.038 0.006
Nodegree 0.036 0.005
Re74 0.037 0.004
Re75 0.038 0.006
On every covariate the FSM’s average imbalance is five to nine times smaller.
balance.sd gives the spread across draws.
The FSM balances the columns it is given. Here it also balances the squares of the continuous covariates, which it was never shown:
squares = fs.make_sq_inter(
x[["Age", "Education", "Re74", "Re75"]], is_inter=False, keep_marginal=False
)
print(fs.tasmd_rand(squares, {"CRD": crd_draws, "FSM": fsm_draws}).mean.round(3))
CRD FSM
Age^2 0.039 0.004
Education^2 0.035 0.007
Re74^2 0.037 0.003
Re75^2 0.041 0.003
That spillover is not guaranteed for every function of the covariates. To
balance squares or interactions on purpose, pass the output of make_sq_inter
to fsm along with the original columns.
Compare designs by effective sample size¶
Balance matters because it shrinks the variance of the effect estimate. The effective sample size (ESS) puts that in units of sample size: a design’s ESS is N times the smallest variance among the designs compared, divided by its own variance. To compute it you need potential outcomes, so this example simulates them. The outcome depends on age, education and 1975 earnings, and treatment adds 1,000 to every unit.
signal = 50 * x["Age"] + 300 * x["Education"] + 0.3 * x["Re75"]
y_control = (1000 + signal + rng.normal(0, 1000, len(x))).to_numpy()
y_mat = np.column_stack([y_control + 1000, y_control]) # treatment adds 1000
print(fs.ess_rand([crd_draws, fsm_draws], y_mat).round(0))
[264. 445.]
Complete randomization estimates the effect as precisely as the FSM would with 264 units instead of 445. The gain grows with how well the balanced covariates predict the outcome, and it vanishes when they predict nothing.
Test for an effect¶
The FSM’s turn order is random, so the reference distribution for a Fisher
randomization test is the set of allocations the same procedure produces.
perm_test takes the observed allocation, the observed outcomes and those
reference draws.
z = fsm_draws[0]
y_obs = np.where(z == 1, y_mat[:, 0], y_mat[:, 1])
test = fs.perm_test(y_obs, z, fsm_draws[1:])
print(round(test.stat_obs), test.p_value)
987 0.005
The estimate is 987 against a true effect of 1,000. The 199 independent reference
draws exclude the allocation used as the
observed experiment. The Monte Carlo correction includes the observed
statistic once, so the smallest possible p-value is 1/200. More draws give
a finer p-value. With the default two-group contrast and method="mean",
tau= tests the sharp null that every unit’s effect equals tau.
Use exact=True only when supplying the complete, equally weighted assignment
distribution, including the observed assignment. A list of distinct FSM
allocations need not have equal probabilities. Rank tests require tau=0
and a contrast summing to zero. For more than two groups, the supported null
is that each unit has the same outcome under every treatment, which is stronger
than a zero average treatment effect or a single zero contrast.
Allocate in batches¶
When units arrive in batches, fsm_batch assigns each new batch given the
earlier ones. Every group starts from the information its earlier members
carry, so the batches together behave like one FSM run.
first, second = x.iloc[:300], x.iloc[300:]
z_first = fs.fsm(first, fs.som([150, 150], rng=rng), rng=rng).assignment
z_second = fs.fsm_batch(
second, first, z_first, fs.som([72, 73], rng=rng), rng=rng
).assignment
print(np.bincount(np.concatenate([z_first, z_second]))[1:])
[222 223]
What the design does not protect against¶
The FSM balances what it can see. Better balance on observed covariates does not guarantee smaller estimation variance for every outcome: components that the design does not balance can fare worse than under complete randomization. Fredrik Sävje’s discussion examines this robustness concern. Choose covariates that are relevant to the outcome, assess plausible outcome models before assignment, and use an analysis that respects the assignment design.