---
title: "LUCIDus Comprehensive Guide: Models, APIs, and End-to-End Functionality"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{LUCIDus Comprehensive Guide: Models, APIs, and End-to-End Functionality}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

## 1) Purpose of This Document

LUCID (Latent Unknown Clusters by Integrating multi-omics Data) finds latent
subgroups of subjects that are simultaneously (1) predictable from a set of
exposures `G`, (2) characterized by distinct omics profiles `Z`, and (3)
associated with a health outcome `Y`. This document is a comprehensive,
user-facing tour of the package's **public API**: everything a user needs to
fit, tune, summarize, predict from, bootstrap, and visualize a LUCID model,
worked through end to end on a small simulated dataset. It intentionally
does not describe internal implementation details -- only functions you are
meant to call directly.

It covers:

- the three model architectures and what each one assumes:
  - **early integration** -- one joint set of clusters from all omics layers
    pooled together
  - **parallel integration** -- one cluster variable per omics layer, fit
    jointly
  - **serial integration** -- a chain of cluster stages, each built on the
    previous stage's cluster assignment
- fitting APIs: `estimate_lucid()`, `tune_lucid()`, `lucid()`
- post-fit APIs: `summary()`, `predict_lucid()` (including `g_computation`
  mode), `boot_lucid()`, `plot()`, `plot_cluster_omic_profile()`
- extractor functions for a fitted model's output: `get_selected_G()`,
  `get_selected_Z()`, `get_cluster_assignment()`, `get_top_omics_features()`
- missing-data diagnostics and imputation: `check_na()`,
  `analyze_missing_pattern()`, `safe_impute()`, `check_imputation_quality()`

Two things intentionally stay out of view. First, several internal EM
building blocks (a low-level data-filling routine, and a handful of
numerical-stability safeguards for the optimizer) support the functions
above but are not exported and are not part of the API this guide teaches;
section 7 explains what they do for you conceptually where it matters.
Second, this is a *breadth* guide -- runtime is kept small throughout so the
whole document fits together as one example. Section 3-model tutorials
(`lucid_3models_normal_outcome.Rmd`, `lucid_3models_binary_outcome.Rmd`) go
deeper on each architecture, including the two-step penalized-screen-then-
refit workflow used for real feature selection.

## 2) Quick Functionality Map

| Functionality | Main API |
|---|---|
| Fit early/parallel/serial model directly | `estimate_lucid()` |
| Grid search for `K` and penalties | `tune_lucid()` |
| One-step wrapper (fit or tune+fit) | `lucid()` |
| Structured model summary | `summary()` |
| Prediction and cluster assignment on new/held-out data | `predict_lucid()` |
| Bootstrap CI inference | `boot_lucid()` |
| Sankey-style path visualization | `plot()` |
| Per-cluster omics profile visualization | `plot_cluster_omic_profile()` |
| Extract selected exposures / omics features from a fit | `get_selected_G()`, `get_selected_Z()` |
| Extract hard cluster assignment from a fit | `get_cluster_assignment()` |
| Extract top-N most important omics features from a fit | `get_top_omics_features()` |
| Missingness diagnostics | `check_na()`, `analyze_missing_pattern()` |
| Robust imputation helpers | `safe_impute()`, `check_imputation_quality()` |

The four `get_*()` extractors share one design point worth calling out up
front: every one of them takes only the fitted model object and figures out
on its own whether it is looking at an early, parallel, or serial fit. You
never pass a `lucid_model` argument to them, and (since this release)
`predict_lucid()` and `boot_lucid()` no longer require one either -- they
detect it from `class(model)`. The only place you still name the model type
explicitly is when *fitting* one, since that is the argument that decides
which architecture gets fit in the first place.

## 3) Setup

```{r setup, message=FALSE, warning=FALSE}
library(LUCIDus)
```

## 4) Build Associated Demo Data (Not Independent Sampling)

A LUCID model only has something interesting to find if `G`, the latent
cluster, `Z`, and `Y` are actually related -- fitting it to independent noise
would produce arbitrary clusters and make every example below meaningless.
This simulation deliberately wires in that structure: a subset of exposures
in `G` drive a binary latent state `x`, `x` in turn shifts each omics layer's
means apart (so the layers actually separate by cluster) and shifts the
outcome `Y`. That known ground truth is also what lets the fitted models'
recovered clusters and selected features be checked for sanity throughout
this document.

```{r build-demo-data}
make_demo_data <- function(n = 80, pG = 6, pZ = 4, seed = 20260309) {
  set.seed(seed)

  # Exposures
  G <- matrix(rnorm(n * pG), nrow = n, ncol = pG)
  colnames(G) <- paste0("G", seq_len(pG))

  # Covariates associated with exposures
  CoG <- cbind(
    cov_g1 = G[, 1] + 0.2 * G[, 2] + rnorm(n, sd = 0.2),
    cov_g2 = -0.3 * G[, 3] + 0.4 * G[, 4] + rnorm(n, sd = 0.25)
  )
  CoY <- CoG

  # Latent cluster driver
  lin <- 1.0 * G[, 1] - 0.8 * G[, 2] + 0.4 * CoG[, 1]
  prob_x <- plogis(lin)
  x <- rbinom(n, size = 1, prob = prob_x)

  # Layer 1 and 2 for parallel stage
  Z1 <- cbind(
    1.2 * x + 0.5 * G[, 1] + rnorm(n, sd = 0.5),
    1.0 * x - 0.4 * G[, 2] + rnorm(n, sd = 0.5),
    0.8 * x + 0.3 * G[, 3] + rnorm(n, sd = 0.5),
    0.6 * x + 0.2 * G[, 4] + rnorm(n, sd = 0.5)
  )

  Z2 <- cbind(
    -1.1 * x + 0.45 * G[, 2] + rnorm(n, sd = 0.5),
    -0.9 * x - 0.35 * G[, 1] + rnorm(n, sd = 0.5),
    -0.7 * x + 0.25 * G[, 5] + rnorm(n, sd = 0.5),
    -0.5 * x + 0.20 * G[, 6] + rnorm(n, sd = 0.5)
  )

  # Layer 3 for serial second stage (early stage)
  Z3 <- cbind(
    0.9 * x + 0.30 * G[, 1] + rnorm(n, sd = 0.55),
    0.7 * x - 0.25 * G[, 3] + rnorm(n, sd = 0.55),
    -0.8 * x + 0.20 * G[, 4] + rnorm(n, sd = 0.55),
    -0.6 * x + 0.15 * G[, 6] + rnorm(n, sd = 0.55)
  )

  colnames(Z1) <- paste0("Z1_f", seq_len(pZ))
  colnames(Z2) <- paste0("Z2_f", seq_len(pZ))
  colnames(Z3) <- paste0("Z3_f", seq_len(pZ))

  # Outcomes
  Y_normal <- 1.1 * x + 0.5 * G[, 1] - 0.25 * G[, 3] + 0.35 * CoY[, 2] + rnorm(n, sd = 0.7)
  Y_binary <- rbinom(n, size = 1, prob = plogis(-0.2 + 1.0 * x + 0.35 * G[, 1] - 0.2 * CoY[, 1]))

  # Structures for each model
  Z_parallel <- list(layer1 = Z1, layer2 = Z2)
  Z_early <- cbind(Z1, Z2)
  Z_serial_mixed <- list(list(layer1 = Z1, layer2 = Z2), Z3)

  list(
    G = G,
    CoG = CoG,
    CoY = CoY,
    Y_normal = as.numeric(Y_normal),
    Y_binary = as.numeric(Y_binary),
    Z1 = Z1,
    Z2 = Z2,
    Z3 = Z3,
    Z_parallel = Z_parallel,
    Z_early = Z_early,
    Z_serial_mixed = Z_serial_mixed
  )
}

d <- make_demo_data()
```

The three `Z_*` structures at the bottom -- `Z_early` (one concatenated
matrix), `Z_parallel` (a named list of layer matrices), `Z_serial_mixed` (a
list whose first element is itself a parallel-style list) -- are exactly the
three shapes `estimate_lucid()` expects for early, parallel, and serial
fitting respectively. Building all three up front from the same underlying
`Z1`/`Z2`/`Z3` layers means every model type below is fit on data that
differ only in how the layers are packaged, not in what they contain.

## 5) Add Missingness for Diagnostics and Missing-Data Examples

Multi-omics studies rarely have complete data: whole layers can be missing
for a subject (e.g. a blood sample was never collected -- "listwise"
missingness within that layer), or a handful of individual features can be
missing sporadically. LUCID's EM fitting handles both patterns natively --
the missing-data helpers in section 6 exist to let you check that handling,
not to do it manually. This chunk injects both patterns into copies of the
data built above, purely so the diagnostics in section 6 have something to
report on.

```{r add-missingness}
# Early matrix missingness
Z_early_miss <- d$Z_early
Z_early_miss[1, ] <- NA          # listwise
Z_early_miss[2:4, 1] <- NA       # sporadic block
Z_early_miss[5, 3] <- NA         # sporadic cell

# Parallel list missingness
Z_parallel_miss <- d$Z_parallel
Z_parallel_miss[[1]][1, ] <- NA  # listwise on layer1
Z_parallel_miss[[2]][2, 2] <- NA # sporadic on layer2

# Serial mixed missingness (stage1 parallel + stage2 early)
Z_serial_miss <- list(
  list(
    layer1 = Z_parallel_miss[[1]],
    layer2 = Z_parallel_miss[[2]]
  ),
  {tmp <- d$Z3; tmp[3, ] <- NA; tmp}
)
```

## 6) Missing-Data Diagnostics and Imputation

`estimate_lucid()` imputes missing omics values internally, conditional on
the current model fit, as part of its EM loop -- there is no separate
"impute first, then fit" step for you to run. What the two functions below
give you is *visibility* into that process: whether the missingness pattern
looks the way you expect, and whether a quick standalone imputation of the
same data is directionally sane.

### 6.1 `analyze_missing_pattern()` and `check_na()`

`analyze_missing_pattern()` reports how much data is missing and how it is
distributed; `check_na()` additionally classifies, per subject and per
omics layer, which missingness pattern applies (fully observed, listwise
missing, or sporadically missing) -- the same three-way classification the
EM loop itself uses internally to decide how to handle each subject.

```{r missing-diagnostics}
miss_info_early <- analyze_missing_pattern(Z_early_miss)
na_early <- check_na(Z_early_miss, lucid_model = "early")
na_parallel <- check_na(Z_parallel_miss, lucid_model = "parallel")

miss_info_early$total_missing
table(na_early$indicator_na)
na_parallel$cross_layer_summary
```

`miss_info_early$total_missing` should match the number of `NA`s injected
above (6, from the 1 listwise row, the 3-cell sporadic block, and the 1
sporadic cell). `table(na_early$indicator_na)` cross-tabulates subjects by
pattern; `na_parallel$cross_layer_summary` shows the same thing per layer for
the parallel structure, which is the number to check when a specific layer
is suspected of being a bigger driver of missingness than the others.

### 6.2 `safe_impute()` and `check_imputation_quality()`

`safe_impute()` is a standalone imputation you can run outside of model
fitting -- useful for a quick look at the data, or for a workflow that needs
a complete matrix for some other purpose before ever calling
`estimate_lucid()`. `check_imputation_quality()` then scores how plausible
the imputed values are relative to the observed distribution of each column.

```{r impute-helpers}
# Use a sub-matrix for concise display
orig_sub <- Z_early_miss[, 1:4, drop = FALSE]
imputed_sub <- safe_impute(orig_sub, method = "mean")
quality_sub <- check_imputation_quality(orig_sub, imputed_sub)

quality_sub$overall_quality
```

`overall_quality` summarizes, across all imputed cells, how close the filled
values landed to each column's observed range and variance -- a low score
here would flag an imputation that looks implausible (e.g. a filled value
far outside the observed range), which is worth knowing about a data set
even before it reaches `estimate_lucid()`. `estimate_lucid()`'s own internal
imputation is more informed than this standalone version -- it fills a
missing value conditional on the fitted cluster structure and covariance,
re-estimated at every EM iteration, rather than with a single fixed
column statistic -- but the two are checking the same basic question: are
the filled-in values sane?

## 7) Core Model Fitting: `estimate_lucid()`

`estimate_lucid()` is the single entry point for fitting any of the three
architectures at one fixed `K` (number of clusters) and one fixed penalty.
`lucid_model` picks the architecture; everything else (`G`, `Z`, `Y`,
`family`, `K`, the `Rho_*` penalties) has the same meaning across all three.
`Rho_G` penalizes the `G -> cluster` coefficients toward zero (an exposure
whose coefficient is driven to exactly zero is "not selected"); `Rho_Z_Mu`
and `Rho_Z_Cov` do the analogous thing for the omics mean/covariance
structure, so weakly-differentiating features can be zeroed out of the
cluster definition. This guide fits at `Rho_* = 0` throughout for simplicity
and speed; the 3-model tutorials walk through the penalized-screen-then-
zero-penalty-refit workflow that real feature selection uses.

### 7.1 Early model

Early integration concatenates every omics layer into one matrix and fits
**one** cluster variable from the pooled features -- the right choice when
you expect a single latent state to manifest across all your omics layers
at once.

```{r fit-early, warning=FALSE}
set.seed(101)
fit_early <- estimate_lucid(
  lucid_model = "early",
  G = d$G,
  Z = Z_early_miss,
  Y = d$Y_normal,
  CoG = d$CoG,
  CoY = d$CoY,
  family = "normal",
  K = 2,
  Rho_G = 0,
  Rho_Z_Mu = 0,
  Rho_Z_Cov = 0,
  max_itr = 8,
  max_tot.itr = 30,
  tol = 1e-2,
  seed = 101,
  verbose = FALSE
)

class(fit_early)
```

Every field below is documented on `?estimate_lucid`'s `@return`; the same
field names and shapes are used across all three model types where a field
applies to more than one. Rather than reading `fit_early$select$selectG`
directly, use `get_selected_G()`/`get_selected_Z()` -- they return the same
information as a named vector and auto-detect the model type, so the same
call works unchanged if `fit_early` were swapped for a parallel or serial
fit later in a script.

```{r fit-early-structure}
names(fit_early)
cat("likelihood:", fit_early$likelihood, "\n")
cat("selected G:", sum(get_selected_G(fit_early)), "of",
    length(get_selected_G(fit_early)), "\n")
cat("selected Z:", sum(get_selected_Z(fit_early)), "of",
    length(get_selected_Z(fit_early)), "\n")
```

At `Rho_G = Rho_Z_Mu = 0` nothing is actually penalized out, so every
exposure and every omics feature is reported "selected" here -- the counts
become informative once a positive penalty is applied, as in the 3-model
tutorials' screening step.

### 7.2 Parallel model

Parallel integration fits a **separate** cluster variable per omics layer,
jointly, rather than pooling the layers into one. Use this when the layers
plausibly reflect different underlying processes -- e.g. a metabolomic
subtype and a methylation subtype need not coincide -- and you want each to
be discovered on its own terms rather than forced into a single joint
cluster.

```{r fit-parallel, warning=FALSE}
set.seed(102)
fit_parallel <- estimate_lucid(
  lucid_model = "parallel",
  G = d$G,
  Z = Z_parallel_miss,
  Y = d$Y_normal,
  CoG = d$CoG,
  CoY = d$CoY,
  family = "normal",
  K = c(2, 2),
  Rho_G = 0,
  Rho_Z_Mu = 0,
  Rho_Z_Cov = 0,
  max_itr = 8,
  max_tot.itr = 30,
  tol = 1e-2,
  seed = 102,
  verbose = FALSE
)

class(fit_parallel)
```

Two fields differ from the early fit above: `N` (sample size) is present
here but not for early, and `z` (the joint E-step responsibility array
across layers, before it is marginalized into `inclusion.p`) appears only
for parallel. `get_selected_G()` takes an optional `layer` argument here,
since a parallel fit can select a different exposure subset per layer.

```{r fit-parallel-structure}
names(fit_parallel)
cat("N:", fit_parallel$N, "\n")
cat("selected G per layer:", paste(sapply(seq_along(fit_parallel$K), function(i)
                                     sum(get_selected_G(fit_parallel, layer = i))),
                                   collapse = ", "), "\n")

sel_z_parallel <- get_selected_Z(fit_parallel)
cat("selected Z per layer:", paste(sapply(sel_z_parallel, sum), collapse = ", "), "\n")
```

`get_selected_Z()` returns a list here, one logical vector per layer, since
"which omics features are selected" is itself a per-layer question for a
parallel fit.

### 7.3 Serial model (mixed topology)

Serial integration chains stages together: stage 1's cluster assignment
becomes (part of) stage 2's input, and so on. It suits a mediation-like
hypothesis -- e.g. exposures act on an early biological layer, which in turn
shapes a later one, which in turn shapes the outcome. Any stage can itself
be early or parallel; the fit below mixes a parallel first stage (two
omics layers) with an early second stage (one layer), which is the most
general configuration the package supports.

```{r fit-serial, warning=FALSE}
set.seed(103)
fit_serial <- estimate_lucid(
  lucid_model = "serial",
  G = d$G,
  Z = Z_serial_miss,
  Y = d$Y_normal,
  CoG = d$CoG,
  CoY = d$CoY,
  family = "normal",
  K = list(list(2, 2), 2),
  Rho_G = 0,
  Rho_Z_Mu = 0,
  Rho_Z_Cov = 0,
  max_itr = 8,
  max_tot.itr = 36,
  tol = 1e-2,
  seed = 103,
  verbose = FALSE
)

class(fit_serial)
length(fit_serial$submodel)
```

Serial adds `submodel` (the fitted stage models, each a complete
`early_lucid`/`lucid_parallel` object) and `res_Delta` (between-stage
transition coefficients). `likelihood` and `select` are present, same as
early/parallel, but are aggregates: `likelihood` sums each stage's own
log-likelihood (no single joint EM loop exists to report one from), and
`select` is stage 1's own selection only -- the one stage whose `G` is the
cohort's real exposures, not a previous stage's cluster probabilities.
`get_selected_G()` on a serial fit always returns stage 1's selection for
the same reason; `get_selected_Z()` returns a list, one element per stage,
shaped like whatever that stage's own architecture is (a vector for an
early stage, a list of layers for a parallel stage).

```{r fit-serial-structure}
names(fit_serial)
cat("top-level likelihood (sum over stages):", fit_serial$likelihood, "\n")
cat("top-level select is stage 1's select:",
    identical(fit_serial$select, fit_serial$submodel[[1]]$select), "\n")

sel_z_serial <- get_selected_Z(fit_serial)
cat("stage 1 (parallel) selected Z per layer:",
    paste(sapply(sel_z_serial[[1]], sum), collapse = ", "), "\n")
cat("stage 2 (early) selected Z:", sum(sel_z_serial[[2]]), "\n")
```

### 7.4 Verbose Logging Demos (`verbose = TRUE`)

`verbose = TRUE` prints one line per EM iteration -- the current
log-likelihood and, where relevant, the change since the last iteration --
which is the fastest way to confirm a fit is actually converging (steadily
increasing, then flattening) rather than diverging or oscillating. The fits
below are intentionally lightweight (a 30-subject subsample, tiny iteration
caps) and only serve to show what that log looks like for each architecture.

```{r fit-verbose-demos, warning=FALSE}
n_demo <- 30
G_demo <- d$G[1:n_demo, , drop = FALSE]
Y_demo <- d$Y_normal[1:n_demo]
CoG_demo <- d$CoG[1:n_demo, , drop = FALSE]
CoY_demo <- d$CoY[1:n_demo, , drop = FALSE]
Z_early_demo <- d$Z_early[1:n_demo, , drop = FALSE]
Z_parallel_demo <- lapply(d$Z_parallel, function(z) z[1:n_demo, , drop = FALSE])
Z_serial_demo <- list(
  lapply(d$Z_parallel, function(z) z[1:n_demo, , drop = FALSE]),
  d$Z3[1:n_demo, , drop = FALSE]
)

set.seed(111)
fit_early_verbose <- estimate_lucid(
  lucid_model = "early",
  G = G_demo,
  Z = Z_early_demo,
  Y = Y_demo,
  CoG = CoG_demo,
  CoY = CoY_demo,
  family = "normal",
  K = 2,
  Rho_G = 0,
  Rho_Z_Mu = 0,
  Rho_Z_Cov = 0,
  max_itr = 2,
  max_tot.itr = 8,
  tol = 1e-2,
  seed = 111,
  verbose = TRUE
)

set.seed(112)
fit_parallel_verbose <- estimate_lucid(
  lucid_model = "parallel",
  G = G_demo,
  Z = Z_parallel_demo,
  Y = Y_demo,
  CoG = CoG_demo,
  CoY = CoY_demo,
  family = "normal",
  K = c(2, 2),
  Rho_G = 0,
  Rho_Z_Mu = 0,
  Rho_Z_Cov = 0,
  max_itr = 2,
  max_tot.itr = 8,
  tol = 1e-2,
  seed = 112,
  verbose = TRUE
)

set.seed(113)
fit_serial_verbose <- estimate_lucid(
  lucid_model = "serial",
  G = G_demo,
  Z = Z_serial_demo,
  Y = Y_demo,
  CoG = CoG_demo,
  CoY = CoY_demo,
  family = "normal",
  K = list(list(2, 2), 2),
  Rho_G = 0,
  Rho_Z_Mu = 0,
  Rho_Z_Cov = 0,
  max_itr = 2,
  max_tot.itr = 10,
  tol = 1e-2,
  seed = 113,
  verbose = TRUE
)
```

## 8) Tuning and Wrapper APIs: `tune_lucid()` and `lucid()`

Choosing `K` and the penalty strengths by hand, as section 7 did, only works
when you already have a good guess. `tune_lucid()` instead fits a whole grid
of `K`/`Rho_*` combinations and reports each one's BIC, so the combination
that best balances fit against model complexity can be selected
systematically rather than by trial and error. `lucid()` goes one step
further: it runs the same tuning search, picks the BIC-best candidate, and
-- importantly -- **refits that winner at zero penalty**, so the returned
model's coefficients are not shrunk by the same penalty that was used only
to decide which features to keep. That two-step logic (penalized search for
selection, zero-penalty refit for estimation) is worth remembering; the
3-model tutorials walk through doing it by hand for cases where more control
over the refit is needed than `lucid()`'s default gives.

### 8.1 `tune_lucid()` with a small early-model grid

```{r tune-lucid, warning=FALSE}
set.seed(104)

tune_early <- tune_lucid(
  G = d$G,
  Z = d$Z_early,
  Y = d$Y_normal,
  CoG = d$CoG,
  CoY = d$CoY,
  family = "normal",
  lucid_model = "early",
  K = 2:3,
  Rho_G = 0,
  Rho_Z_Mu = 0,
  Rho_Z_Cov = 0,
  max_itr = 6,
  max_tot.itr = 24,
  seed = 104
)

head(tune_early$tune_list)
```

`tune_list` has one row per grid candidate, with its fitted `K`/penalty
combination and its BIC; the lowest BIC in this table is the candidate
`lucid()` (below) would have selected automatically.

### 8.2 `lucid()` wrapper (auto-tune over K for early model)

```{r lucid-wrapper, warning=FALSE}
set.seed(105)

fit_lucid_wrapper <- lucid(
  G = d$G,
  Z = d$Z_early,
  Y = d$Y_normal,
  CoG = d$CoG,
  CoY = d$CoY,
  family = "normal",
  lucid_model = "early",
  K = 2:3,
  Rho_G = 0,
  Rho_Z_Mu = 0,
  Rho_Z_Cov = 0,
  max_itr = 6,
  max_tot.itr = 24,
  seed = 105
)

class(fit_lucid_wrapper)
```

`fit_lucid_wrapper` is a complete `early_lucid` object -- the same class
`estimate_lucid()` would have returned had `K = 2:3`'s BIC-winner been fit
directly -- so it works with every function in this guide (`summary()`,
`predict_lucid()`, the `get_*()` extractors) exactly like any other fit.

## 9) Summaries: `summary()`

`summary()` is the primary way to read a fitted model's results without
digging through its raw list structure. For every model type it prints, in
order: the model specification (family, sample size, `K`); a feature-
selection overview (how many exposures/omics features survived, if a
penalty was used); the `G -> cluster` coefficients (with odds ratios); the
cluster-specific omics means; and the `cluster -> Y` coefficients. When a
`boot.se`/bootstrap object is supplied (section 11), every one of those
coefficient tables additionally gets a `sig` column marking rows whose
95%-normal-theory confidence interval excludes 0 with `"*"` -- a quick visual
scan for which effects are distinguishable from no effect at all, without
reading every interval by eye.

```{r summaries, warning=FALSE}
summary(fit_early)
summary(fit_parallel)
summary(fit_serial)
```

At `Rho_* = 0` the selection overview above reports everything selected (as
noted in section 7); with a positive penalty, this is the table that shows
how much was screened out and at what rate.

## 10) Prediction: `predict_lucid()`

`predict_lucid()` runs a fitted model's E-step on new (or the same) `G`/`Z`
data to obtain posterior cluster probabilities and, from those, a predicted
outcome. It is what you use to score subjects who were not part of the
fitting sample, or to double-check a model's assignments on its own training
data.

### 10.1 Standard prediction

```{r predict-standard, warning=FALSE}
# Use lightweight no-covariate fits for robust prediction demo.
set.seed(205)
fit_early_pred <- estimate_lucid(
  lucid_model = "early",
  G = d$G,
  Z = d$Z_early,
  Y = d$Y_normal,
  family = "normal",
  K = 2,
  max_itr = 6,
  max_tot.itr = 20,
  tol = 1e-2,
  seed = 205
)

set.seed(206)
fit_parallel_pred <- estimate_lucid(
  lucid_model = "parallel",
  G = d$G,
  Z = d$Z_parallel,
  Y = d$Y_normal,
  family = "normal",
  K = c(2, 2),
  max_itr = 6,
  max_tot.itr = 20,
  tol = 1e-2,
  seed = 206
)

set.seed(207)
fit_serial_pred <- estimate_lucid(
  lucid_model = "serial",
  G = d$G,
  Z = d$Z_serial_mixed,
  Y = d$Y_normal,
  family = "normal",
  K = list(list(2, 2), 2),
  max_itr = 6,
  max_tot.itr = 24,
  tol = 1e-2,
  seed = 207
)

pred_early <- predict_lucid(
  model = fit_early_pred,
  G = d$G,
  Z = d$Z_early,
  Y = d$Y_normal
)

pred_parallel <- predict_lucid(
  model = fit_parallel_pred,
  G = d$G,
  Z = d$Z_parallel,
  Y = d$Y_normal
)

pred_serial <- predict_lucid(
  model = fit_serial_pred,
  G = d$G,
  Z = d$Z_serial_mixed,
  Y = d$Y_normal
)

# Cluster assignment. pred.x is a vector for early and a list -- by layer for
# parallel, by stage for serial -- so the blocks are summarised in one place.
# pred.x nests: a vector for early, a list by layer for parallel, and for
# serial a list by stage whose elements are themselves lists when that stage is
# a parallel submodel. Flatten to the leaves so each cluster variable is counted
# on its own -- pooling a parallel stage's layers would report twice as many
# assignments as there are subjects.
flatten_blocks <- function(x, path = "") {
  if (!is.list(x)) return(stats::setNames(list(as.numeric(x)), path))
  out <- list()
  for (i in seq_along(x)) {
    nm <- if (nzchar(path)) paste0(path, ".", i) else as.character(i)
    out <- c(out, flatten_blocks(x[[i]], nm))
  }
  out
}

cluster_sizes <- function(pred_x, label) {
  blocks <- flatten_blocks(pred_x)
  nms <- names(blocks)
  # index by position: the single block of an early model is named "", and
  # blocks[[""]] does not select anything.
  do.call(rbind, lapply(seq_along(blocks), function(i) {
    tb <- table(factor(blocks[[i]]))
    data.frame(model = label,
               block = if (!nzchar(nms[i])) "-" else nms[i],
               cluster = names(tb), n = as.integer(tb), row.names = NULL)
  }))
}

rbind(
  cluster_sizes(pred_early$pred.x,    "early"),
  cluster_sizes(pred_parallel$pred.x, "parallel"),
  cluster_sizes(pred_serial$pred.x,   "serial")
)
```

The table above reports how many subjects fall in each cluster, per model
and (for parallel/serial) per layer/stage block. Roughly balanced counts
here are expected for this simulation's roughly 50/50 latent split; a wildly
unbalanced split can be a sign that `K` is larger than the data actually
supports.

`predict_lucid()`'s `pred.x` above required re-running the E-step on `G`/
`Z`. When the same fitted-model hard assignment is wanted directly, without
supplying data again, `get_cluster_assignment()` reads it straight off the
fitted object's own posterior (`inclusion.p`):

```{r cluster-assignment-extractor}
identical(as.numeric(get_cluster_assignment(fit_early_pred)),
          as.numeric(pred_early$pred.x))
```

The two agree here because `pred_early` was computed on the same `G`/`Z` the
model was fit on. `get_cluster_assignment()` is the right tool whenever the
question is simply "what did this fitted model assign its subjects to,"
while `predict_lucid()` is for scoring genuinely new data or getting a
predicted outcome as well as an assignment.

Predicted outcomes, on the scale of the outcome that was modelled:

```{r predict-standard-y}
outcome_summary <- function(pred_y, label) {
  v <- as.numeric(unlist(pred_y))
  data.frame(
    model  = label,
    n      = length(v),
    mean   = round(mean(v), 3),
    sd     = round(stats::sd(v), 3),
    min    = round(min(v), 3),
    median = round(stats::median(v), 3),
    max    = round(max(v), 3),
    row.names = NULL
  )
}

rbind(
  outcome_summary(pred_early$pred.y,    "early"),
  outcome_summary(pred_parallel$pred.y, "parallel"),
  outcome_summary(pred_serial$pred.y,   "serial")
)
```

The predicted outcome is a posterior-weighted average of the cluster-specific
outcome levels, so its spread is narrower than the observed outcome's: it
carries no residual variation, only the between-cluster differences. Comparing
its range against `summary(d$Y_normal)` shows how much of the outcome the latent
structure accounts for.

```{r predict-standard-vs-observed}
rbind(
  outcome_summary(pred_early$pred.y, "predicted (early)"),
  outcome_summary(d$Y_normal,        "observed")
)
```

### 10.2 G-computation mode (early, parallel, and serial)

`g_computation = TRUE` switches to a different question: instead of scoring
observed `(G, Z)` pairs, it asks what the outcome distribution *would be* for
a given exposure profile, marginalizing over the fitted cluster and omics
model -- a simple causal "what if this subject's exposure had been X"
calculation built on top of the fitted parameters. This is why it needs only
`G` (no `Z`, no `Y`) and why it alone returns `pred.z`, the implied omics
profile under that hypothetical exposure.

```{r predict-gcomp, warning=FALSE}
pred_early_g <- predict_lucid(
  model = fit_early_pred,
  G = d$G,
  Z = NULL,
  Y = NULL,
  g_computation = TRUE
)

pred_parallel_g <- try(
  predict_lucid(
    model = fit_parallel_pred,
    G = d$G,
    Z = NULL,
    Y = NULL,
    g_computation = TRUE
  ),
  silent = TRUE
)

names(pred_early_g)
if (inherits(pred_parallel_g, "try-error")) {
  "parallel g_computation returned a try-error on this demo object; code pattern is shown above."
} else {
  names(pred_parallel_g)
}

pred_serial_g <- predict_lucid(
  model = fit_serial_pred,
  G = d$G,
  Z = NULL,
  Y = NULL,
  g_computation = TRUE
)

names(pred_serial_g)
length(pred_serial_g$pred.z)
```

### 10.3 Argument Modes: What to Pass and What You Get Back

`Z` is required for every model type. Only `g_computation = TRUE` relaxes it.
`Y` is always optional; omitting it makes the prediction unsupervised.

| `Z` | `Y` | `g_computation` | Result |
|---|---|---|---|
| supplied | supplied | `FALSE` | Supervised: outcome informs the posterior |
| supplied | omitted | `FALSE` | Unsupervised: clusters from `G` and `Z` only |
| omitted | either | `FALSE` | Error naming `Z` and `g_computation` |
| omitted | omitted | `TRUE` | Counterfactual prediction from `G` alone |

The posterior is formed from the exposure, omics and outcome likelihood terms.
Dropping `Y` removes one term and leaves a well-defined posterior over the rest.
Dropping `Z` removes the term the clusters are defined by, leaving nothing to
condition on. `g_computation = TRUE` is a separate estimator that uses only the
exposure path, which is why it accepts `Z = NULL` and why it alone returns
`pred.z`; supplied `Z` and `Y` are ignored in that mode.

Returned components, and their shape by model type:

| Component | Meaning | early | parallel | serial |
|---|---|---|---|---|
| `inclusion.p` | Posterior cluster probabilities | `N` x `K` matrix | list by layer | list by stage |
| `pred.x` | Cluster labels, `1..K` since 3.1.0 | vector | list by layer | list by stage |
| `pred.y` | Predicted outcome | vector | vector | vector |
| `pred.z` | Implied omics profile, `g_computation` only | matrix | list by layer | list by stage |

Reusing the fits from 10.1, with no refitting:

```{r predict-modes}
pred_unsup <- predict_lucid(
  model = fit_early_pred,
  G = d$G,
  Z = d$Z_early
)

missing_z <- try(
  predict_lucid(
    model = fit_early_pred,
    G = d$G,
    Z = NULL,
    Y = d$Y_normal
  ),
  silent = TRUE
)

data.frame(
  mode = c("Y omitted (unsupervised)", "Z omitted, no g-computation"),
  result = c(
    paste(names(pred_unsup), collapse = ", "),
    if (inherits(missing_z, "try-error")) "error, as documented" else "unexpectedly succeeded"
  )
)

cat(if (inherits(missing_z, "try-error")) attr(missing_z, "condition")$message else "")
```

A serial model must have at least two stages to be predicted. A single-stage
serial model is an equivalent early or parallel model and should be fitted as
one; prediction declines it with a message to that effect.

## 11) Bootstrap Inference: `boot_lucid()`

Point estimates from `estimate_lucid()` come with no standard errors --
`boot_lucid()` supplies them by nonparametric bootstrap: it resamples
subjects with replacement, refits the model on each resample, and forms
confidence intervals from the resulting distribution of estimates. Because
that resampling-and-refitting only makes sense for stable, unpenalized
estimates, `boot_lucid()` is meant to be run on a zero-penalty refit (the
model whose coefficients are not shrunk by a selection penalty), not on the
penalized screening fit itself -- the 3-model tutorials' screen-then-refit
workflow produces exactly that kind of model. This guide bootstraps
`fit_early`/`fit_parallel`/`fit_serial` directly since they were already fit
at `Rho_* = 0`.

Runtime note: this section uses small `R` for tutorial speed; a real analysis
needs enough bootstrap replicates (typically several hundred) for the
resulting intervals to be stable.

```{r bootstrap-all, warning=FALSE}
set.seed(106)

boot_early <- boot_lucid(
  G = d$G,
  Z = Z_early_miss,
  Y = d$Y_normal,
  CoG = d$CoG,
  CoY = d$CoY,
  model = fit_early,
  R = 2,
  conf = 0.9
)

boot_parallel <- boot_lucid(
  G = d$G,
  Z = Z_parallel_miss,
  Y = d$Y_normal,
  CoG = d$CoG,
  CoY = d$CoY,
  model = fit_parallel,
  R = 2,
  conf = 0.9
)

boot_serial <- boot_lucid(
  G = d$G,
  Z = Z_serial_miss,
  Y = d$Y_normal,
  CoG = d$CoG,
  CoY = d$CoY,
  model = fit_serial,
  R = 2,
  conf = 0.9
)

summary(fit_early, boot.se = boot_early)
summary(fit_parallel, boot.se = boot_parallel)
summary(fit_serial, boot.se = boot_serial)
```

Each coefficient table above now shows a normal-theory confidence interval
alongside the point estimate, plus the `sig` column described in section 9.
With only `R = 2` replicates here the interval is not meaningful -- this
chunk exists to show the *output format*, not to draw
real conclusions; a real bootstrap needs `R` in the hundreds.

## 12) Visualization

### 12.1 Path Diagram: `plot()`

`plot()` draws a Sankey diagram of the fitted model's structure: flows from
each exposure, through the latent cluster(s), to the outcome, with flow
width proportional to the estimated association strength. It is a
structural view -- what connects to what, and how strongly -- complementary
to the omics-profile view in the next section, which shows what the
clusters actually look like in the omics data.

```{r plotting, warning=FALSE}
plot_early <- plot(fit_early)
plot_parallel <- try(plot(fit_parallel), silent = TRUE)
plot_serial <- try(plot(fit_serial), silent = TRUE)

class(plot_early)
if (inherits(plot_parallel, "try-error")) {
  "plot(fit_parallel) returned try-error in this build (parallel plot is under development)."
} else {
  class(plot_parallel)
}
if (inherits(plot_serial, "try-error")) {
  "plot(fit_serial) returned try-error in this build (serial plot is under development)."
} else {
  class(plot_serial)
}
```

### 12.2 Cluster Omics Profiles: `plot_cluster_omic_profile()`

`plot()` draws the path structure. `plot_cluster_omic_profile()` draws what the
clusters actually *are*: which omics features separate them, and in which
direction. It returns a named list of `ggplot` objects, one per omics layer, so
a parallel or serial fit gives one figure per layer rather than one crowded one.

Features are ranked by `importance`. The default, `"separation"`, is the spread
of the cluster means divided by the typical within-cluster spread; `"range"` and
`"sd"` use the means alone. Only the standardized measure can distinguish a
feature that separates the clusters from one that is merely noisy.

| Argument | Effect |
|---|---|
| `type` | `"heatmap"` (default) or `"bar"` |
| `top_n` | Features per panel, default 10; a layer with fewer shows all |
| `importance` | `"separation"` (default), `"range"`, `"sd"` |
| `layer_names` | Subtitles; defaults to the names of the omics list |
| `layer_colors` | One hue per layer |
| `scale` | `TRUE` (default) fills with a per-feature z-score; `FALSE` with the cluster mean |

```{r profile-heatmap-early, fig.width = 6, fig.height = 4.5}
prof_early <- plot_cluster_omic_profile(fit_early, top_n = 8)
names(prof_early)
prof_early[[1]]
```

Read the heatmap by row: a feature whose color alternates sharply between
clusters (e.g. dark for cluster 1, light for cluster 2) is one that strongly
distinguishes them; a feature with similar shading across clusters is barely
contributing to the separation despite appearing in the top-`n` list.

The bar rendering shows the same features and ordering, with clusters as
shades of the layer's colour:

```{r profile-bar-early, fig.width = 6.5, fig.height = 4.5}
plot_cluster_omic_profile(fit_early, type = "bar", top_n = 8)[[1]]
```

A parallel fit returns one plot per layer:

```{r profile-parallel, fig.width = 6.5, fig.height = 4.5}
prof_parallel <- plot_cluster_omic_profile(fit_parallel, top_n = 8)
names(prof_parallel)
prof_parallel[[1]]
```

```{r profile-parallel-bar, fig.width = 6.5, fig.height = 4.5}
plot_cluster_omic_profile(fit_parallel, type = "bar", top_n = 8)[[2]]
```

A serial fit gives one plot per stage, and one per layer within a stage that is
itself a parallel sub-model:

```{r profile-serial, fig.width = 6.5, fig.height = 4.5}
prof_serial <- plot_cluster_omic_profile(fit_serial, top_n = 8)
names(prof_serial)
prof_serial[[1]]
```

```{r profile-serial-bar, fig.width = 6.5, fig.height = 4.5}
plot_cluster_omic_profile(fit_serial, type = "bar", top_n = 8)[[2]]
```

### 12.3 Extracting the Ranking Directly: `get_top_omics_features()`

The importance ranking behind every panel above is also available as plain
data, without generating a plot -- useful for a report table, or for feeding
the top features into a downstream analysis. `get_top_omics_features()`
uses the exact same ranking criterion as `plot_cluster_omic_profile()`
(so its output for a given `top_n` matches that plot's panel ordering), and,
like the other extractors, auto-detects the model type and returns one named
numeric vector per layer/stage.

```{r top-omics-features}
top_early <- get_top_omics_features(fit_early, top_n = 5)
top_early
```

The names are the features, in descending order of importance score; the
values are the score itself (by default, the between-cluster separation
described above). This is the same top-5 that would appear in a
`plot_cluster_omic_profile(fit_early, top_n = 5)` panel, just as a plain
vector rather than a figure.

## 13) Binary Outcome Example (Early Model)

Everything above used a continuous outcome (`family = "normal"`). Switching
to `family = "binary"` changes only the outcome model: the cluster effects
on `Y` become log-odds (with odds ratios reported alongside), there is no
residual variance to estimate, and `predict_lucid()`'s `response` argument
lets you choose class-label or probability output. This short chunk shows
that fit and a probability-scale prediction.

```{r binary-example, warning=FALSE}
set.seed(107)
fit_early_binary <- estimate_lucid(
  lucid_model = "early",
  G = d$G,
  Z = d$Z_early,
  Y = d$Y_binary,
  CoG = d$CoG,
  CoY = d$CoY,
  family = "binary",
  K = 2,
  max_itr = 8,
  max_tot.itr = 30,
  tol = 1e-2,
  seed = 107
)

pred_binary_prob <- predict_lucid(
  model = fit_early_binary,
  G = d$G,
  Z = d$Z_early,
  Y = d$Y_binary,
  CoG = d$CoG,
  CoY = d$CoY,
  response = FALSE
)

range(pred_binary_prob$pred.y)
```

`pred.y` here is a probability in `[0, 1]` because `response = FALSE`;
setting `response = TRUE` instead would return hard `0`/`1` class labels.

## 14) End Notes

- This guide focuses on breadth (all major exported APIs) while keeping runtime practical.
- For production analysis, you should usually increase:
  - `max_itr`, `max_tot.itr`
  - bootstrap `R`
  - tuning grid size in `tune_lucid()`
- For serial pipelines, keep a close eye on stage-wise missingness summaries.
- Under the hood, the EM optimizer that every fit in this guide runs on
  applies standard numerical-stability safeguards -- computing sums of
  probabilities in log space to avoid underflow, regularizing
  near-singular covariance matrices, and monitoring the log-likelihood at
  every iteration to catch non-convergence -- automatically, with no
  action needed from the caller. These are internal implementation details
  rather than part of the public API, so they are not demonstrated here.

## 15) Session Info

```{r}
sessionInfo()
```
