pss: Propensity Score Sampling

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).

library(pss)

Example data

The example data are …

Estimating propensity scores (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():

  1. Do you want to use the covariates’ joint distribution, marginal distributions, or some combination of sub-joint distributions (chosen via the argument cov_dist)?
  2. How do you want to specify \(p(Z=1)\) (chosen via the argument pz1_fn)?

Covariate distributions

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.

Specifying \(p(Z=1)\)

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_pz1
  • pz1_fn = pz1_estimate(n_pz1) allows the user to specify an estimate of \(p(Z=1)\) directly, ie. \(p(Z=1) =\)n_pz1
  • pz1_fn = pz1_expectedN(n_pz1) will set \(p(Z=1)\) such that the expected size of the matched comparison group will be n_pz1

Examples

We 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.

Generating a collection of candidate matched samples (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 data
  • propscores: a propensity score data frame (output by propscores())
  • seeds: a vector of random seeds

There 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).

Examples

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.2425481

Creating the final matched sample (matched_sample)

Oughton, Rachel H., Nicole Westmarland, Alishya Dixon, and Rosie Erol. 2026. “Propensity Score Sampling to Create a Matched Comparison Group from an External Eligible Untreated Cohort, with Application to a Forensic Marking Intervention.” TBC.