Cross-language reproducibility¶
The point of the shared specification is that one design produces the same data in R and in Python. The two implementations never call each other. What they share is a numerical contract.
The shared generator¶
Native random-number generators differ across ecosystems (R uses Mersenne-Twister with
inversion, NumPy uses PCG64), so a naive port would diverge. pilotr instead ships one
generator implemented identically in both languages.
Uniforms come from L'Ecuyer's (1988) combined linear congruential generator. Every
intermediate product stays below 2**53, so the arithmetic is exact in IEEE-754 doubles
and in Python integers alike. Normals use Wichura's (1988) Algorithm AS 241 inverse-CDF, the
same routine R's qnorm uses, so deviates agree to full double precision. Everything else
(Cholesky-correlated random effects, inverse-CDF Poisson and ordinal draws, Marsaglia and
Tsang gamma draws for the Beta family) derives from those two through a documented, identical
draw order.
The draw order is specified on the Specification page.
Determinism in Python¶
The same specification and seed always give the same data.
from pilotr import simulate
spec = {
"name": "demo", "seed": 2024,
"units": {"subject": {"n": 200}},
"factors": [{"name": "group", "levels": ["a", "b"],
"contrasts": {"effect": [-0.5, 0.5]}, "between": "subject"}],
"fixed": {"intercept": 100, "coefficients": {"effect": 5}},
"response": {"family": "gaussian", "name": "score", "sigma": 10},
}
print(simulate(spec).rows == simulate(spec).rows)
True
Changing the seed changes the draws, while the structure and the ground-truth parameters stay fixed.
spec2 = dict(spec, seed=spec["seed"] + 1)
print(simulate(spec).column("score") == simulate(spec2).column("score"))
False
Verifying parity¶
Simulate the same spec and seed in both languages and compare. In Python:
from pilotr import simulate, load_spec
simulate(load_spec("design.json")).to_csv("py.csv")
In R:
library(pilotr)
write.csv(simulate_design(load_spec("design.json")), "r.csv", row.names = FALSE)
The two CSVs compare exactly, up to CSV number formatting. The repository's
python/examples/parity_check.py
runs this comparison across the worked example designs and reports the maximum difference:
zero for every design with rounded responses, and below 1e-14 for the unrounded continuous
design, where R's CSV writer prints 15 significant digits. At that precision the residual
cannot be told apart from the writer's own rounding, and the script's small tolerance (1e-6)
is there to absorb it. The stricter harness under tools/parity/ dumps 17 digits and resolves
what is really there: a handful of cells apart by a few units in the last place, from exp(),
whose rounding IEEE-754 does not fix. That harness pins every path built from IEEE-exact
arithmetic alone to a recorded hash, and deliberately leaves the exp() and log() cases
unpinned, since those last bits belong to the maths library and so are not the specification's
to fix.
solve_curve sits a layer above the generator, taking a curve that has already been simulated
and fitting a model to it, and it is held to a different standard on purpose. Its fit calls
exp and the normal distribution function at every iteration, so the two engines cannot be
asked to land on the same bits, and a recorded hash would pin whichever maths library happened
to record it. What they are asked for is agreement far closer than any reading of the result
could depend on. The script tools/parity/solve_cross.py puts the same fixed curves through
both engines and compares the solved value, its interval, the fitted coefficients and the text
of every refusal. The largest relative difference measured over those cases is 5.0e-15, against
an allowance of 1e-9. On a solved sample size of 220 subjects that allowance comes to two
ten-millionths of a subject. Every refusal message is identical character for character.
A simulated crossed design has one row per subject-by-item observation. The first rows of the worked crossed reaction-time example look like this (the same rows the R package produces):
from pilotr import simulate, load_spec, pilotr_example
data = simulate(load_spec(pilotr_example("crossed_mixed_rt")))
print(table(data.head(6)))
| subject | item | condition | RT |
|---|---|---|---|
| 1 | 1 | related | 669 |
| 1 | 1 | unrelated | 727 |
| 1 | 2 | related | 699 |
| 1 | 2 | unrelated | 503 |
| 1 | 3 | related | 661 |
| 1 | 3 | unrelated | 517 |