An Introduction to EPLSIM Package

Yuyan Wang

2026-11-04

library(EPLSIM)

Introduction

EPLSIM fits partial linear single index models (PLSIM) for evaluating the health effects of environmental mixtures. The model combines a set of exposures into a single index via a linear combination, relates that index to the outcome through a flexible (possibly nonlinear) link function, and adjusts for confounders through a separate linear term. This vignette walks through a typical workflow: preparing confounders, fitting a model, and visualizing the results.

Three families of fitting functions are available:

This vignette focuses on the continuous-outcome case (plsi.lr.auto.constraint()); the binary- and count-outcome analogs follow the same interface and are introduced at the end.

Preparing confounders

confounder.trans() standardizes continuous confounders and creates dummy variables for categorical ones, returning both the updated data and the resulting variable names to use as Z.name.

dat.cov <- data.frame(
  age = c(1.5, 2.3, 3.1, 4.8, 5.2),
  sex = c(1, 2, 1, 2, 2),
  race = c(1, 2, 3, 4, 5)
)

# specify the confounder vector
Z.name <- c("age", "sex", "race")

# set levels and make the reference level first for categorical confounders
dat.cov$sex <- factor(dat.cov$sex, 1:2, c('Male', 'Female'))
dat.cov$race <- factor(dat.cov$race, 1:5, c("NH-White", "NH-Black",
                                             "MexicanAmerican", "OtherRace", "Hispanic"))

# transform the confounder vector and check
cov_m <- confounder.trans(Z_continuous = c("age"), Z_discrete = c("sex", "race"), data = dat.cov)
Z.name <- cov_m$New.Name
dat.cov <- cov_m$Updated.data
print(Z.name)
#> [1] "age.c"                "sex.Female"           "race.NH-Black"       
#> [4] "race.MexicanAmerican" "race.OtherRace"       "race.Hispanic"

The package’s built-in example data, nhanes.new, already has its confounders prepared this way, so the rest of this vignette uses it directly.

Fitting the model

data(nhanes.new)
dat <- nhanes.new

# outcome
Y.name <- "log.triglyceride"

# exposures making up the single index
X.name <- c("X1_trans.b.carotene", "X2_retinol", "X3_g.tocopherol", "X4_a.tocopherol",
            "X5_PCB99", "X6_PCB156", "X7_PCB206",
            "X8_3.3.4.4.5.pncb", "X9_1.2.3.4.7.8.hxcdf", "X10_2.3.4.6.7.8.hxcdf")

# confounders, already prepared as in the section above
Z.name <- c("AGE.c", "SEX.Female", "RACE.NH.Black",
            "RACE.MexicanAmerican", "RACE.OtherRace", "RACE.Hispanic")

Other fitting functions

plsi.lr.auto() fits the same model without the built-in constraint, and plsi.lr.v2() uses a fixed-df natural cubic spline in place of the penalized smooth, which is useful when running many fits in a simulation where mgcv::gam()’s per-call overhead adds up:

model_auto <- plsi.lr.auto(data = dat, Y.name = Y.name, X.name = X.name, Z.name = Z.name,
                           k = 10, bs = "cr", initial.random.num = 1, seed = 2026)

model_v2 <- plsi.lr.v2(data = dat, Y.name = Y.name, X.name = X.name, Z.name = Z.name,
                       spline.num = 5, spline.degree = 3, initial.random.num = 1, seed = 2026)

Every plotting function below works identically with the output of any of these fitting functions.

Visualizing results

Single-index coefficients

si.coef.plot(model_con$si.coefficient)

Bars show each exposure’s contribution to the single index, with 95% confidence intervals; the color indicates the sign of the coefficient.

Single-index function

The single-index function shows the estimated (possibly nonlinear) relationship between the combined exposure index and the outcome.

si.fun.plot(model_con$si.fun, type = "linear")

One exposure’s main effect

e.main.plot() shows the predicted outcome as a single exposure varies, holding the rest of the index at its confounder-adjusted reference level.

e.main.plot(model_con, dat, exp_name = "X4_a.tocopherol", type = "linear")

e.main.plot(model_con, dat, exp_name = "X5_PCB99", type = "linear")

e.main.plot(model_con, dat, exp_name = "X10_2.3.4.6.7.8.hxcdf", type = "linear")

Interaction between two exposures

e.interaction.plot() shows one exposure’s effect at the 25th/50th/75th percentile of a second exposure, and vice versa, side by side.

e.interaction.plot(model_con, dat, "X4_a.tocopherol", "X3_g.tocopherol", type = "linear")

The two panels are not interchangeable: the left panel varies the first named exposure while holding the second at its quantiles, and the right panel does the reverse. Swapping the argument order swaps which exposure is varied in which panel:

e.interaction.plot(model_con, dat, "X3_g.tocopherol", "X4_a.tocopherol", type = "linear")

Interquartile effects across all exposures

interquartile.quartile.plot() summarizes, for every exposure at once, the difference in predicted outcome between its 75th and 25th percentile, at the 25th/50th/75th percentile of every other exposure.

interquartile.quartile.plot(model_con, dat, type = "linear")

Mixture overall effect

mixture.overall.plot() shows the predicted outcome as all exposures move together through their joint quantiles (e.g. the 10th percentile of every exposure simultaneously, the 15th percentile of every exposure simultaneously, and so on).

mixture.overall.plot(model_con, dat, type = "linear")

Binary and count outcomes

plsi.logistic.auto.constraint() and plsi.log.auto.constraint() follow the same interface as plsi.lr.auto.constraint(), and every plotting function above accepts a matching type = "logistic" or type = "log" argument. nhanes.new has no native binary or count outcome, so the examples below simulate one from a known single-index combination of the exposures.

set.seed(2026)
beta_true <- c(0.30, -0.20, 0.10, 0.40, -0.30, 0.20, -0.10, 0.25, -0.15, 0.35)
beta_true <- beta_true / sqrt(sum(beta_true^2))
si_true <- as.vector(scale(dat[, X.name]) %*% beta_true)

Binary outcome

dat$high.tg <- rbinom(nrow(dat), size = 1,
                      prob = plogis(-0.2 + 0.5 * sin(si_true) + 0.05 * dat$AGE.c))

model_logistic <- plsi.logistic.auto.constraint(data = dat, Y.name = "high.tg",
                                                X.name = X.name, Z.name = Z.name,
                                                k = 10, bs = "cr",
                                                initial.random.num = 1, seed = 2026,
                                                maxit = 300)

# did the selected starting value converge?
model_logistic$optim.convergence

model_logistic$si.coefficient
model_logistic$confounder.coefficient   # includes odds ratios

si.fun.plot(model_logistic$si.fun, type = "logistic")
e.main.plot(model_logistic, dat, exp_name = "X4_a.tocopherol", type = "logistic")

Count outcome

dat$n.events <- rpois(nrow(dat), lambda = exp(0.3 + 0.4 * sin(si_true) + 0.05 * dat$AGE.c))

model_log <- plsi.log.auto.constraint(data = dat, Y.name = "n.events",
                                      X.name = X.name, Z.name = Z.name,
                                      family = "nb", k = 10, bs = "cr",
                                      initial.random.num = 1, seed = 2026,
                                      maxit = 300)

model_log$optim.convergence
model_log$si.coefficient
model_log$confounder.coefficient        # includes rate ratios

si.fun.plot(model_log$si.fun, type = "log")
e.main.plot(model_log, dat, exp_name = "X4_a.tocopherol", type = "log")

family = "nb" (negative binomial, the default) estimates its own dispersion parameter and is the safer choice for real, usually overdispersed, count data; use family = "poisson" only if the mean-equals-variance assumption holds.

For both outcome types, maxit sets the number of Nelder-Mead iterations per starting value. With many exposures the default of 100 may be too few for the search to converge; check optim.convergence and increase maxit if the selected start reports a convergence code of 1.

mirror server hosted at Truenetwork, Russian Federation.