Real PISA data: access, memory and reproducibility
JoonHo Lee
2026-09-11
Source:vignettes/a5-real-pisa-guidance.Rmd
a5-real-pisa-guidance.RmdAbstract
This article describes what to do when you analyze real PISA files with pvstackr: obtaining the files under their terms, declaring the PISA 2022 design, writing the target and fit calls, planning for the memory and computing time they need, and recording what is needed to repeat the analysis. pvstackr ships no real PISA records, so the calls are shown without being run. The last section says what the companion preprint shows about PISA and what it does not.
pvstackr does not ship real PISA records. The only data in the
package are the synthetic pisa_tiny data (one made-up
country, CNT is "SYN", with \(M = 2\) plausible values and \(R = 4\) replicate weights) and an example
fit made from them. They exist so that the examples and the package
tests run without licensed files. pvstackr is not affiliated with or
endorsed by OECD/PISA. Its documentation uses the PISA column names,
such as PV1MATH, W_FSTUWT,
W_FSTURWT1 and CNTSTUID, because they are the
names in the files that users analyze (OECD
2024).
Each call below is written for a PISA 2022 file and is not run, since the package has no real data; The full analysis workflow runs the same steps on the synthetic data.
Data access and licensing
Obtaining the files
Obtain real PISA files only from an authorized source: the OECD’s official PISA website or a copy that your institution has approved. Before you use a file, read the license, citation, redistribution, and privacy terms of the cycle and of the files you downloaded. The terms differ between cycles and releases, and the terms that come with the files are the ones that apply.
The pvstackr license and your data
The MIT license for pvstackr does not license users’
real PISA data, the extracts derived from them or any other files
processed with the package. It covers the software only. Your data are
governed by the terms of their source, including its rules on
attribution, redistribution, third-party rights and privacy, and
following them is your responsibility.
Keeping the data out of your repository
Do not commit real PISA microdata, large extracts or the cache files of a modeling engine to a package or project repository under version control. Keep them in a folder outside the source tree and refer to the folder through an environment variable, so that its path does not appear in your code:
pisa_dir <- Sys.getenv("PISA_DATA_DIR")
stopifnot(nzchar(pisa_dir))
student_file <- file.path(pisa_dir, "student_file_for_your_cycle")Sys.getenv() returns an empty string when
PISA_DATA_DIR is not set, and stopifnot() then
stops the script with an error. You can set the variable in your
.Renviron file.
Reading the file
File names and formats differ between cycles and releases. Read your authorized file with the reader for its format, whether an SPSS, SAS, transport, or converted local extract, and keep only the columns that the model needs: the country, the identifiers, the covariates, the plausible values, the final weight and the replicate weights. Keeping only these columns reduces the memory that the later steps need (see memory and runtime).
# An example: use the reader for the format of your file, such as
# haven::read_sav() for SPSS, a reader for SAS or SAS transport files, or
# read.csv() for a converted CSV file. pvstackr has no PISA reader.
pisa_raw <- haven::read_sav(student_file)
pisa_country <- subset(pisa_raw, CNT == "USA")
pisa_country <- pisa_country[
,
c(
"CNT",
"CNTSCHID",
"CNTSTUID",
"ESCS",
"ST004D01T",
paste0("PV", 1:10, "MATH"), # M = 10 plausible values in mathematics
"W_FSTUWT", # final student weight
paste0("W_FSTURWT", 1:80) # R = 80 replicate weights
)
]The extract keeps the country code CNT, the school and
student IDs CNTSCHID and CNTSTUID, two
covariates (ESCS, the PISA index of economic, social and
cultural status, and ST004D01T, the student’s gender (OECD 2024, ch. 19)), the ten plausible values
in mathematics, the final weight W_FSTUWT and the 80
replicate weights.
Declaring the design
pv_design() finds and checks the plausible-value and
weight columns before anything is computed, as described in The full analysis
workflow. On a real file, check each argument that describes the
design against the technical documentation of the cycle: the
plausible-value suffix pv_suffix, the number of plausible
values expected_M, the number of replicate weights
expected_R, the Fay coefficient fay_k and the
identifier columns id_cols. In PISA 2022, \(M = 10\), \(R =
80\) and \(k = 0.5\) (OECD 2024). pv_design() stops with
an error when the number of plausible-value or replicate-weight columns
it finds differs from expected_M or
expected_R, for example because the suffix is wrong. It
does not check fay_k against the replicate weights, so take
that value from the documentation.
design <- pv_design(
data = pisa_country,
formula = OUTCOME ~ ESCS + ST004D01T, # OUTCOME stands for each plausible value
pv_suffix = "MATH", # finds PV1MATH to PV10MATH
expected_M = 10L, # stop unless 10 plausible values are found
expected_R = 80L, # stop unless 80 replicate weights are found
fay_k = 0.5, # the Fay coefficient of PISA 2022
id_cols = "CNTSTUID" # the column that identifies each row
)
designThe formula has the placeholder OUTCOME on the left-hand
side, which pvstackr replaces by each plausible value in turn,
PV1MATH to PV10MATH.
pv_suffix = "MATH" selects the mathematics values, since
PISA 2022 names the plausible values by subject. The default,
pv_suffix = "", matches only bare names such as
PV1; on a PISA 2022 file it finds no plausible-value
columns and stops with an error. The survey weights stay out of the
formula; the defaults of pv_design() find the final weight
W_FSTUWT and the replicate weights W_FSTURWT1
to W_FSTURWT80 by their PISA names.
pv_design() checks only the form of the formula. The
covariates are checked by pv_brr_target(), which stops with
an error when one of them has a missing value, is a character column
(make it a factor) or has a class that pvstackr does not accept.
haven::read_sav() returns the columns that carry SPSS value
labels with the class haven_labelled, and such a covariate
stops pv_brr_target() with the message “A formula predictor
has an unsupported type or class”. Since the target is computed from
design$data, prepare the extract before you call
pv_design(): remove the incomplete rows, for example with
complete.cases(), and convert the labeled columns to plain
numbers, for example with haven::zap_labels().
Computing the target
pv_brr_target() computes the design-based target from
the columns in the design. For each plausible value it fits the
regression by weighted least squares, once with the final weight and
once with each replicate weight, and computes the BRR–Fay replicate
covariance of the coefficients from the spread of the replicate
estimates. Rubin’s rules then combine the \(M\) results into the estimates \(\bar\beta\) and their covariance matrix
\(T_{\text{MI}} = \bar U + (1 +
1/M)\,B\), where \(\bar U\) is
the average of the \(M\) replicate
covariances and \(B\) is the covariance
matrix of the \(M\) estimates (Rubin 1987). The full analysis workflow gives
the formulas. The target is fixed-effect-only:
pv_brr_target() stops with an error when the formula
contains a random-effect term such as (1 | CNTSCHID), and
so does a stack_direct fit.
target <- pv_brr_target(
data = design$data,
formula = design$formula,
pv_cols = design$pv_cols,
weight_col = design$weight_col,
rep_weight_cols = design$rep_weight_cols,
fay_k = design$fay_k,
id_cols = design$id_cols
)
targetThe call uses the default rule for the degrees of freedom,
df_method = "classic", so the intervals of a fit calibrated
to this target are descriptive
(coverage_claim_allowed = FALSE). By pvstackr’s reporting
rule, only a target made with df_method = "barnard_rubin"
and a positive df_complete gives stack_direct
intervals that can be read as confidence intervals with nominal
coverage. Reading and
reporting the results explains the rule and
df_complete.
Fitting the model
pv_fit() with method = "stack_direct" fits
one Bayesian model to the stacked data, which hold one copy of the data
for each plausible value, and calibrates its fixed-effect draws to the
target with the Cholesky calibration correction (CCC). The fit needs an
engine: the bundled one, selected with
pv_control(backend = "brms"), or three functions of your
own, fit_function, draws_function and
diagnose_function. The chunk below uses functions of your
own. With them, leave backend at its default: pvstackr
passes the value to your fit_function and records it in the
fit, but the value selects nothing. The full analysis workflow
describes what the three functions receive and must return; without
diagnose_function the fit is blocked.
fit <- pv_fit(
data = design$data,
formula = design$formula,
target = target,
method = "stack_direct",
control = pv_control(
method = "stack_direct",
center = "target", # the default; stack_direct requires it
iter = 2000L,
warmup = 1000L,
chains = 4L,
cores = 4L,
seed = 20260607,
return_draws = FALSE,
keep_data = FALSE,
keep_backend_fit = FALSE,
keep_log_lik = FALSE
),
fit_function = your_fit_function, # fits the stacked model
draws_function = your_draws_function, # returns the draws of that fit
diagnose_function = your_diagnose_function, # returns its sampler diagnostics
cache_dir = NULL # no cache file for your function
)
summary(fit)
get_estimates(fit)To use the bundled engine instead, leave out the three functions and
set backend = "brms" in pv_control(). It fits
the stacked model with the brms function brm() and needs
the brms and posterior packages. It saves the brms fit, which contains
the stacked outcome and weights, as a cache file in the folder
cache_dir (by default cache in the working
directory), creating the folder if needed. On real data, set
cache_dir to a folder outside your repository, for example
under PISA_DATA_DIR. brms reuses a cached fit when only
chains, iter, warmup or
seed change, so give the cache file a new name with
cache_stem, or set cache_dir = NULL, when you
change them (see ?pv_fit_direct).
Reading the result
get_estimates(fit) returns the estimate table, which you
read as in Reading and
reporting the results. With control$center = "target",
the default, which stack_direct requires, its numbers are
copied from the target (The
full analysis workflow). The other value,
center = "posterior", is accepted by
pv_control(), but no fitting function uses it:
pv_fit() stops with an error when a
stack_direct fit sets it. The diagnostics include the
center separation, which shows how far the mean of the stacked draws was
from the target before the calibration (Reading and
reporting the results).
Memory and runtime
Start with one country, one domain and a small formula, and keep only the columns that the analysis needs (see data access and licensing). Two counts then tell you the size of a job before you start it: the amount of data and the number of fits.
The stacked data hold one copy of the data for each plausible value,
so a design with N rows and M plausible values
gives N * M stacked rows. Each copy keeps every column of
the data, not only those in the formula, so a column that you drop from
the extract is not repeated M times. Row \(i\) of each copy has the weight \(\tilde w_i/M\), where \(\tilde w_i\) is the student’s final weight
divided by the mean final weight (The full analysis workflow
explains why). With \(M = 10\), as in
PISA 2022, the engine fits the model to ten times as many rows as the
extract has.
pv_brr_target() fits the regression by weighted least
squares once with the final weight and once with each of the
R replicate weights, for each of the M
plausible values: M * (R + 1) fits. For PISA 2022 (\(M = 10\), \(R =
80\)) that is 10 * 81 = 810 weighted least-squares
fits, before any Bayesian sampling. Each is an ordinary weighted
regression, but on a large national extract it is worth trying the call
on a smaller part of the data first.
The Bayesian fit usually needs the most memory and time. These settings help:
- Set
seedand record every setting ofpv_control()(see the reproducibility checklist), so that the fit can be repeated. - Keep the engine’s cache files outside the repository, with the data (see data access and licensing).
- Keep the defaults
keep_data = FALSE,keep_backend_fit = FALSEandkeep_log_lik = FALSEunless you need the data, the engine’s fit object or the log-likelihood draws in the saved fit; each makes the saved fit larger. - Set
return_draws = FALSEwhen you do not need the calibrated draws: the estimate table comes from the target and does not use them (Reading and reporting the results). - Increase
chains,iterandcoresonly once the design and the target are final, that is, when their checksumsdesign_hashandtarget_hashno longer change from one run to the next; a long run on inputs that still change has to be repeated.
Reproducibility checklist
Record these items with each analysis of real data, so that you and your readers can repeat it and see which inputs produced which numbers:
- the PISA cycle, the release date and the authorized source of the files;
- the filters applied to the extract, such as the country and the domain;
- the model variables and the plausible-value suffix
pv_suffix; - the final weight, the prefix and number of the replicate weights,
and the Fay coefficient
fay_k; - the pvstackr version,
utils::packageVersion("pvstackr"), and the R session, printed bysessionInfo(); - the checksums of the design,
design_hash,row_support_hash,pv_value_hashandweight_design_hash, and of the target,target_hash; - the engine (
backend), the seed, andchains,iter,warmupandcores; - what the fit keeps:
return_draws,keep_backend_fit,keep_dataandkeep_log_lik.
The design checksums do not cover the values of the covariates.
target_hash is a checksum of the contents of the target,
including its estimates, so a change in the covariates changes it as
well; it is also a column of the estimate table (Reading and reporting the
results). The chunk below collects the records in one list from the
objects made above:
# A sketch: one list with the records above, taken from the objects made in
# this article. Rename the fields to suit your project.
analysis_record <- list(
pisa_cycle = "2022", # record the release date too
data_source = "OECD PISA portal", # the authorized source
filters = "CNT == 'USA'; domain = MATH",
variables = all.vars(design$formula),
pv_suffix = "MATH",
weight_col = design$weight_col,
rep_weight_prefix = "W_FSTURWT",
R = length(design$rep_weight_cols),
fay_k = design$fay_k,
pkg_version = as.character(utils::packageVersion("pvstackr")),
design_hash = design$design_hash,
row_support_hash = design$row_support_hash,
pv_value_hash = design$pv_value_hash,
weight_design_hash = design$weight_design_hash,
target_hash = target$target_hash,
backend = "none", # "brms" with the bundled engine
seed = 20260607,
chains = 4L,
iter = 2000L,
warmup = 1000L,
cores = 4L,
return_draws = FALSE,
keep_backend_fit = FALSE,
keep_data = FALSE,
keep_log_lik = FALSE
)
str(analysis_record)sessionInfo() prints the versions of R, of the operating
system and of the loaded packages. Here it runs and shows the session
that built this article, which does not attach pvstackr; in your
analysis, call it after library(pvstackr) so that the
pvstackr version is recorded:
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.5 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=C.UTF-8 LC_NUMERIC=C LC_TIME=C.UTF-8
#> [4] LC_COLLATE=C.UTF-8 LC_MONETARY=C.UTF-8 LC_MESSAGES=C.UTF-8
#> [7] LC_PAPER=C.UTF-8 LC_NAME=C LC_ADDRESS=C
#> [10] LC_TELEPHONE=C LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> loaded via a namespace (and not attached):
#> [1] digest_0.6.39 desc_1.4.3 R6_2.6.1 fastmap_1.2.0
#> [5] xfun_0.60 cachem_1.1.0 knitr_1.52 htmltools_0.5.9
#> [9] rmarkdown_2.32 lifecycle_1.0.5 cli_3.6.6 sass_0.4.10
#> [13] pkgdown_2.2.1 textshaping_1.0.5 jquerylib_0.1.4 systemfonts_1.3.2
#> [17] compiler_4.6.1 tools_4.6.1 ragg_1.5.2 bslib_0.12.0
#> [21] evaluate_1.0.5 yaml_2.3.12 otel_0.2.0 jsonlite_2.0.0
#> [25] rlang_1.3.0 fs_2.1.0The companion preprint
The stacked fit and the calibration that pvstackr uses are described in a companion preprint (Lee et al. 2026). The preprint gives the conditions under which the fixed-effect estimate of one stacked fit equals the average of the estimates for the separate plausible values: the stacked fixed-effect point identity, Theorem 4.1 of the preprint, which One stacked fit and the Rubin point estimate explains. It also shows that CCC gives the calibrated draws exactly the mean and covariance of the target (Proposition B.1 of the preprint), as described in Calibrating the fixed-effect draws (CCC). The preprint builds its default target from model-based fits of each plausible value and describes a target from replicate weights, the only kind that pvstackr computes, as an alternative (The design-based target: BRR–Fay replicate weights and Rubin’s rules describes pvstackr’s target).
The preprint’s application to PISA 2022 reading, in the United States and Korea, checks whether one MCMC run, together with the target and one calibration, reproduces the results of ten MCMC runs, one for each plausible value (Lee et al. 2026, sec. 6). The preprint presents its PISA values as context rather than as findings, and three points matter when you read them:
- In the application, the stacked fit is calibrated to the combined
output of the ten fits, so it has their estimates and standard errors by
construction, as a
stack_directfit has those of its target. The preprint calls this agreement target-calibrated by design and does not offer it as independent evidence. - The application fits a model with a random intercept for schools and
takes its target from the Bayesian fits of that model to each plausible
value. pvstackr 0.2.x cannot repeat this analysis: its target uses the
BRR–Fay replicate weights, and
pv_brr_target()andstack_directstop with an error on a random-effect term. - In the preprint, the per-PV workflow, one fit for each plausible
value combined with Rubin’s rules, is the reference that defines the
target. The package’s
per_pvmethod combines posterior draws from fits that you supply, and its intervals are always descriptive, because the variance within each plausible value comes from the model’s posterior draws (model-based), not from the replicate weights.
The simulation of the preprint compares the per-PV workflows and the calibrated stack with the generating values; their variances and targets come from model-based fits, and the simulated data have no replicate weights (Lee et al. 2026, sec. 5). It therefore does not test the BRR–Fay target that pvstackr computes, and pvstackr’s reporting rule for intervals is the package’s own rule, not a result of the preprint.
In a qualified secondary analysis (Appendix F.4 of the preprint), the
difference between Korea and the United States in the between-school
gradient of ESCS shrinks by more than half and loses
nominal significance when the survey weights are placed in two stages
rather than at the student level, but it keeps its sign. The preprint
draws no conclusion about the two countries from it.
Comparing the three fitting methods gives the cautions for comparing pvstackr’s methods on your own data, including the preprint’s PISA result for the Pareto \(\hat k\) check.