8  Module 3: Unsupervised Subtyping — Advanced

The advanced Subtyping workflow normalizes independent development and validation sets, compares K-means, latent profile analysis (LPA), and NMF, and evaluates t-SNE/UMAP structure, internal cluster quality, cross-method agreement, marker profiles, silhouettes, alluvial relationships, and deployment to new samples.

8.1 Preventing Information Leakage

Split the data first, fit min–max parameters on the development set, and apply them unchanged to validation data. The workflow also checks numeric types and removes zero-variance features. Embeddings are exploratory: choose and validate a subtype solution using stability, agreement, marker reproducibility, and clinical usefulness rather than visual separation alone.

8.2 Step-by-step Workflow

The workflow is divided into short stages. Each note explains the purpose, key checks, and interpretation limits. Generated plots and tables appear directly below their code chunk.

8.2.1 About this chapter

Audience. Advanced users who want to compare multiple clustering methods, validate them, and produce publication-style figures.

Input. Requires stat_obj.rds, produced by Module 1.

The chapter runs end to end as one session:

  1. CONFIG
  2. Load Data and Convert to a Subtyping Object
  3. Split into Train / Validation Sets, then Normalize
  4. Clustering — Three Complementary Methods
    • Estimate the best rank (= number of subtypes) across a candidate range.
    • Pick the best rank from the estimation above.
    • Assign each training sample to a subtype.
    • Refit with more NMF runs and save a re-usable model object, so new
  5. Predict Subtypes on the Validation Split
  6. Dimensionality Reduction for Visualization (t-SNE & UMAP)
  7. Clustering Quality Evaluation
  8. Consistency Between the Three Clustering Methods
  9. Subtype-Specific Marker Features & Enhanced Visualization
  10. Deployment: Predict Subtypes for New Samples
  11. Save Final Objects

8.2.2 0 CONFIG

This section defines seeds, output paths, and dependencies. Update these paths and key parameters first when using new data.

Code
# 0. CONFIG
# ---------------------------------------------------------------------------
rm(list = ls())
library(icare)

INPUT_STAT_OBJ  <- "stat_obj.rds"
OUTPUT_DIR      <- "./Module3_Advanced_Output"
GROUP_COL       <- "group"   # the outcome/group column carried over from Module 1
SEED            <- 123

dir.create(OUTPUT_DIR, showWarnings = FALSE, recursive = TRUE)
# Register OUTPUT_DIR with the package rather than defining a local sub_dir().
# icare already ships sub_dir(); a local definition shadows it only inside this
# script, so functions INSIDE the package that call sub_dir() themselves would
# still resolve it in the icare namespace and write somewhere else. Calling
# set_output_root() points both this script and the package at the same tree.
set_output_root(OUTPUT_DIR)

# =============================================================================

8.2.3 1 Load Data and Convert to a Subtyping Object

This section imports and organizes the data. Check dimensions and variable coding before continuing.

Code
# 1. Load Data and Convert to a Subtyping Object
# =============================================================================
stat_obj <- readRDS(INPUT_STAT_OBJ)
InspectObject(stat_obj)
==========================================
  Object class : Stat 
==========================================
  raw.data           : 601 x 25 
  clean.data         : 601 x 25 
  info.data          : 601 x 23 
  scale.data         : 601 x 25 
  group_col          : group 
  n features         : 25 
  variable.types     : (3 element(s): numeric_vars, categorical_vars, vars_to_encode) 
  compute.descriptive: (7 element(s): Group_Counts, Count_Results, Num_Results, Normality_Test, count_plots, violin_plots, ridge_plots) 
  corr.result        : (empty) 
  process.info       : (6 element(s): missing_count, missing_info, outlier_detection, outlier_handling, normalization, normalization_params) 
==========================================
Code
sub_obj <- ConvertObject(stat_obj, to = "Subtyping")
Data prepared for clean.data with 601 rows and 25 columns.
Data prepared for scale.data with 601 rows and 25 columns.
Subtyping object created successfully.
Stat -> Subtyping conversion complete.
Code
InspectObject(sub_obj)
==========================================
  Object class : Subtyping 
==========================================
  clean.data         : 601 x 25 
  info.data          : 601 x 23 
  scale.data         : 601 x 25 
  clustered.data     : empty 
  Optimal.cluster    :  
  cluster.results    : (NULL) 
  visualization.res  : (NULL) 
  evaluation_results : (empty) 
==========================================
Code
features=colnames(sub_obj@clean.data)[colnames(sub_obj@clean.data)!='sex']
sub_obj@clean.data <- sub_obj@clean.data[, features, drop = FALSE]
# =============================================================================

8.2.4 2 Split into Train / Validation Sets, then Normalize

This section preprocesses the data. Review missingness, outlier handling, and normalization before accepting the defaults.

Code
# 2. Split into Train / Validation Sets, then Normalize
# =============================================================================
# IMPORTANT: split BEFORE fitting any normalization parameters — mirrors the
# pattern used in Module 2 (createDataPartition first, then preProcess() fit
# on the training split only, never on the full dataset). Fitting min-max
# parameters on the full data (train+validation combined) and only THEN
# splitting would let validation-set values leak into the scaling applied to
# the validation set itself — the opposite of what we want.
set.seed(SEED)
split <- SplitSubtypingObject(sub_obj, p = 0.7, stratify_by = GROUP_COL)
Data prepared for clean.data with 422 rows and 24 columns.
Subtyping object created successfully.
Data prepared for clean.data with 179 rows and 24 columns.
Subtyping object created successfully.
Split completed: training ( 422 ), test ( 179 )
Code
# min-max scaling keeps all features non-negative, which NMF requires.
# Fit normalization on the TRAINING split only.
# IMPORTANT: pass group_col = GROUP_COL explicitly. Sub_normalize_process()'s
# default is the literal string "group", so renaming GROUP_COL in the CONFIG
# block and leaving this call alone would normalize against the wrong name.
# Since the 1.1 label contract the outcome lives in info.data and never in
# clean.data, so it can no longer leak into scale.data; the defensive check
# below still guards the assumption that matters -- every column handed to
# K-means / LPA / NMF must be numeric.
sub_train <- Sub_normalize_process(split$train, normalize_method = "min_max",
                                   group_col = GROUP_COL)
Input object class: Subtyping 
Starting normalization process...
Cleaned data saved to: ./Module3_Advanced_Output/StatObject/Data/scale_data.csv 
Normalized data stored in 'scale.data' slot.
Code
cat("Normalization completed. scale.data dimensions:", dim(sub_train@scale.data), "\n")
Normalization completed. scale.data dimensions: 422 24 
Code
# Defensive check: confirm every remaining column really is numeric before
# it reaches K-means/LPA/NMF, all three of which require a purely numeric
# input matrix.
non_numeric_cols <- names(sub_train@scale.data)[!vapply(sub_train@scale.data, is.numeric, logical(1))]
if (length(non_numeric_cols) > 0) {
  stop("Non-numeric column(s) in scale.data: ", paste(non_numeric_cols, collapse = ", "),
       ". Check group_col above, or drop these columns from clean.data first.")
}

# Extract the normalization parameters learned on the TRAINING split only, so
# they can be re-applied identically to the validation split without letting
# validation-set information leak into its own scaling (avoids data leakage,
# analogous to Module 2's preProcess() being fit on split.data$training only).
norm_params <- Sub_extract_norm_params(sub_train, verbose = TRUE)
Sub_extract_norm_params: extracted parameters for 24 variables.
  Methods used: min_maxx24 
Code
# Apply the training-derived parameters to the validation split — the
# validation set never contributes to its own normalization statistics.
sub_test <- Sub_apply_norm_params(split$test, norm_params = norm_params, verbose = TRUE)
Normalisation applied: 24 variables transformed, 0 skipped (absent or non-numeric).
scale.data dimensions: 179 24 
Code
cat("Train set:", nrow(sub_train@clean.data), "samples | Validation set:",
    nrow(sub_test@clean.data), "samples\n")
Train set: 422 samples | Validation set: 179 samples
Code
# Remove zero-variance columns — otherwise K-means' internal PCA step errors out.
sub_train@scale.data <- remove_constant_columns(sub_train@scale.data)
No constant columns found.
Code
sub_train@clean.data <- sub_train@clean.data[, colnames(sub_train@scale.data), drop = FALSE]

# Keep the validation split's feature set aligned with the (possibly reduced)
# training feature set after zero-variance removal.
common_feats <- intersect(colnames(sub_train@scale.data), colnames(sub_test@scale.data))
sub_test@scale.data <- sub_test@scale.data[, common_feats, drop = FALSE]
sub_test@clean.data <- sub_test@clean.data[, common_feats, drop = FALSE]

# =============================================================================

8.2.5 3 Clustering — Three Complementary Methods

This section performs the core analysis for this stage. Review the output immediately below the code and confirm that data ranges, assumptions, and results match the study design.

Code
# 3. Clustering — Three Complementary Methods
# =============================================================================
## 3.1 K-means, with the number of clusters chosen automatically (k up to 8).
cat("\n=== K-means Clustering ===\n")

=== K-means Clustering ===
Code
sub_train <- Sub_kmeans_with_optimal_k(
  sub_train, use_scaled_data = TRUE, k.max = 8, palette_name = "Zissou1",
  save_plots = TRUE, save_dir = sub_dir("01_kmeans"), seed = SEED
)
Starting K-means clustering analysis...
Determining optimal K via NbClust silhouette index...
Optimal K: 2 
- Silhouette curve saved.
- WSS elbow curve saved.
- Cluster scatter saved.
  cluster size ave.sil.width
1       1  187          0.08
2       2  235          0.10
- Silhouette bar saved.
Updating 'Subtyping' object...
- cluster.results[kmeans.result] updated
- Optimal.cluster: 2 
- clustered.data updated
- info.data$cluster_kmeans written
Code
print(table(sub_train@info.data$cluster_kmeans))

  1   2 
187 235 
Code
## 3.2 LPA (Latent Profile Analysis / Gaussian mixture) — a model-based
## alternative to K-means that allows clusters of different shapes/sizes.
cat("\n=== LPA Clustering ===\n")

=== LPA Clustering ===
Code
library(mclust)
sub_train <- Sub_lpa_with_optimal_k(
  sub_train, use_scaled_data = TRUE, max_clusters = 3, verbose = TRUE,
  color_palette = "Darjeeling1", save_plots = TRUE, save_dir = sub_dir("02_lpa"), seed = SEED
)
Starting LPA clustering analysis...
Running LPA with model selection...
Optimal number of clusters (K): 3 
Selected covariance model     : VEV 
- BIC plot saved.
- BIC summary (best-per-K) plot saved.
- PCA scatter saved.
- Variable profile plot saved.
- Classification probability heatmap saved.
- Silhouette plot saved.
- Cluster assignments saved.

Updating 'Subtyping' object...
- cluster.results[lpa.result] updated
- Optimal.cluster: 3 
- clustered.data updated
- info.data$cluster_lpa written
- Selected model: VEV 
Code
cat("LPA optimal K =", sub_train@Optimal.cluster, "\n")
LPA optimal K = 3 
Code
print(table(sub_train@info.data$cluster_lpa))

  1   2   3 
 85  57 280 
Code
## 3.3 NMF (Non-negative Matrix Factorization) — the primary method here,
## because it produces a re-usable model that can assign new/validation
## samples to the same subtypes (steps 3.3d and 4 below).
cat("\n=== NMF Clustering ===\n")

=== NMF Clustering ===
Code
library(NMF)
nmf_dir <- sub_dir("03_nmf")

8.2.6 3.3a Estimate the best rank (= number of subtypes) across a candidate range.

This section performs the core analysis for this stage. Review the output immediately below the code and confirm that data ranges, assumptions, and results match the study design.

Code
# 3.3a Estimate the best rank (= number of subtypes) across a candidate range.
sub_train <- Sub_nmf_estimate(sub_train, rank_range = 2:6, nrun = 5,
                              method = "brunet", save_dir = nmf_dir, seed = SEED)
Starting NMF rank estimation (ranks: 2–3–4–5–6 )...
NMF decomposition complete.
- NMF rank metrics saved to: ./Module3_Advanced_Output/03_nmf 
Updating 'Subtyping' object...
- cluster.results[nmf.result$estimate] updated

8.2.7 3.3b Pick the best rank from the estimation above.

This section performs the core analysis for this stage. Review the output immediately below the code and confirm that data ranges, assumptions, and results match the study design.

Code
# 3.3b Pick the best rank from the estimation above.
sub_train <- Sub_nmf_best_rank(sub_train, nrun = 5, method = "brunet",
                               palette_name = "Zissou1", save_dir = nmf_dir)
Best rank (max cophenetic): 5 
Re-running NMF at K = 5 for final model...
- Consensus map saved: nmf_consensus_K5.pdf 
- Basis heatmap saved: nmf_basis_K5.pdf 
- Coefficient heatmap saved: nmf_coef_K5.pdf 
- Silhouette plot saved: nmf_silhouette_K5.pdf 
Updating 'Subtyping' object...
- best_estimate, best_rank, silhouette updated
- Optimal.cluster: 5 
Code
cat("NMF optimal rank =", sub_train@Optimal.cluster, "\n")
NMF optimal rank = 5 

8.2.8 3.3c Assign each training sample to a subtype.

This section trains and compares candidate models. Select models using discrimination, calibration, stability, and clinical utility together.

Code
# 3.3c Assign each training sample to a subtype.
sub_train <- Sub_nmf_assign_subtypes(sub_train)
Subtype assignment (H-matrix max coefficient):
group_fac
S1 S2 S3 S4 S5 
84 81 87 94 76 
Updating 'Subtyping' object...
- clustered.data updated
- info.data$cluster_nmf written
Code
print(table(sub_train@info.data$cluster_nmf))

S1 S2 S3 S4 S5 
84 81 87 94 76 

8.2.9 3.3d Refit with more NMF runs and save a re-usable model object for new data

This section trains and compares candidate models. Select models using discrimination, calibration, stability, and clinical utility together.

Code
# 3.3d Refit with more NMF runs and save a re-usable model object, so new
# samples (or the validation split) can be classified consistently later.
model_dir <- sub_dir("03_nmf/model")
sub_train <- Sub_nmf_train_model(
  sub_train, best_k = sub_train@Optimal.cluster, nrun = 10, method = "brunet",
  model_name = "subtyping_nmf_model", save_dir = model_dir
)
Training final NMF model (K = 5 , nrun = 10 )...
- Model saved to: ./Module3_Advanced_Output/03_nmf/model/subtyping_nmf_model.rds 
- Consensus matrix saved to: ./Module3_Advanced_Output/03_nmf/model/consensus_matrix.csv 
- Subtype assignments saved to: ./Module3_Advanced_Output/03_nmf/model/training_subtypes.csv 
- Silhouette plot saved: nmf_silhouette_K5.pdf 
- Silhouette summary saved to: ./Module3_Advanced_Output/03_nmf/model/silhouette_summary.txt 
All outputs saved. Returning updated Subtyping object.
Code
# =============================================================================

8.2.10 4 Predict Subtypes on the Validation Split

This section creates training and validation sets. Restrict feature selection and parameter estimation to the training set to prevent leakage.

Code
# 4. Predict Subtypes on the Validation Split
# =============================================================================
cat("\n=== Predict validation set using the trained NMF model ===\n")

=== Predict validation set using the trained NMF model ===
Code
sub_test <- Sub_predict_subtypes(sub_test, train_object = sub_train, method = "nmf", verbose = TRUE)
NMF prediction (NNLS H-matrix projection):
labels
S1 S2 S3 S4 S5 
34 39 36 46 24 
Subtype labels written to info.data$cluster_nmf (179 samples).
Code
cat("Validation NMF subtype distribution:\n")
Validation NMF subtype distribution:
Code
print(table(sub_test@info.data$cluster_nmf))

S1 S2 S3 S4 S5 
34 39 36 46 24 
Code
# =============================================================================

8.2.11 5 Dimensionality Reduction for Visualization (t-SNE & UMAP)

This section creates exploratory and reporting figures. Plots appear below the code and are also saved as vector PDFs for publication.

Code
# 5. Dimensionality Reduction for Visualization (t-SNE & UMAP)
# =============================================================================
cat("\n=== Dimensionality reduction (t-SNE & UMAP) ===\n")

=== Dimensionality reduction (t-SNE & UMAP) ===
Code
dr_dir <- sub_dir("04_dim_reduction")

sub_train <- Sub_tsne_analyse(sub_train, use_scaled_data = TRUE)
Starting Sub_tsne_analyse...
Starting t-SNE with perplexity: 30 
Removed duplicates, remaining rows: 422 
Performing t-SNE...
t-SNE completed successfully.
Updating 'Subtyping' object...
The 'Subtyping' object has been updated with the following slots:
- 'visualization.results' slot updated.
Code
sub_train <- Sub_umap_analyse(sub_train, n_neighbors = 15, min_dist = 0.1, metric = "euclidean")
Starting Sub_umap_analyse...
Starting UMAP...
Removed duplicates, remaining rows: 422 
UMAP completed successfully.
Updating 'Subtyping' object...
The 'Subtyping' object has been updated with the following slots:
- 'visualization.results' slot updated.
Code
# t-SNE colored by LPA subtype.
PlotDimReduction(sub_train, reduction = "tsne", color_by = "cluster_lpa",
                 palette_name = "Darjeeling1", save_plot = TRUE,
                 save_dir = file.path(dr_dir, "tsne"), point_size = 1.8) +
  ggplot2::labs(title = "t-SNE colored by LPA subtype")
Plot saved to: ./Module3_Advanced_Output/04_dim_reduction/tsne/tsne_cluster_lpa.pdf 
Code
# t-SNE colored by the clinical outcome (as a factor for discrete coloring).
sub_train@info.data[[GROUP_COL]] <- factor(sub_train@info.data[[GROUP_COL]])
PlotDimReduction(sub_train, reduction = "tsne", color_by = GROUP_COL,
                 palette_name = "Royal1", save_plot = TRUE,
                 save_dir = file.path(dr_dir, "tsne"), point_size = 1.8) +
  ggplot2::labs(title = "t-SNE colored by clinical outcome")
Plot saved to: ./Module3_Advanced_Output/04_dim_reduction/tsne/tsne_group.pdf 
Code
# =============================================================================

8.2.12 6 Clustering Quality Evaluation

This section evaluates quality and robustness. Interpret plots, metrics, and sample distributions together.

Code
# 6. Clustering Quality Evaluation
# =============================================================================
cat("\n=== Clustering quality evaluation ===\n")

=== Clustering quality evaluation ===
Code
# Use a separate temporary copy per method so none of them overwrite each other.
km_eval  <- sub_train; km_eval@clustered.data$group  <- sub_train@info.data$cluster_kmeans
lpa_eval <- sub_train; lpa_eval@clustered.data$group <- sub_train@info.data$cluster_lpa
nmf_eval <- sub_train; nmf_eval@clustered.data$group <- sub_train@info.data$cluster_nmf

eval_km  <- Sub_evaluation_results(km_eval,  seed = SEED)
Calculating Davies-Bouldin Index...
Cluster labels mapped to continuous indices.
Cluster centers and within-cluster distances calculated.
Between-cluster distances calculated.

Davies-Bouldin Index Calculation Complete:
Davies-Bouldin Index: 80.13991 
Global center calculated.
Cluster labels mapped to continuous indices.
Cluster centers and within-cluster dispersion calculated.
Between-cluster dispersion calculated.

Calinski-Harabasz Index Calculation Complete:
Calinski-Harabasz Index: 470.9141 
Calculating Silhouette Score...

Silhouette Score Calculation Complete:
Silhouette Score: 0.007545358 
Updating 'Subtyping' object...
The 'Subtyping' object has been updated with the following slots:
- 'evaluation_results' slot updated.
Code
eval_lpa <- Sub_evaluation_results(lpa_eval, seed = SEED)
Calculating Davies-Bouldin Index...
Cluster labels mapped to continuous indices.
Cluster centers and within-cluster distances calculated.
Between-cluster distances calculated.

Davies-Bouldin Index Calculation Complete:
Davies-Bouldin Index: 104.0424 
Global center calculated.
Cluster labels mapped to continuous indices.
Cluster centers and within-cluster dispersion calculated.
Between-cluster dispersion calculated.

Calinski-Harabasz Index Calculation Complete:
Calinski-Harabasz Index: 315.3694 
Calculating Silhouette Score...

Silhouette Score Calculation Complete:
Silhouette Score: 0.02832988 
Updating 'Subtyping' object...
The 'Subtyping' object has been updated with the following slots:
- 'evaluation_results' slot updated.
Code
eval_nmf <- Sub_evaluation_results(nmf_eval, seed = SEED)
Calculating Davies-Bouldin Index...
Cluster labels mapped to continuous indices.
Cluster centers and within-cluster distances calculated.
Between-cluster distances calculated.

Davies-Bouldin Index Calculation Complete:
Davies-Bouldin Index: 44.87957 
Global center calculated.
Cluster labels mapped to continuous indices.
Cluster centers and within-cluster dispersion calculated.
Between-cluster dispersion calculated.

Calinski-Harabasz Index Calculation Complete:
Calinski-Harabasz Index: 99.69562 
Calculating Silhouette Score...

Silhouette Score Calculation Complete:
Silhouette Score: -0.05289385 
Updating 'Subtyping' object...
The 'Subtyping' object has been updated with the following slots:
- 'evaluation_results' slot updated.
Code
eval_summary <- data.frame(
  Method = c("K-means", "LPA", "NMF"),
  rbind(eval_km@evaluation_results, eval_lpa@evaluation_results, eval_nmf@evaluation_results)
)
print(eval_summary)
   Method Calinski_Harabasz Davies_Bouldin   Silhouette n_clusters n_samples
1 K-means      470.9141....   80.13991.... 0.007545....          2       422
2     LPA      315.3693....   104.0424.... 0.028329....          3       422
3     NMF      99.69561....   44.87957.... -0.05289....          5       422
Code
# =============================================================================

8.2.13 7 Consistency Between the Three Clustering Methods

This section evaluates quality and robustness. Interpret plots, metrics, and sample distributions together.

Code
# 7. Consistency Between the Three Clustering Methods
# =============================================================================
cat("\n=== Consistency between methods ===\n")

=== Consistency between methods ===
Code
sub_compare <- sub_train
# Prefix numeric cluster labels with "S" so they are treated as categories,
# not as an ordered numeric scale, in the comparison plot.
sub_compare@info.data$cluster_lpa    <- paste0("S", sub_compare@info.data$cluster_lpa)
sub_compare@info.data$cluster_kmeans <- paste0("S", sub_compare@info.data$cluster_kmeans)

ari_mat <- compare_clusterings(
  sub_compare, methods = c("cluster_kmeans", "cluster_lpa", "cluster_nmf"), output = "matrix"
)
cat("Adjusted Rand Index matrix:\n"); print(round(ari_mat, 3))
Adjusted Rand Index matrix:
               cluster_kmeans cluster_lpa cluster_nmf
cluster_kmeans          1.000       0.005       0.111
cluster_lpa             0.005       1.000       0.005
cluster_nmf             0.111       0.005       1.000
Code
plot_clustering_comparison(
  sub_compare, methods = c("cluster_kmeans", "cluster_lpa", "cluster_nmf"),
  save_dir = sub_dir("05_method_comparison"), width = 5, height = 4.5, base_size = 12
)
ARI heatmap saved to: ./Module3_Advanced_Output/05_method_comparison/clustering_comparison_ARI.pdf 
Code
# =============================================================================

8.2.14 8 Subtype-Specific Marker Features & Enhanced Visualization

This section creates exploratory and reporting figures. Plots appear below the code and are also saved as vector PDFs for publication.

Code
# 8. Subtype-Specific Marker Features & Enhanced Visualization
# =============================================================================
cat("\n=== Subtype-specific differential features ===\n")

=== Subtype-specific differential features ===
Code
deg_dir <- sub_dir("06_differential")

## 8.1 Multi-class differential analysis (one-vs-rest Wilcoxon), using the
## LPA subtypes as the grouping variable. Any of the three cluster labels
## could be used here instead.
deg_df <- sub_train@clean.data
deg_df$subtype <- factor(sub_train@info.data$cluster_lpa)

multi_deg <- batch_Wilcoxon_MultiClass(
  mat = deg_df, group_col = "subtype", only.pos = FALSE,
  save_data = TRUE, save_dir = deg_dir
)
Multi-class DEG results saved to: ./Module3_Advanced_Output/06_differential/deg_multiclass_all.csv 
Code
## 8.2 Marker heatmaps of the top differential features per subtype.
# Basic version, auto z-limit:
tryCatch(
  PlotClusterHeatmap(
    sub_train, p_cutoff = 2, deg_df = multi_deg, top_n = 5,
    save_path = file.path(deg_dir, "cluster_heatmap_basic.pdf")
  ),
  error = function(e) message("Sample-level heatmap skipped (raster backend): ", e$message)
)
# With a clinical annotation bar (outcome) alongside the heatmap:
tryCatch(
  PlotClusterHeatmap(
    sub_train, p_cutoff = 2, deg_df = multi_deg, group_by = "cluster_lpa",
    annotation_cols = tolower(GROUP_COL), top_n = 5, log_transform = TRUE,
    save_path = file.path(deg_dir, "cluster_heatmap_with_outcome.pdf"),
    # Key the palette by the SAME name as annotation_cols. A key that does not
    # match an annotation column is ignored silently, so the bar comes out in
    # the default colours instead of failing loudly.
    annotation_palette = setNames(
      list(c("Case" = "#D32F2F", "Healthy Control" = "#1976D2")),
      tolower(GROUP_COL))
  ),
  error = function(e) message("Annotated sample-level heatmap skipped (raster backend): ", e$message)
)
# Group-mean z-score heatmap (one column per subtype, not per sample) —
# useful for a compact summary figure.
PlotGroupMeanHeatmap(
  sub_train, deg_df = multi_deg, top_n = 5, custom_levels = c("1", "2", "3"),
  z_score_type = "row", heatmap_palette = c("#2166AC", "white", "#B2182B"),
  save_path = file.path(deg_dir, "group_mean_zscore.pdf")
)

## 8.3 Silhouette plot — how well-separated are the LPA subtypes?
PlotSilhouette(sub_train, group_by = "cluster_lpa", dist_method = "euclidean",
               palette_name = "Darjeeling1", save_plot = TRUE, save_dir = sub_dir("07_silhouette"))

## 8.4 Alluvial diagram comparing outcome, K-means, LPA, and NMF assignments
## for the same samples — a compact way to show whether methods agree.
PlotMultiAlluvial(
  sub_train, cols_list = c(tolower(GROUP_COL), "cluster_kmeans", "cluster_lpa", "cluster_nmf"),
  save_plot = TRUE, save_dir = sub_dir("08_alluvial")
)

# =============================================================================

8.2.15 9 Deployment: Predict Subtypes for New Samples

This section applies the trained object to new samples. Validate input format, performance, and target population in an independent cohort before deployment.

Code
# 9. Deployment: Predict Subtypes for New Samples
# =============================================================================
demo_raw_data <- head(sub_train@clean.data, 20)
sub_manager   <- New_Sub_Manager(sub_train)

# Console-only prediction (no UI) for a quick sanity check.
quick_pred <- sub_manager$sub_predict(head(demo_raw_data), method = "nmf")
Sub_extract_norm_params: extracted parameters for 24 variables.
  Methods used: min_maxx24 
Data prepared for clean.data with 6 rows and 24 columns.
Subtyping object created successfully.
Code
print(quick_pred@info.data$cluster_nmf)
[1] S3 S5 S3 S2 S2 S4
Levels: S2 S3 S4 S5
Code
# Optional metadata shown in the deployment app.
my_intro <- list(
  abstract = "Cohort-scale subtyping tool.",
  citation = "Add your citation here."
)
my_var_dict <- data.frame(
  Feature     = c("gender", "age", "wbc", "platelets", "crp", "ldh"),
  Description = c("Patient biological sex (1 = Male, 2 = Female)",
                  "Patient age at diagnosis",
                  "White blood cell count",
                  "Total platelet count",
                  "C-reactive protein (inflammation marker)",
                  "Lactate dehydrogenase (tumor-burden marker)"),
  Units       = c("Category", "Years", "10^9/L", "10^9/L", "mg/L", "U/L"),
  stringsAsFactors = FALSE
)

# The interactive Shiny app is only launched in an interactive session.
if (interactive()) {
  launch_sub_deploy_app(sub_manager, var_dict = my_var_dict,
                        title = "Stratification Portal", project_info = my_intro)
}

# =============================================================================

8.2.16 10 Save Final Objects

This section saves model objects, tables, and figures for reproducibility, review, and reuse.

Code
# 10. Save Final Objects
# =============================================================================
saveRDS(sub_train, file = file.path(OUTPUT_DIR, "final_subtyping_object.rds"))

cat("\n========================================\n")

========================================
Code
cat("Module 3 (advanced) pipeline finished successfully!\n")
Module 3 (advanced) pipeline finished successfully!
Code
cat("Optimal cluster numbers — K-means:", eval_km@evaluation_results$n_clusters,
    "| LPA:", eval_lpa@evaluation_results$n_clusters,
    "| NMF:", eval_nmf@evaluation_results$n_clusters, "\n")
Optimal cluster numbers — K-means: 2 | LPA: 3 | NMF: 5 
Code
cat("Best method by Silhouette score:", eval_summary$Method[which.max(eval_summary$Silhouette)], "\n")
Best method by Silhouette score: LPA 
Code
cat("All results saved under:", OUTPUT_DIR, "\n")
All results saved under: ./Module3_Advanced_Output 
Code
cat("========================================\n")
========================================