knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 4.5, warning = FALSE, message = FALSE )
FPScausal implements the Functional Propensity Score (FPS) weighting methodology for causal inference with functional treatments (Ciardulli, S. and Fontana, N., 2026).
The core idea is to represent a functional treatment $X(s)$ through its Functional Principal Component (FPC) scores $\mathbf{A} \in \mathbb{R}^L$, and then to estimate covariate-balancing weights ${w_i}$ by maximising the empirical likelihood subject to the balancing constraints $$\frac{1}{n}\sum_{i=1}^n w_i \mathbf{g}i = \mathbf{0}, \quad \sum{i=1}^n w_i = 1,$$ where $\mathbf{g}i = [\mathbf{A}_i^\top, \mathbf{C}_i^\top, \mathrm{vec}(\mathbf{A}_i\mathbf{C}_i^\top)^\top]^\top$ stacks the balancing moments for unit $i$. Here $\mathbf{C}_i$ denotes the vector of confounders observed for unit $i$ (e.g.\ demographic variables or baseline measurements). The dual of this empirical-likelihood problem reduces to the smooth, unconstrained minimisation $$\min{\boldsymbol\theta} \log!\Bigl(\sum_{i=1}^n e^{-\boldsymbol\theta^\top \mathbf{g}_i}\Bigr),$$ solved via the BFGS quasi-Newton algorithm. The weights are recovered as the softmax transformation $w_i = e^{-\boldsymbol\theta^{\top}\mathbf{g}_i}/ \sum_j e^{-\boldsymbol\theta^{\top}\mathbf{g}_j}$.
Once the weights are obtained, the causal estimand is
estimated via weighted least squares.
This vignette walks through the full workflow on simulated data for two outcome types.
library(FPScausal)
simulate_fps_data() implements the data-generating process from the
simulation study in the paper. The four settings ("LL", "LN", "NL", "NN")
control whether the treatment-confounder and the confounder-outcome
relationships are Linear or Nonlinear:
| Setting | Treatment–Confounder | Confounder–Outcome | |---------|---------------------|--------------------| | LL | Linear | Linear | | LN | Linear | Nonlinear | | NL | Nonlinear | Linear | | NN | Nonlinear | Nonlinear |
The treatment $X(s)$ is built from six Fourier eigenfunctions; the scalar confounders $\mathbf{C}$ are 3-dimensional; one functional covariate $D(s)$ (4 Fourier components) is optionally included.
We simulate $n = 200$ subjects under setting "LL" with scalar covariates only (no functional covariate) and a scalar continuous outcome.
set.seed(42) dat <- simulate_fps_data( n = 200, setting = "LL", outcome_type = "scalar", include_functional_cov = FALSE, seed = 42 ) cat("Treatment X:", nrow(dat$X), "x", ncol(dat$X), "\n") cat("Outcome Y: length", length(dat$Y), "\n") cat("Scalar C: ", nrow(dat$C), "x", ncol(dat$C), "\n")
The true causal effect function is:
$$\mu(s) = 2\sqrt{2}\sin(2\pi s) + \sqrt{2}\cos(2\pi s) + \tfrac{\sqrt{2}}{2}\sin(4\pi s) + \tfrac{\sqrt{2}}{2}\cos(4\pi s)$$
plot(dat$t_grid, dat$true_beta, type = "l", lwd = 2, col = "black", xlab = "s", ylab = expression(mu(s)), main = "True causal effect") abline(h = 0, lty = 2, col = "grey")
The treatment domain treat_domain is inferred automatically from
treat_grid when omitted:
w_obj <- fps_weighting( treatment = dat$X, treat_grid = dat$t_grid, domain_name = "s", pve = 0.95, covariates = dat$C ) print(w_obj)
Weight distribution:
plot(w_obj, type = "weights")
Treatment FPCA: scree and eigenfunctions:
plot(w_obj, type = "fpca_treatment")
Covariate balance: absolute Pearson correlations before (red) and after (blue) weighting. Dashed line at 0.1:
plot(w_obj, type = "balance")
eff <- fps_effect_estimation( outcome = dat$Y, fps_object = w_obj, true_beta = dat$true_beta ) print(eff)
Weighted vs unweighted comparison (with analytical CI):
plot(eff, type = "comparison")
eff_boot <- fps_effect_estimation( outcome = dat$Y, fps_object = w_obj, bootstrap = TRUE, B = 200, alpha = 0.05, true_beta = dat$true_beta, seed = 123 )
Effect with 95% bootstrap CI:
plot(eff_boot, type = "effect")
Significant time points (CI excludes 0):
plot(eff_boot, type = "significance")
When the outcome is binary (0/1), fps_effect_estimation automatically
detects it and fits a linear probability model (weighted least squares),
returning the average treatment effect on the probability scale.
Y_bin <- as.integer(dat$Y > median(dat$Y)) eff_bin <- fps_effect_estimation(Y_bin, w_obj) print(eff_bin)
Weighted vs unweighted comparison (with analytical CI):
plot(eff_bin, type = "comparison")
When a functional covariate $D(s)$ is available, it enters the balancing step through its own FPC scores. We use $n = 2000$ to ensure a stable weight solution (the constraint dimension grows with the number of FPCs).
dat_fc <- simulate_fps_data( n = 2000, setting = "LL", outcome_type = "scalar", include_functional_cov = TRUE, seed = 7 )
w_fc <- fps_weighting( treatment = dat_fc$X, treat_grid = dat_fc$t_grid, domain_name = "s", pve = 0.95, covariates = list( scalar = dat_fc$C, functional = list(dat_fc$D) ), cov_grids = list(dat_fc$t_grid), cov_pve = 0.95 ) print(w_fc)
The FPC scores of the functional covariate are automatically named
Func_Cov1_FPC1, Func_Cov1_FPC2, ... in the balance plot:
plot(w_fc, type = "balance")
eff_fc <- fps_effect_estimation( outcome = dat_fc$Y, fps_object = w_fc, true_beta = dat_fc$true_beta ) plot(eff_fc, type = "comparison")
The table below shows the Integrated Squared Error (ISE) and Integrated Squared Bias (ISB) of the weighted vs unweighted estimate across settings.
settings <- c("LL", "LN", "NL", "NN") results_tbl <- lapply(settings, function(s) { d <- simulate_fps_data(200, setting = s, outcome_type = "scalar", include_functional_cov = FALSE, seed = 1) w <- fps_weighting(d$X, treat_grid = d$t_grid, covariates = d$C) eff <- fps_effect_estimation(d$Y, w, true_beta = d$true_beta) data.frame( Setting = s, ISE_weighted = round(mean((eff$beta - d$true_beta)^2), 4), ISE_unweighted = round(mean((eff$beta_unweighted - d$true_beta)^2), 4), ISB_weighted = round(mean(eff$beta - d$true_beta)^2, 6), ISB_unweighted = round(mean(eff$beta_unweighted - d$true_beta)^2, 6) ) }) knitr::kable( do.call(rbind, results_tbl), caption = "ISE and ISB for weighted vs unweighted estimate across settings" )
Now we simulate with a functional outcome $Y(t)$, so the causal estimand is the bivariate effect surface $\mu(s,t)$. We use $n = 200$ with scalar covariates only for this illustration.
dat_fn <- simulate_fps_data( n = 200, setting = "LL", outcome_type = "functional", include_functional_cov = FALSE, seed = 99 ) cat("Treatment X:", nrow(dat_fn$X), "x", ncol(dat_fn$X), "\n") cat("Outcome Y: ", nrow(dat_fn$Y), "x", ncol(dat_fn$Y), "\n")
The true surface is:
$$\mu(s,t) = 2\sqrt{2}\sin(2\pi s)\cos(2\pi t) + 2\sqrt{2}\sin(2\pi t)\cos(2\pi s) + \sqrt{2}\cos(4\pi t)\sin(4\pi s) + \sqrt{2}\cos(4\pi s)\sin(4\pi t)$$
image(dat_fn$t_grid, dat_fn$t_grid, dat_fn$true_beta, xlab = "s (treatment)", ylab = "t (outcome)", main = expression(paste("True ", mu, "(s,t)")), col = hcl.colors(50, "Blue-Red 3"))
w_fn <- fps_weighting( treatment = dat_fn$X, treat_grid = dat_fn$t_grid, treat_domain = c(0, 1), domain_name = "s", pve = 0.95, covariates = dat_fn$C ) print(w_fn)
plot(w_fn, type = "balance")
eff_fn <- fps_effect_estimation( outcome = dat_fn$Y, fps_object = w_fn, outcome_t_grid = dat_fn$t_grid, outcome_domain = c(0, 1), outcome_domain_name = "t", outcome_pve = 0.95, true_beta = dat_fn$true_beta ) print(eff_fn)
Outcome FPCA:
plot(eff_fn, type = "fpca_outcome")
Estimated effect surface (weighted):
The dashed black contour lines overlay the true surface $\mu(s,t)$ for
reference — they appear because true_beta was passed to
fps_effect_estimation().
plot(eff_fn, type = "effect")
Weighted vs unweighted comparison:
plot(eff_fn, type = "comparison")
eff_fn_boot <- fps_effect_estimation( outcome = dat_fn$Y, fps_object = w_fn, outcome_t_grid = dat_fn$t_grid, outcome_domain = c(0, 1), outcome_domain_name = "t", outcome_pve = 0.95, bootstrap = TRUE, B = 200, alpha = 0.05, true_beta = dat_fn$true_beta, seed = 42 )
1-D slice of the effect surface — fixing outcome time t = 0.5:
plot(eff_fn_boot, type = "bootstrap_slice", point = 0.5, which_domain = "outcome")
1-D slice — fixing exposure time s = 0.5:
plot(eff_fn_boot, type = "bootstrap_slice", point = 0.5, which_domain = "treatment")
Significance map:
plot(eff_fn_boot, type = "significance")
settings <- c("LL", "LN", "NL", "NN") results_fn <- lapply(settings, function(s) { d <- simulate_fps_data(200, setting = s, outcome_type = "functional", include_functional_cov = FALSE, seed = 2) w <- fps_weighting(d$X, treat_grid = d$t_grid, domain_name = "s", covariates = d$C) eff <- fps_effect_estimation(d$Y, w, outcome_t_grid = d$t_grid, outcome_domain = c(0, 1), outcome_domain_name = "t", true_beta = d$true_beta) data.frame( Setting = s, ISE_weighted = round(mean((eff$beta - d$true_beta)^2), 4), ISE_unweighted = round(mean((eff$beta_unweighted - d$true_beta)^2), 4), ISB_weighted = round(mean(eff$beta - d$true_beta)^2, 6), ISB_unweighted = round(mean(eff$beta_unweighted - d$true_beta)^2, 6) ) }) knitr::kable( do.call(rbind, results_fn), caption = "Surface ISE and ISB for weighted vs unweighted estimate" )
sessionInfo()
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.