Latent biomarker constructs and survival
When a single biomarker score isn’t enough
ClinicoPath
2026-07-06
Source:vignettes/meddecide-31-latentbiomarker.Rmd
meddecide-31-latentbiomarker.RmdWhen to use this function
Clinical research regularly confronts a frustrating mismatch: the biological process you care about is genuinely latent - it has no single measurement - yet your survival model needs one number per patient to compute a hazard ratio. Examples abound.
Immune phenotype - tumor-infiltrating lymphocyte counts (CD8+, FoxP3+, PD-L1 combined positive score, CD68+ macrophages) are all partial, noisy windows onto the same underlying construct: how hot or cold the immune microenvironment is. Using any single marker as a Cox covariate throws the others away and inflates the problem of multiple comparisons.
Histologic aggressiveness - tumor budding count, worst pattern of invasion, perineural invasion rate, and lymphovascular invasion are each imperfect indicators of how aggressively a carcinoma is progressing. A single latent “aggressiveness” factor captures their shared variance while discarding measurement noise.
Stromal activation - α-SMA expression, collagen density, FAP+ fibroblast fraction, and stiffness proxy scores from digital image analysis collectively describe activated desmoplastic stroma. None is the ground truth; together they triangulate one construct.
latentbiomarker handles all of these by fitting a
single-factor reflective confirmatory factor analysis
(CFA) with lavaan, extracting factor scores, and
passing them to a Cox proportional-hazards model from
survival. The estimator switches automatically between MLR
(continuous indicators) and WLSMV with polychoric correlations (ordinal
or binary indicators).
When NOT to use this function
Formative composites. If your “score” is constituted
by its components - where each component adds independent information by
design - CFA gives nonsense results. Classic example: a histologic grade
that sums nuclear grade + tubule formation + mitotic count
(Nottingham/Elston - Ellis). The components are chosen to be
conceptually distinct, not symptoms of one latent cause. Use
cSEM or seminr for formative indices, or
simply compute the weighted sum directly.
n < 100. SEM is data-hungry. Factor loadings and model-fit indices are unreliable in small samples; the function will refuse the analysis at this threshold (see the refusal policy below).
A single binary marker. If you have one marker (e.g., MMRd yes/no), there is no measurement model to fit. Enter it directly as a Cox covariate.
Multiple correlated constructs. If your indicators
cluster into two distinct factors (e.g., proliferative markers and
invasion markers), forcing them into one factor distorts both. Use
SEMLj for multi-factor SEM + survival.
Refusal policy
The function evaluates a series of gates before fitting. Hard gates
stop execution immediately with an explanatory notice. Soft gates allow
the analysis to proceed but pin a warning at the top of the results.
This policy is documented fully in
docs/superpowers/specs/2026-05-13-latentbiomarker-design.md,
Section 6.
| Gate | Type | Condition | Action |
|---|---|---|---|
| G1 | Hard | n < 100 | Refuse; suggest z-score composite or larger cohort |
| G1-soft | Soft | 100 <= n < 200 | Warn; interpret CIs cautiously |
| G2 | Hard | Cases-per-CFA-parameter < 5 | Refuse; show CPP and suggest reducing indicators |
| G2-soft | Soft | 5 <= CPP < 10 | Warn; SEs may be optimistic |
| G3 | Hard | Indicators < 3 | Refuse; model under-identified with < 3 indicators |
| G3-soft | Soft | Exactly 3 indicators (df = 0) | Warn; fit indices not interpretable |
| G4 | Soft | Events-per-Cox-variable < 10 | Warn; consider reducing adjusters |
| G5 | Soft | All inter-indicator correlations < 0.3 | Warn; indicators may not share a common cause |
| G6 | Hard | reflective_confirmed == FALSE |
Refuse; user must confirm the reflective assumption |
Six worked scenarios
Scenario 1 - Continuous indicators, the canonical happy path
In most immunophenotyping studies, pathologists score five or six markers on continuous or near-continuous scales: CD8+ TIL density (cells/mm²), PD-L1 combined positive score (%), CD68+ macrophage fraction (%), FoxP3+ Treg density, and LAG-3+ cell density. These are all imperfect measurements of one shared construct - immune activation - and their inter-correlations arise because they are each partly driven by that latent state.
The simulation below creates 400 patients whose five markers are
generated by a single latent factor F plus independent
measurement noise. This mimics the true data-generating process for a
reflective model. With set.seed(3) for reproducibility, we
then run latentbiomarker using the MLR estimator (the
default for all-continuous indicators). MLR is maximum likelihood with
robust Huber - White standard errors, which is appropriate when the
multivariate normality assumption is not perfectly met - as is typical
with right-skewed IHC counts.
set.seed(3)
n <- 400
# True factor loadings for five immune markers
lambda <- c(0.75, 0.70, 0.80, 0.65, 0.72)
# Latent immune factor (standardised)
F <- rnorm(n)
# Unique variances: 1 - lambda^2 (reflective model)
E <- sapply(sqrt(1 - lambda^2), function(sd) rnorm(n, 0, sd))
# Observed indicators matrix
X <- outer(F, lambda) + E
colnames(X) <- c("cd8_til", "pdl1_cps", "cd68_frac", "foxp3_density", "lag3_cells")
# Survival outcome: higher immune activation (F) -> better OS
# Baseline hazard corresponds roughly to median OS 36 months
set.seed(42)
log_hr_true <- -0.4 # protective effect of immune activation
T_surv <- rexp(n, rate = exp(log_hr_true * F) / 36)
C_surv <- rexp(n, rate = 1 / 60) # censoring around 60 months
time <- pmin(T_surv, C_surv)
event <- as.integer(T_surv <= C_surv)
df_scenario1 <- data.frame(X, time = time, event = event)
cat("n =", n, "| events =", sum(event), "| event rate =",
round(mean(event), 2), "\n")## n = 400 | events = 238 | event rate = 0.6
# Verify the CFA recovers loadings close to truth using lavaan directly.
# This is exactly what latentbiomarker calls internally.
if (requireNamespace("lavaan", quietly = TRUE)) {
model_syn <- '
F =~ cd8_til + pdl1_cps + cd68_frac + foxp3_density + lag3_cells
'
fit1 <- lavaan::cfa(model_syn, data = df_scenario1, estimator = "MLR",
std.lv = TRUE)
loadings1 <- lavaan::parameterEstimates(fit1, standardized = TRUE)
loadings1 <- loadings1[loadings1$op == "=~",
c("lhs", "rhs", "std.all", "se", "pvalue")]
names(loadings1) <- c("Factor", "Indicator", "Std_Loading", "SE", "p")
print(loadings1, digits = 3, row.names = FALSE)
# Fit indices
fi <- lavaan::fitMeasures(fit1, c("cfi.robust", "tli.robust",
"rmsea.robust", "srmr"))
cat("\nFit indices (MLR):\n")
print(round(fi, 3))
}## Factor Indicator Std_Loading SE p
## F cd8_til 0.778 0.045 0
## F pdl1_cps 0.672 0.045 0
## F cd68_frac 0.788 0.047 0
## F foxp3_density 0.630 0.051 0
## F lag3_cells 0.699 0.050 0
##
## Fit indices (MLR):
## cfi.robust tli.robust rmsea.robust srmr
## 0.999 0.997 0.023 0.015
# Factor score extraction + Cox regression - the second stage
if (requireNamespace("lavaan", quietly = TRUE) &&
requireNamespace("survival", quietly = TRUE)) {
scores1 <- lavaan::lavPredict(fit1, method = "regression")[, 1]
scores1_std <- as.numeric(scale(scores1))
cox1 <- survival::coxph(
survival::Surv(time, event) ~ scores1_std,
data = df_scenario1
)
summary_cox1 <- summary(cox1)
cat("Cox HR for latent immune factor (per SD):\n")
print(round(summary_cox1$coefficients[, c(1, 2, 3, 5)], 3))
cat("\nTrue log-HR was", log_hr_true,
"| Estimated log-HR =",
round(coef(cox1), 3), "\n")
}## Cox HR for latent immune factor (per SD):
## coef exp(coef) se(coef) Pr(>|z|)
## -0.306 0.737 0.073 0.000
##
## True log-HR was -0.4 | Estimated log-HR = -0.306
The standardised loadings should fall within roughly ±0.10 of the true values (0.65-0.80). The Cox model recovers a protective HR below 1.0, consistent with the simulated truth. CFI above 0.95 and RMSEA below 0.06 confirm good single-factor fit - this is the ideal happy path.
Scenario 2 - Ordinal indicators trigger WLSMV
IHC scoring in surgical pathology is almost never continuous. Pathologists routinely assign semi-quantitative scores: 0 (absent), 1+ (weak/focal), 2+ (moderate/diffuse), 3+ (strong/diffuse). This four-level ordinal scale is common for PD-L1 tumor proportion score bins, p53 aberrant expression, and chromatin pattern descriptors.
When any indicator has five or fewer distinct ordered levels, Pearson
correlations are biased because they assume normality around the
observed values. The correct association measure is the polychoric
correlation - the Pearson correlation between the two hypothesised
underlying continuous latent responses. lavaan handles this
automatically when ordered = TRUE is specified, and it
switches to WLSMV (weighted least-squares means and variance adjusted)
estimation, which is the recommended estimator for ordinal manifest
variables.
latentbiomarker auto-detects ordinal indicators and
issues a STRONG_WARNING notice reminding users that the
factor loadings are now on the latent-response scale (not the observed
0-3 scale) and that fit indices have slightly different reference
distributions under WLSMV.
set.seed(7)
n2 <- 350
# Latent immune factor (standardised)
F2 <- rnorm(n2)
lambda2 <- c(0.70, 0.68, 0.73, 0.65)
# Continuous underlying responses
E2 <- sapply(sqrt(1 - lambda2^2), function(sd) rnorm(n2, 0, sd))
Y2_cont <- outer(F2, lambda2) + E2
# Discretise to 0/1/2/3 using quantile thresholds (produces realistic ordinal)
ordinalise <- function(x, thresholds = c(-0.8, 0.2, 1.0)) {
cut(x, c(-Inf, thresholds, Inf), labels = FALSE) - 1L
}
X2 <- apply(Y2_cont, 2, ordinalise)
colnames(X2) <- c("pdl1_score", "p53_score", "ki67_score", "her2_score")
# Survival: higher factor -> shorter OS (aggressive tumour phenotype)
log_hr_true2 <- 0.35
T2 <- rexp(n2, rate = exp(log_hr_true2 * F2) / 48)
C2 <- rexp(n2, rate = 1 / 72)
time2 <- pmin(T2, C2)
event2 <- as.integer(T2 <= C2)
df_scenario2 <- data.frame(as.data.frame(X2), time = time2, event = event2)
cat("Indicator value ranges:\n")## Indicator value ranges:
## pdl1_score p53_score ki67_score her2_score
## 0 76 64 78 62
## 1 121 137 123 138
## 2 90 104 90 90
## 3 63 45 59 60
if (requireNamespace("lavaan", quietly = TRUE)) {
# Declare indicators as ordered - this triggers polychoric correlations
# and WLSMV estimation automatically
model_ord <- '
F =~ pdl1_score + p53_score + ki67_score + her2_score
'
fit2 <- lavaan::cfa(model_ord, data = df_scenario2, estimator = "WLSMV",
ordered = c("pdl1_score", "p53_score",
"ki67_score", "her2_score"),
std.lv = TRUE)
loadings2 <- lavaan::parameterEstimates(fit2, standardized = TRUE)
loadings2 <- loadings2[loadings2$op == "=~",
c("rhs", "std.all", "se", "pvalue")]
names(loadings2) <- c("Indicator", "Std_Loading_latent_response",
"SE", "p")
print(loadings2, digits = 3, row.names = FALSE)
fi2 <- lavaan::fitMeasures(fit2, c("cfi.robust", "tli.robust",
"rmsea.robust", "srmr.robust"))
cat("\nFit indices (WLSMV):\n")
print(round(fi2, 3))
}## Indicator Std_Loading_latent_response SE p
## pdl1_score 0.732 0.046 0
## p53_score 0.588 0.054 0
## ki67_score 0.655 0.047 0
## her2_score 0.655 0.051 0
##
## Fit indices (WLSMV):
## cfi.robust tli.robust rmsea.robust
## 1.000 1.014 0.000
Notice that the loadings are labelled “on the latent-response scale.”
When you use WLSMV, lavaan works with thresholds and
polychoric correlations; the loadings describe how the latent continuous
response underlying each score moves with the latent factor, not how the
observed 0 - 3 integer moves. This distinction matters for
interpretation: a loading of 0.68 means that one SD increase in immune
activation is associated with a 0.68 SD shift in the latent PD-L1
response, which in turn shifts the probabilities of the observable
0/1/2/3 categories via the estimated thresholds.
Scenario 3 - Mixed continuous + ordinal + binary indicators
Real tissue microarray (TMA) or whole-slide analysis datasets often combine assay types. You might have: CD8+ TIL density from digital image analysis (continuous, cells/mm²), PD-L1 combined positive score (continuous, 0 - 100%), an IHC H-score converted to a 0/1+/2+/3+ bin (ordinal), and a binary call for mismatch repair deficiency (MMRd: 0 = proficient, 1 = deficient). All four reflect immune microenvironment state; all four are worth combining.
When any indicator is non-continuous, latentbiomarker
falls back to WLSMV across all indicators. Continuous indicators are
treated as normally distributed, while ordinal and binary indicators use
their respective polychoric and tetrachoric correlations. A binary
indicator is mathematically a two-level ordered variable - its
tetrachoric correlation is a special case of the polychoric. You do not
need to do anything special: auto-detection handles it.
set.seed(11)
n3 <- 300
F3 <- rnorm(n3)
lambda3 <- c(0.72, 0.68, 0.65, 0.58) # two continuous, one ordinal, one binary
E3 <- sapply(sqrt(1 - lambda3^2), function(sd) rnorm(n3, 0, sd))
Y3_cont <- outer(F3, lambda3) + E3
# Indicators:
# 1. cd8_density: continuous (cells/mm^2 on approximate scale, non-negative)
cd8_density <- exp(Y3_cont[, 1] + 3) # lognormal, strictly positive
# 2. pdl1_cps: continuous (0-100)
pdl1_cps <- pmax(0, pmin(100, Y3_cont[, 2] * 20 + 30))
# 3. ihc_score: ordinal 0/1/2/3
ihc_score <- cut(Y3_cont[, 3], c(-Inf, -0.7, 0.2, 0.9, Inf),
labels = FALSE) - 1L
# 4. mmrd: binary (MMR-deficient vs proficient)
mmrd <- as.integer(Y3_cont[, 4] > 0.3)
# Survival
log_hr3 <- -0.30
T3 <- rexp(n3, rate = exp(log_hr3 * F3) / 42)
C3 <- rexp(n3, rate = 1 / 60)
time3 <- pmin(T3, C3)
event3 <- as.integer(T3 <= C3)
df_scenario3 <- data.frame(
cd8_density, pdl1_cps, ihc_score, mmrd,
time = time3, event = event3
)
cat("Variable types and ranges:\n")## Variable types and ranges:
## cd8_density: continuous, range 2 353
## pdl1_cps: continuous, range 0 84
## ihc_score: ordinal, levels 0 1 2 3
## mmrd: binary, n(0)= 187 n(1)= 113
if (requireNamespace("lavaan", quietly = TRUE)) {
model_mix <- '
F =~ cd8_density + pdl1_cps + ihc_score + mmrd
'
# WLSMV required because ihc_score and mmrd are non-continuous
fit3 <- lavaan::cfa(model_mix, data = df_scenario3,
estimator = "WLSMV",
ordered = c("ihc_score", "mmrd"),
std.lv = TRUE)
loadings3 <- lavaan::parameterEstimates(fit3, standardized = TRUE)
loadings3 <- loadings3[loadings3$op == "=~",
c("rhs", "std.all", "se", "pvalue")]
names(loadings3) <- c("Indicator", "Std_Loading", "SE", "p")
print(loadings3, digits = 3, row.names = FALSE)
}## Indicator Std_Loading SE p
## cd8_density 0.711 2.423 0
## pdl1_cps 0.531 1.120 0
## ihc_score 0.682 0.048 0
## mmrd 0.598 0.048 0
The key takeaway: you can mix assay types freely as long as every indicator reflects the same construct. The estimator choice is driven by the weakest-scale indicator - if even one indicator is ordinal or binary, WLSMV is used for all. This is conservative but correct. If all four indicators were continuous, MLR would be preferred.
Scenario 4 - Insufficient n triggers G1
Small cohorts are the norm in many specialised pathology studies:
rare tumour types, TMA series from a single institution, or pilot
studies for larger grants. Researchers are sometimes tempted to run CFA
on 50-80 cases when those are the only specimens available.
latentbiomarker refuses this with the G1 hard gate.
The refusal message explains why the gate exists - factor loadings and fit indices are known to be unstable below n = 100, standard errors are substantially inflated, and model convergence is erratic - and offers two constructive alternatives: a simple z-score composite (compute each indicator’s z-score, average them, use that as a Cox covariate) or waiting for a larger cohort.
set.seed(99)
n4 <- 50 # too small for CFA
F4 <- rnorm(n4)
lambda4 <- c(0.72, 0.68, 0.65)
E4 <- sapply(sqrt(1 - lambda4^2), function(sd) rnorm(n4, 0, sd))
X4 <- outer(F4, lambda4) + E4
colnames(X4) <- c("marker_a", "marker_b", "marker_c")
time4 <- rexp(n4, rate = 1/36)
event4 <- rbinom(n4, 1, 0.6)
df_scenario4 <- data.frame(as.data.frame(X4), time = time4, event = event4)
# Simulate the G1 gate check that latentbiomarker performs
g1_threshold <- 100
n_obs <- nrow(df_scenario4)
if (n_obs < g1_threshold) {
cat("HARD REFUSAL - G1 triggered:\n")
cat("SEM is data-hungry. With n =", n_obs, "< 100,\n",
"factor loadings and fit indices are unreliable.\n",
"Consider:\n",
" (1) a simpler z-score composite with conventional Cox regression, or\n",
" (2) waiting for a larger cohort.\n")
}## HARD REFUSAL - G1 triggered:
## SEM is data-hungry. With n = 50 < 100,
## factor loadings and fit indices are unreliable.
## Consider:
## (1) a simpler z-score composite with conventional Cox regression, or
## (2) waiting for a larger cohort.
# The recommended alternative: z-score composite
# For n=50, this is statistically defensible where CFA is not
zscore_composite <- rowMeans(scale(df_scenario4[, c("marker_a",
"marker_b",
"marker_c")]))
if (requireNamespace("survival", quietly = TRUE)) {
cox_composite <- survival::coxph(
survival::Surv(time4, event4) ~ zscore_composite,
data = df_scenario4
)
cat("Z-score composite Cox (n=50, 3 indicators):\n")
print(round(summary(cox_composite)$coefficients[, c(1, 2, 5)], 3))
}## Z-score composite Cox (n=50, 3 indicators):
## coef exp(coef) Pr(>|z|)
## 0.052 1.053 0.803
The z-score composite is not a measurement model - it makes no claim that the indicators are exchangeable or that loadings are equal - but it is a defensible, transparent, and reproducible way to combine information from small samples. When the cohort grows to n >= 100 (and ideally n >= 200), the CFA approach becomes appropriate and can be applied retrospectively to test whether the loadings are actually equal or not.
Scenario 5 - Just-identified model with exactly 3 indicators
Three indicators with one latent factor is the minimum identified CFA model, but it sits at zero degrees of freedom. This means the model is just-identified: it can reproduce the observed correlations perfectly by construction, regardless of whether the single-factor model is actually correct. As a consequence:
- CFI = 1.000 and TLI ≈ 1.000 by definition (not a sign of good fit)
- RMSEA = 0.000 (similarly uninformative)
- SRMR = 0.000 or near zero
These perfect-looking indices are meaningless. There is no residual variance in the model-implied correlations to assess because the model has exactly as many free parameters as there are unique covariances. You cannot distinguish a perfect single-factor model from a perfect correlated-uniqueness model.
latentbiomarker issues the G3-soft INFO notice when
exactly 3 indicators are supplied, explaining this structural limitation
and recommending that users either add a fourth indicator to gain at
least 1 df, or interpret the results without relying on the fit
statistics.
set.seed(21)
n5 <- 280 # adequate sample size, but only 3 indicators
F5 <- rnorm(n5)
lambda5 <- c(0.75, 0.70, 0.68)
E5 <- sapply(sqrt(1 - lambda5^2), function(sd) rnorm(n5, 0, sd))
X5 <- outer(F5, lambda5) + E5
colnames(X5) <- c("biomarker_1", "biomarker_2", "biomarker_3")
time5 <- rexp(n5, rate = exp(-0.25 * F5) / 40)
event5 <- rbinom(n5, 1, 0.55)
df_scenario5 <- data.frame(as.data.frame(X5), time = time5, event = event5)
if (requireNamespace("lavaan", quietly = TRUE)) {
model5 <- '
F =~ biomarker_1 + biomarker_2 + biomarker_3
'
fit5 <- lavaan::cfa(model5, data = df_scenario5, estimator = "MLR",
std.lv = TRUE)
cat("Model degrees of freedom:", lavaan::fitMeasures(fit5, "df"), "\n")
fi5 <- lavaan::fitMeasures(fit5, c("cfi.robust", "tli.robust",
"rmsea.robust", "srmr"))
cat("\nFit indices (3 indicators, df = 0):\n")
print(round(fi5, 3))
cat("\nG3-soft INFO notice:\n")
cat("With 3 indicators and 1 factor, the model is just-identified (df = 0).\n",
"CFI, RMSEA, and SRMR are not meaningful - perfect fit is guaranteed\n",
"by construction. Add a fourth indicator to obtain an over-identified\n",
"model with testable fit.\n")
}## Model degrees of freedom: 0
##
## Fit indices (3 indicators, df = 0):
## cfi.robust tli.robust rmsea.robust srmr
## NA NA 0 0
##
## G3-soft INFO notice:
## With 3 indicators and 1 factor, the model is just-identified (df = 0).
## CFI, RMSEA, and SRMR are not meaningful - perfect fit is guaranteed
## by construction. Add a fourth indicator to obtain an over-identified
## model with testable fit.
Notice the fit indices are trivially perfect. This is not a bug in the software or a sign that your model is correct - it is an algebraic inevitability. The Cox regression stage still runs and the HR estimate is valid, but you cannot make claims about model fit quality. If adding a fourth indicator is not feasible, consider framing the latent variable as a three-item composite and acknowledge the identification constraint explicitly in the methods section.
Scenario 6 - Two-factor reality forced into one factor
Perhaps the most practically important failure mode is running a single-factor model on indicators that actually belong to two distinct constructs. Consider a panel that mixes proliferative markers (Ki-67 LI, PCNA expression, Cyclin D1 expression) with invasion markers (MMP-9 expression, E-cadherin loss, neural invasion extent). These two groups reflect different biological processes. Their inter-group correlations are modest; their within-group correlations are high.
Forcing all six into a single-factor CFA produces poor fit: CFI drops
below 0.95 and RMSEA rises above 0.10. The latentbiomarker
function detects this via the Fit-poor gate and issues a notice
recommending SEMLj for multi-factor SEM, which supports
correlated factors and the full structural model including survival.
set.seed(33)
n6 <- 450
# Factor 1: proliferation
F1 <- rnorm(n6)
# Factor 2: invasion, moderately correlated with F1
F2 <- 0.3 * F1 + sqrt(1 - 0.3^2) * rnorm(n6)
# Loadings for each factor's three indicators
l1 <- c(0.80, 0.75, 0.72) # proliferative markers
l2 <- c(0.78, 0.73, 0.70) # invasion markers
# Residual SDs
sd1 <- sqrt(1 - l1^2)
sd2 <- sqrt(1 - l2^2)
# Generate indicators (independent per-column residual noise so the covariance
# stays full-rank / positive-definite; matches the pattern used in scenario 4)
proliferative <- outer(F1, l1) + sapply(sd1, function(s) rnorm(n6, 0, s))
invasion <- outer(F2, l2) + sapply(sd2, function(s) rnorm(n6, 0, s))
colnames(proliferative) <- c("ki67_li", "pcna_expr", "cyclind1_expr")
colnames(invasion) <- c("mmp9_expr", "ecadherin_loss", "ni_extent")
# Survival outcome driven mainly by invasion (F2), weakly by proliferation
log_hr6 <- 0.4 * F2 + 0.15 * F1
T6 <- rexp(n6, rate = exp(log_hr6) / 48)
C6 <- rexp(n6, rate = 1 / 72)
time6 <- pmin(T6, C6)
event6 <- as.integer(T6 <= C6)
df_scenario6 <- data.frame(
as.data.frame(proliferative),
as.data.frame(invasion),
time = time6, event = event6
)
cat("Within-group correlations (proliferative):\n")## Within-group correlations (proliferative):
## ki67_li pcna_expr cyclind1_expr
## ki67_li 1.00 0.60 0.62
## pcna_expr 0.60 1.00 0.59
## cyclind1_expr 0.62 0.59 1.00
cat("\nWithin-group correlations (invasion):\n")##
## Within-group correlations (invasion):
## mmp9_expr ecadherin_loss ni_extent
## mmp9_expr 1.00 0.63 0.59
## ecadherin_loss 0.63 1.00 0.51
## ni_extent 0.59 0.51 1.00
cat("\nCross-group correlations (proliferative vs invasion):\n")##
## Cross-group correlations (proliferative vs invasion):
## mmp9_expr ecadherin_loss ni_extent
## ki67_li 0.16 0.19 0.19
## pcna_expr 0.15 0.12 0.13
## cyclind1_expr 0.14 0.16 0.12
if (requireNamespace("lavaan", quietly = TRUE)) {
# Force a single-factor model across all 6 indicators
model6_single <- '
F =~ ki67_li + pcna_expr + cyclind1_expr +
mmp9_expr + ecadherin_loss + ni_extent
'
fit6_single <- lavaan::cfa(model6_single, data = df_scenario6,
estimator = "MLR", std.lv = TRUE)
fi6_single <- lavaan::fitMeasures(fit6_single,
c("cfi.robust", "tli.robust",
"rmsea.robust", "srmr"))
cat("Single-factor model fit indices:\n")
print(round(fi6_single, 3))
cfi_val <- round(fi6_single["cfi.robust"], 3)
rmsea_val <- round(fi6_single["rmsea.robust"], 3)
if (cfi_val < 0.95 || rmsea_val > 0.10) {
cat("\nFit-poor notice (as shown in latentbiomarker):\n")
cat("Single-factor model fits poorly (CFI =", cfi_val,
", RMSEA =", rmsea_val, ").\n",
"Consider splitting into multiple constructs.\n",
"SEMLj supports multi-factor SEM with survival outcomes.\n")
}
}## Single-factor model fit indices:
## cfi.robust tli.robust rmsea.robust srmr
## 0.566 0.276 0.317 0.197
##
## Fit-poor notice (as shown in latentbiomarker):
## Single-factor model fits poorly (CFI = 0.566 , RMSEA = 0.317 ).
## Consider splitting into multiple constructs.
## SEMLj supports multi-factor SEM with survival outcomes.
if (requireNamespace("lavaan", quietly = TRUE)) {
# Compare with the correct two-factor model for reference
model6_correct <- '
Proliferation =~ ki67_li + pcna_expr + cyclind1_expr
Invasion =~ mmp9_expr + ecadherin_loss + ni_extent
Proliferation ~~ Invasion
'
fit6_correct <- lavaan::cfa(model6_correct, data = df_scenario6,
estimator = "MLR", std.lv = TRUE)
fi6_correct <- lavaan::fitMeasures(fit6_correct,
c("cfi.robust", "tli.robust",
"rmsea.robust", "srmr"))
cat("Two-factor model fit indices (for comparison - requires SEMLj):\n")
print(round(fi6_correct, 3))
cat("\nConclusion: The two-factor model fits well (CFI >= 0.95, RMSEA <= 0.06),\n",
"while the single-factor model does not. The Fit-poor diagnostic correctly\n",
"invites users to graduate to multi-factor SEM in SEMLj.\n")
}## Two-factor model fit indices (for comparison - requires SEMLj):
## cfi.robust tli.robust rmsea.robust srmr
## 1.000 1.000 0.007 0.017
##
## Conclusion: The two-factor model fits well (CFI >= 0.95, RMSEA <= 0.06),
## while the single-factor model does not. The Fit-poor diagnostic correctly
## invites users to graduate to multi-factor SEM in SEMLj.
This scenario illustrates why the Fit-poor gate is a useful diagnostic rather than merely an error condition. The function does not refuse to run - it runs, reports the poor fit, and tells you what to do next. The CFI / RMSEA flags are your signal that you are forcing a biological reality that is richer than one factor into a single-factor straitjacket. The KM plot and HR may still be useful as a summary, but they should be interpreted knowing that the single factor is a blend of two distinct processes rather than a clean measurement of one.
What is deliberately out of scope (v1)
The following capabilities were considered and explicitly deferred to
later versions, documented in Section 1 (Non-goals) of
docs/superpowers/specs/2026-05-13-latentbiomarker-design.md:
-
Multiple correlated factors - if your biomarker
panel breaks into two or more distinct constructs, use
SEMLjor a futurelatentbiomarker2. The single-factor assumption is the price of simplicity and the Fit-poor gate will tell you when it is violated. - Bootstrap propagation of factor-score measurement uncertainty into Cox HRs - the Murphy - Topel two-stage problem. Factor scores are estimated with error; treating them as observed values underestimates Cox CIs. v1 ships with a prominent notice flagging this limitation; v2 may add an optional bootstrap pipeline. Until then, interpret HRs conservatively.
-
Continuous and binary outcomes - future sibling
functions
latentbiomarker-continuousandlatentbiomarker-binarywill handle regression and logistic outcomes. v1 is survival-only. -
Mediation chains - if you want to test whether a
latent immune factor mediates the effect of treatment on survival, that
is a mediation problem; defer to a future
pathologymediationfunction. -
Latent class analysis for imaging - biology
circularity - a different modelling paradigm; deferred to
imagingbiologyLCA. -
User-typed lavaan syntax -
SEMLjalready does this better than we ever would.latentbiomarkerdeliberately does not expose lavaan syntax; it is opinionated so that clinicians who are not SEM experts get sensible defaults without the risk of mis-specifying a custom model.
Further reading
- lavaan package: Rosseel Y. lavaan: An R Package for Structural Equation Modeling. J Stat Softw 2012; 48(2): 1 - 36. Package documentation at https://lavaan.ugent.be/.
- SEMLj: Multi-factor SEM with structural paths in jamovi. Documentation at https://semlj.github.io/.
- cSEM: Formative composite SEM in R. Package documentation at https://m-e-rademaker.github.io/cSEM/.
- seminr: Another R package for partial least squares path modelling (formative composites). https://sem-in-r.github.io/seminr/.
- Murphy - Topel bias: Murphy KM, Topel RH. Estimation and Inference in Two-Step Econometric Models. J Bus Econ Stat 1985; 3(4): 370 - 9. The theoretical basis for why two-stage (CFA then Cox) CIs are too narrow.
- Events-per-variable rule: Peduzzi P, Concato J, Feinstein AR, Holford TR. Importance of events per independent variable in proportional hazards regression analysis. J Clin Epidemiol 1995; 48: 1503 - 10.
- SEM textbook: Kline RB. Principles and Practice of Structural Equation Modeling, 4th ed. Guilford; 2016. Chapter 9 covers CFA; Chapter 18 covers ordinal indicators.
-
Design specification:
docs/superpowers/specs/2026-05-13-latentbiomarker-design.md- full gate policy, architecture, and build sequence for this function. -
ClinicoPath jamovi module patterns:
vignettes/jamovi_module_patterns_guide.md.