Counterbalancing & Assignment
Within-subjects structure — factors, crossing, sampling, shuffling — decides what one participant sees and in what order (see Experiment Design). Counterbalancing is the orthogonal question: across many participants, who gets which condition, kept balanced and reproducible. In PsyCloud it's declared, not left to chance, and it's deterministic — a participant's assignment can be reconstructed from the seed.
This page is about between-subjects factors — a condition a participant is assigned once and keeps for the whole session (e.g. a key mapping, a stimulus set, an instruction framing). Within-subject ordering/randomization lives in the design pipeline; see Experiment Design.
Two layers that work together
Counterbalancing in PsyCloud has two cooperating pieces:
- A per-trial assignment step —
assign_between. Inside a phase's pipeline, it filters the trial table down to the rows matching this participant's assigned cell. Methods:hash— deterministic from the participant id; no server state required.quota— balanced toward target counts (server-tracked).random— seeded random pick.
- A bundle-level allocation policy —
cb_random/cb_rotation. This declares the factor's levels and the algorithm the server uses to allocate each new participant to a cell, keeping the cells balanced as people arrive.
The policy decides which cell a participant is in; assign_between applies that cell to
the trial table at run time. Most studies use both (as below); a quick pilot can use
assign_between(method = "hash") alone.
Declare it
# Per-trial: filter the practice/main rows to this participant's mapping cell.
exp |>
phase("practice", label = "Practice") |>
trials(both_mappings(practice_rows)) |>
assign_between(factor = "mapping", method = "hash")
# ... later, at the bundle level: declare the balanced allocation policy.
exp |>
bundle(
with_auto_ids = TRUE,
counterbalance = cb_random(mapping = c("A", "B"))
)Random vs. rotation
The policy's algorithm decides how participants are spread across cells:
cb_randomassigns each participant at random, weighted so the cells converge to the target proportions over many participants. Assignment is unpredictable; at small N a run can be a little uneven — it's balanced in expectation, not exactly.cb_rotationassigns by cycling through the cells (the order reshuffled each full cycle), so the cells are evened out after each complete cycle — the tightest balance, best at small/medium N. If the next scheduled cell is unavailable (e.g. a hard quota is full) it falls back to the balancing (minimization) algorithm.
Rule of thumb: reach for cb_rotation when you want maximally even cells throughout
(small/medium N, or cells that must fill in lockstep), and cb_random when you want
unpredictable assignment and N is large enough that balance-in-expectation is fine.
# Random: ~50/50 in expectation
bundle(counterbalance = cb_random(mapping = c("A", "B")))
# Rotation: as-even-as-possible at every point (tighter at small N)
bundle(counterbalance = cb_rotation(mapping = c("A", "B")))
# Multi-factor (2x2): the two axes are crossed into 4 cells
bundle(counterbalance = cb_rotation(
mapping = c("A", "B"),
framing = c("gain", "loss")
))For weighted splits, quotas, balance targets, or streak-avoidance, drop to the lower-level
cb_policy() builder. Cells are counted by assigned participants by default
(accounting_unit can count started / completed / passed_qc instead).
The both_mappings(...) / practice_rows helpers above are illustrative — see the full
runnable study in the repo's examples/high-level/task-switching-gate.
How balance actually happens (hosted vs. preview)
Two things in this section run your design without a server:
Simulate — an SDK/workbench helper that plays the study against the real runtime with stand-in participants and returns the resulting trial tables, all on your machine. In Python it's
simulate_tables(bundle, n_participants=…); in R it'sworkbench_simulate().Preview — the same idea inside Studio: the Preview perspective drives the study with a simulated participant so you can watch it play.
Both are offline tools for checking that a design runs and reproduces — no recruited participants and no stored data. (There's no separate "simulate" page; it lives in the SDK workbench and Studio Preview.)
This distinction matters and is easy to get wrong:
- Hosted runs (real participants). At session start the server allocates the
participant using the policy's algorithm —
cb_rotationcycles through cells,cb_randomsamples toward the target proportions, and a minimization policy picks the least-filled cell — and records each decision in a ledger-backed allocation store so the running cell counts persist across sessions. The chosen cell is fixed for the session and passed to the runtime, whereassign_betweenapplies it. This is where real balance is enforced. (How abandoned sessions are handled depends on the path — see Known limits below.) - Preview /
simulate(no server). There's no ledger, so PsyCloud makes a stable, seeded per-participant pick across the declared levels — enough to exercise the design and reproduce a given participant, but not cross-participant balanced. - Compiled bundles. A compiled bundle has no participant, so compilation pins one representative cell (the first level of each between-subject axis) consistently across all scopes. Inspect a compiled artifact and you'll see that single cell — not the live per-participant allocation.
Simulate and Preview tell you a design runs and that a given participant id reproduces — but with no server-side ledger offline, they can't tell you whether conditions are balanced across participants. Real balance is enforced only on a hosted run; inspect it in Monitor / Data.
Reproducibility
There are two different guarantees, depending on where assignment happens:
- Preview /
simulateis seeded — assignment and trial order derive from a deterministic chain (study + participant + namespace), so they reproduce offline from the seed alone. - Hosted assignment is ledger-backed — the server records each participant's allocation, so you reconstruct it from the stored assignment plus the policy, not the seed alone (balanced allocation depends on arrival order and counts, which a seed can't reproduce by itself).
The within-subject trial order for a given participant is seed-reproducible in both
cases. Pair the stored assignment with a preregistered analysis script for fully reproducible
results. Changing randomNamespace in the design's determinism settings deliberately
invalidates all previously generated streams.
Order counterbalancing (Latin square)
For within-session order effects (e.g. block order), PsyCloud's allocators include
Latin-square schemes — cyclic rotation and balanced Williams designs (which control
first-order carryover). These are selected through the allocation policy rather than
assign_between.
In Studio
When a phase contains an assign_between step, Studio surfaces a between-subjects factor
banner in Trial Design with a per-factor method selector — Balanced quota (recommended),
Deterministic hash, or Random. Advanced balancing (targets, Latin-square / balanced
blocks, sequential effects) is configured through the counterbalance wizard reached from the
assignment inspector.
Counterbalancing UI appears after you add an assign_between step (or
open the wizard); there is no standalone "Counterbalancing" tab yet. If you author in code,
the SDK fields above are the source of truth.
Validation & checking allocation
psycloud validate <bundle-or-study>andpsycloud inspect <bundle-or-study> --section counterbalancecheck the policy structure (schema0.1.1): factor ids, non-empty levels, proportions, quotas.- On a live run, Monitor shows allocation status. Data exports carry an
assignment_hashper session, and the participant's cell shows up as the filtered factor values in the trial rows (afterassign_between) — there's no separate assigned-cell column, so confirm balance from those.
Known limits (alpha)
- Item rotation (rotating which items a participant sees, balanced across
the pool) is expressible in the policy and SDKs (
cb_item_rotation_*) but is not yet executed by the design pipeline — treat it as roadmap. - TypeScript authoring exposes the low-level policy builders but not the
high-level
cb_random/cb_rotationhelpers — author counterbalancing in Python or R (or construct the policy object directly). - Dropout rebalancing depends on the allocation path. Simple
conditions-based allocation counts live sessions, so a participant who abandons or whose lease expires frees their cell and the next arrival refills it. Thecb_*policy path (v0.1.1) keeps a persistent assigned-count that is not decremented when a participant drops out, so abandoned cells in a policy-based run are not auto-refilled. Over-recruit slightly and check balance in Monitor / Data.