This vignette provides a
comprehensive overview of the propensity score sampling workflow using
the pss package. The methodology behind the approach is
only described where it is relevant to the use of the code; for full
details see Oughton et al. (2026).
The example data are …
propscores())In propensity score sampling, the propensity scores are estimated
from the dataset using the frequencies of cases for each combination of
covariate values. For this reason, pss currently only works
for categorical variables, though continuous variables can be
incorporated by binning them into ordinal variables.
The propensity score is the probability of some eligible participant (or study unit, eg. neighbourhood) being given the intervention, if it is generally available (\(Z=1\)), given their characteristics (\(\mathbf{x}\)). We factorise the propensity score using Bayes Theorem
\[ p\left(Z=1 \mid \mathbf{x}\right) = \frac{p\left(\mathbf{x}\mid Z=1\right)p(Z=1)}{p(\mathbf{x})}.\]
The function propscores() uses the data provided via the
argument df to create a data frame containing the
propensity score for each combination of covariate values
There are two main choices to make when using
propscores():
cov_dist)?pz1_fn)?If cov_dist = "joint" then the joint density is used to
estimate \(p(\mathbf{x}|Z=1)\) and
\(p(\mathbf{x})\). That is, the number
of cases is found for every combination of values of the covariates
given in cov_cols for both trial arms. This is the most
accurate approach, but may perform poorly if the number of cases is
small relative to the number of covariates.
If cov_dist = "marginal" then \(p(\mathbf{x}|Z=1)\) and \(p(\mathbf{x})\) are estimated using the
marginal approximation
\[\begin{align*} p\left(\mathbf{x}\mid Z=1\right) & = \prod\limits_{j=1}^k p\left(x^{(j)}\mid Z=1\right) \\ p\left(\mathbf{x}\right) & = \prod\limits_{j=1}^k p\left(x^{(j)}\right). \end{align*}\]
This is a strong assumption, since there will likely be some dependence between covariates, but it is more robust to empty combinations.
Finally, one can specify sub-groups of covariates to be treated
jointly by entering a list to cov_dist. For example, if we
set
cov_cols = c("category", "risk_ass", "sus_age_bin", "nprev_sus_bin")
and
cov_dist = list(c("category", "risk_ass"))
then proportions will be found for all combinations of
category and risk_ass (treating these two
covariates jointly) for each arm. Proportions will also be found for
each value of sus_age_bin and nprev_sus_bin.
These proportions will be multiplied together to estimate \(p(\mathbf{x}|Z=1)\) and \(p(\mathbf{x})\). This approach gives a
middle ground between the fully marginal and fully joint approaches.
Unlike \(p(\mathbf{x}|Z=1)\) and
\(p(\mathbf{x})\), \(p(Z=1)\) cannot be estimated from the data
in df, but it does need to be specified to estimate the
propensity scores. The function propscores() provides three
approaches, specified via pz1_fn and its argument
n_pz1. These are described in more detail in [the paper]
but in summary
pz1_fn = pz1_rescale(n_pz1) will set \(p(Z=1)\) such that the largest propensity
score (unless any are infinite) is n_pz1pz1_fn = pz1_estimate(n_pz1) allows the user to specify
an estimate of \(p(Z=1)\) directly, ie.
\(p(Z=1) =\)n_pz1pz1_fn = pz1_expectedN(n_pz1) will set \(p(Z=1)\) such that the expected size of the
matched comparison group will be n_pz1We will first estimate propensity scores using the fully joint approach and an estimate of \(p(Z=1) = 0.1\):
ps_joint_est025 = propscores(
df = eg_data,
cov_cols = c("sus_age_bin", "category", "risk_ass"),
arm_col = "Arm",
intervention_level = "Intervention",
cov_dist = "joint",
pz1_fn = pz1_estimate(0.25)
)
#> There are 1 NA values in the sus_age_bin column. These rows will be lost.
#> There are 1 NA values in the category column. These rows will be lost.Notice that this warns us that there are NA values in two of the columns, leading to rows being lost.
Each of these has columns for every element of cov_cols,
and columns px_int, px_comp and
PropScore. They will also have other columns, depending on
which methods were used.
Below we display the data frame ps_joint_est025. Notice
that in one row (HIGH, Theft_CD,
40+) the propensity score is Inf. This is
because there are some such cases in the intervention group, but none in
the comparison group, and so although the match sampling process
requires some comparison cases, there are none to be sampled. There are
also some rows (MEDIUM, Theft / CD,
Under 29 and MEDIUM, Theft / CD,
33-39) for which there are no cases in the intervention
cohort, and so no comparison cases will be sampled (and the propensity
score is zero).
ps_joint_est025 |>
dplyr::select(c("risk_ass", "category", "sus_age_bin", "px_int", "px_comp", "count_comp", "PropScore")) |>
# dplyr::select(c("risk_ass", "category", "sus_age_bin", "count_int", "count_comp", "PropScore")) |>
knitr::kable(digits = 3)| risk_ass | category | sus_age_bin | px_int | px_comp | count_comp | PropScore |
|---|---|---|---|---|---|---|
| MEDIUM | Course of conduct | Under 29 | 0.035 | 0.092 | 63 | 0.095 |
| MEDIUM | Course of conduct | 29-32 | 0.054 | 0.056 | 38 | 0.243 |
| MEDIUM | Course of conduct | 33-39 | 0.067 | 0.095 | 65 | 0.176 |
| MEDIUM | Course of conduct | 40+ | 0.076 | 0.124 | 85 | 0.154 |
| MEDIUM | Theft / CD | Under 29 | 0.000 | 0.013 | 9 | 0.000 |
| MEDIUM | Theft / CD | 29-32 | 0.000 | 0.013 | 9 | 0.000 |
| MEDIUM | Theft / CD | 33-39 | 0.000 | 0.015 | 10 | 0.000 |
| MEDIUM | Theft / CD | 40+ | 0.003 | 0.020 | 14 | 0.039 |
| MEDIUM | Violent/Sexual | Under 29 | 0.013 | 0.064 | 44 | 0.049 |
| MEDIUM | Violent/Sexual | 29-32 | 0.022 | 0.029 | 20 | 0.190 |
| MEDIUM | Violent/Sexual | 33-39 | 0.019 | 0.059 | 40 | 0.082 |
| MEDIUM | Violent/Sexual | 40+ | 0.006 | 0.104 | 71 | 0.015 |
| HIGH | Course of conduct | Under 29 | 0.111 | 0.042 | 29 | 0.656 |
| HIGH | Course of conduct | 29-32 | 0.048 | 0.028 | 19 | 0.429 |
| HIGH | Course of conduct | 33-39 | 0.127 | 0.053 | 36 | 0.604 |
| HIGH | Course of conduct | 40+ | 0.092 | 0.035 | 24 | 0.657 |
| HIGH | Theft / CD | Under 29 | 0.010 | 0.004 | 3 | 0.544 |
| HIGH | Theft / CD | 29-32 | 0.006 | 0.003 | 2 | 0.544 |
| HIGH | Theft / CD | 33-39 | 0.003 | 0.007 | 5 | 0.109 |
| HIGH | Theft / CD | 40+ | 0.003 | 0.000 | 0 | Inf |
| HIGH | Violent/Sexual | Under 29 | 0.076 | 0.028 | 19 | 0.687 |
| HIGH | Violent/Sexual | 29-32 | 0.067 | 0.026 | 18 | 0.634 |
| HIGH | Violent/Sexual | 33-39 | 0.076 | 0.037 | 25 | 0.522 |
| HIGH | Violent/Sexual | 40+ | 0.083 | 0.051 | 35 | 0.404 |
To avoid the arbitrary results of small numbers, but preserve a
potential link between category and risk_ass,
we might choose the partially marginal approach instead (for
demonstration purposes we also switch to specifying \(p(z=1)\) so that the expected size of the
comparison group is 250),
ps_mix_exp250 = propscores(
df = eg_data,
cov_cols = c("sus_age_bin", "category", "risk_ass"),
arm_col = "Arm",
intervention_level = "Intervention",
cov_dist = list(c("risk_ass", "category")),
pz1_fn = pz1_expectedN(250)
)
#> There are 1 NA values in the sus_age_bin column. These rows will be lost.
#> There are 1 NA values in the category column. These rows will be lost.This has no infinite propensity scores
ps_mix_exp250 |>
dplyr::select(c("risk_ass", "category", "sus_age_bin", "px_int", "px_comp", "PropScore")) |>
# dplyr::select(c("risk_ass", "category", "sus_age_bin", "count_int", "count_comp", "PropScore")) |>
knitr::kable(digits = 3)| risk_ass | category | sus_age_bin | px_int | px_comp | PropScore |
|---|---|---|---|---|---|
| MEDIUM | Course of conduct | Under 29 | 0.057 | 0.090 | 0.229 |
| MEDIUM | Course of conduct | 29-32 | 0.046 | 0.057 | 0.290 |
| MEDIUM | Course of conduct | 33-39 | 0.068 | 0.097 | 0.252 |
| MEDIUM | Course of conduct | 40+ | 0.061 | 0.123 | 0.180 |
| MEDIUM | Theft / CD | Under 29 | 0.001 | 0.015 | 0.019 |
| MEDIUM | Theft / CD | 29-32 | 0.001 | 0.010 | 0.024 |
| MEDIUM | Theft / CD | 33-39 | 0.001 | 0.016 | 0.021 |
| MEDIUM | Theft / CD | 40+ | 0.001 | 0.021 | 0.015 |
| MEDIUM | Violent/Sexual | Under 29 | 0.015 | 0.063 | 0.085 |
| MEDIUM | Violent/Sexual | 29-32 | 0.012 | 0.040 | 0.108 |
| MEDIUM | Violent/Sexual | 33-39 | 0.018 | 0.068 | 0.094 |
| MEDIUM | Violent/Sexual | 40+ | 0.016 | 0.086 | 0.067 |
| HIGH | Course of conduct | Under 29 | 0.093 | 0.039 | 0.867 |
| HIGH | Course of conduct | 29-32 | 0.075 | 0.025 | 1.100 |
| HIGH | Course of conduct | 33-39 | 0.111 | 0.042 | 0.956 |
| HIGH | Course of conduct | 40+ | 0.100 | 0.053 | 0.682 |
| HIGH | Theft / CD | Under 29 | 0.005 | 0.004 | 0.551 |
| HIGH | Theft / CD | 29-32 | 0.004 | 0.002 | 0.699 |
| HIGH | Theft / CD | 33-39 | 0.007 | 0.004 | 0.607 |
| HIGH | Theft / CD | 40+ | 0.006 | 0.005 | 0.433 |
| HIGH | Violent/Sexual | Under 29 | 0.074 | 0.035 | 0.771 |
| HIGH | Violent/Sexual | 29-32 | 0.060 | 0.022 | 0.978 |
| HIGH | Violent/Sexual | 33-39 | 0.089 | 0.038 | 0.850 |
| HIGH | Violent/Sexual | 40+ | 0.080 | 0.048 | 0.606 |
An alternative would be to adopt the fully marginal approach (changing the method for \(p(Z=1)\) again for demonstration purposes, now fixing \(p(Z=1)\) so that the largest propensity score is 2)
ps_marg_resc2 = propscores(
df = eg_data,
cov_cols = c("risk_ass", "category", "sus_age_bin", "nprev_sus_bin"),
arm_col = "Arm",
intervention_level = "Intervention",
cov_dist = "marginal",
pz1_fn = pz1_rescale(2)
)
#> There are 1 NA values in the category column. These rows will be lost.
#> There are 1 NA values in the sus_age_bin column. These rows will be lost.candidates)Because propensity score sampling uses random sampling, we can generate many candidate matched samples and choose the best. This is not a problem for inference unless we involve the outcome values in the process.
The main arguments to candidates() are
df: a data frame of study datapropscores: a propensity score data frame (output by
propscores())seeds: a vector of random seedsThere are also arguments to specify details of diagnostic summaries
(smd_cols, smd_order) and to pass on to
matched_sample(), which creates the candidate matched
sample for each seed (replace, downsample,
drop_int).
candidates(
df = eg_data,
propscores = ps_joint_est025,
seeds = 1:10,
smd_cols = c("risk_ass", "category", "sus_age_bin"),
smd_order = 2
)
#> seed n_int n_comp SMD_risk_ass SMD_category SMD_sus_age_bin
#> 1 1 314 168 0.23308149 0.02636891 0.11576866
#> 2 2 314 171 0.04256640 0.05493603 0.07528036
#> 3 3 314 170 0.03389044 0.06374867 0.03572941
#> 4 4 314 185 0.02598238 0.04409182 0.09550639
#> 5 5 314 178 0.08440418 0.11249425 0.06965982
#> 6 6 314 172 0.03861228 0.12868680 0.07227845
#> 7 7 314 162 0.09666017 0.09146735 0.07851967
#> 8 8 314 172 0.02605045 0.02221172 0.09039220
#> 9 9 314 166 0.01098257 0.09910340 0.11214305
#> 10 10 314 162 0.05372981 0.15983827 0.06603256
#> SMD_category.risk_ass SMD_risk_ass.sus_age_bin SMD_category.sus_age_bin
#> 1 0.28132402 0.2832541 0.1828508
#> 2 0.10018936 0.1447606 0.2225351
#> 3 0.15439603 0.1408354 0.1618457
#> 4 0.11952593 0.1372929 0.1725952
#> 5 0.19985826 0.1698178 0.2036004
#> 6 0.16773534 0.1146762 0.1860480
#> 7 0.19176077 0.1342730 0.2682980
#> 8 0.05807104 0.1154419 0.1484787
#> 9 0.14409534 0.1461870 0.2080970
#> 10 0.16829690 0.2032046 0.2425481matched_sample)