7  Module 3: Unsupervised Subtyping — Quick Start

This chapter converts stat_obj.rds into icare’s Subtyping object and uses non-negative matrix factorization (NMF) to discover and visualize candidate patient subtypes. Unlike a purely descriptive clustering result, the fitted NMF model can be retained and applied to new samples after the original preprocessing has been replayed.

7.1 Important Notes

NMF requires non-negative numeric features. Exclude identifiers and known outcomes from the feature matrix, supply the correct group/ID columns to normalization, and verify that all remaining inputs are numeric. Interpret the number and meaning of subtypes with stability, external validation, and clinical relevance—not visualization alone.

7.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.

7.2.1 Environment and Global Setup

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
# =============================================================================
# icare Package Vignette — Module 3: Subtyping / Unsupervised Clustering
# QUICK-START EDITION
# =============================================================================
# Audience : Users who just want to find and visualize subtypes in their
#            data without comparing every clustering algorithm.
# Input    : "stat_obj.rds", produced by Module 1 (data cleaning).
#
# This script uses NMF as the single clustering method, since it also
# produces a re-usable model for classifying new samples later.
#
# Looking for K-means/LPA comparisons, cluster validation, marker-feature
# heatmaps, or a deployment app? See "03_module3_subtyping_ADVANCED.R".
# =============================================================================

library(icare)
library(NMF)

set.seed(123)

# ---------------------------------------------------------------------------

7.2.2 1 Load the cleaned data and convert it into a Subtyping object

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

Code
# 1. Load the cleaned data and convert it into a Subtyping object
# ---------------------------------------------------------------------------
stat_obj <- readRDS("stat_obj.rds")
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
# min-max scaling: NMF requires non-negative input.
# IMPORTANT: pass the ACTUAL name of your outcome/ID column via `group_col`
# (default is "group") -- Sub_normalize_process() only excludes a column
# from scaling by an EXACT name match, and it never removes non-numeric
# columns from what it returns. If group_col doesn't match your real column
# name, or clean.data has any other non-numeric column (site, ID, free
# text...), it will silently survive into scale.data and later crash NMF
# deep inside with an unrelated-looking "'x' must be numeric" error.
# Replace "group" below with your real outcome column name if different.
features=colnames(sub_obj@scale.data)[colnames(sub_obj@scale.data)!='sex']
sub_obj@clean.data <- sub_obj@clean.data[, features, drop = FALSE]
sub_obj <- Sub_normalize_process(sub_obj, normalize_method = "min_max",
                                 group_col = "group")
Input object class: Subtyping 
Starting normalization process...
Cleaned data saved to: /tmp/RtmphL229e/icare_output/StatObject/Data/scale_data.csv 
Normalized data stored in 'scale.data' slot.
Code
# Defensive check BEFORE clustering: make sure every remaining column is
# actually numeric. If this stops, the message tells you exactly which
# column(s) to exclude or fix group_col for.
non_numeric_cols <- names(sub_obj@scale.data)[!vapply(sub_obj@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 = ", "),
       ". Set group_col correctly above, or drop these columns from ",
       "clean.data before calling Sub_normalize_process().")
}

# Drop zero-variance columns (they can break the clustering step).
sub_obj@scale.data <- remove_constant_columns(sub_obj@scale.data)
No constant columns found.
Code
# ---------------------------------------------------------------------------

7.2.3 2 Estimate the number of subtypes, then cluster

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
# 2. Estimate the number of subtypes, then cluster
# ---------------------------------------------------------------------------
sub_obj <- Sub_nmf_estimate(sub_obj, rank_range = 2:6, nrun = 5, method = "brunet")
Starting NMF rank estimation (ranks: 2–3–4–5–6 )...
NMF decomposition complete.
- NMF rank metrics saved to: /tmp/RtmphL229e/icare_output/m3/cluster_results/nmf_results 
Updating 'Subtyping' object...
- cluster.results[nmf.result$estimate] updated
Code
sub_obj <- Sub_nmf_best_rank(sub_obj, nrun = 5, method = "brunet")
Best rank (max cophenetic): 2 
Re-running NMF at K = 2 for final model...
- Consensus map saved: nmf_consensus_K2.pdf 
- Basis heatmap saved: nmf_basis_K2.pdf 
- Coefficient heatmap saved: nmf_coef_K2.pdf 
- Silhouette plot saved: nmf_silhouette_K2.pdf 
Updating 'Subtyping' object...
- best_estimate, best_rank, silhouette updated
- Optimal.cluster: 2 
Code
cat("Optimal number of subtypes:", sub_obj@Optimal.cluster, "\n")
Optimal number of subtypes: 2 
Code
sub_obj <- Sub_nmf_assign_subtypes(sub_obj)
Subtype assignment (H-matrix max coefficient):
group_fac
 S1  S2 
300 301 
Updating 'Subtyping' object...
- clustered.data updated
- info.data$cluster_nmf written
Code
print(table(sub_obj@info.data$cluster_nmf))

 S1  S2 
300 301 
Code
# ---------------------------------------------------------------------------

7.2.4 3 Visualize the subtypes

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

Code
# 3. Visualize the subtypes
# ---------------------------------------------------------------------------
sub_obj <- Sub_tsne_analyse(sub_obj, use_scaled_data = TRUE)
Starting Sub_tsne_analyse...
Starting t-SNE with perplexity: 30 
Removed duplicates, remaining rows: 601 
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
PlotDimReduction(sub_obj, reduction = "tsne", color_by = "cluster_nmf",
                 palette_name = "Zissou1", save_plot = TRUE)
Plot saved to: /home/runner/work/icare/icare/Figures/Subtyping/tsne_cluster_nmf.pdf 
Code
# ---------------------------------------------------------------------------

7.2.5 4 Check cluster quality and find marker features

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

Code
# 4. Check cluster quality and find marker features
# ---------------------------------------------------------------------------
sub_obj@clustered.data$group <- sub_obj@info.data$cluster_nmf
eval_result <- Sub_evaluation_results(sub_obj, seed = 123)
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: 99.04784 
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: 683.2985 
Calculating Silhouette Score...

Silhouette Score Calculation Complete:
Silhouette Score: 0.007093155 
Updating 'Subtyping' object...
The 'Subtyping' object has been updated with the following slots:
- 'evaluation_results' slot updated.
Code
print(eval_result@evaluation_results)
$Calinski_Harabasz
[1] 683.2985

$Davies_Bouldin
[1] 99.04784

$Silhouette
[1] 0.007093155

$n_clusters
[1] 2

$n_samples
[1] 601
Code
deg_df <- sub_obj@clean.data
deg_df$subtype <- factor(sub_obj@info.data$cluster_nmf)
multi_deg <- batch_Wilcoxon_MultiClass(mat = deg_df, group_col = "subtype", only.pos = FALSE)

tryCatch(
  PlotClusterHeatmap(
    sub_obj, p_cutoff = 2, deg_df = multi_deg, top_n = 5,
    group_by = "cluster_nmf", save_path = "./subtype_marker_heatmap.pdf"
  ),
  error = function(e) message(
    "Sample-level subtype heatmap skipped because the macOS raster backend ",
    "could not create its temporary image: ", e$message
  )
)

# ---------------------------------------------------------------------------

7.2.6 5 Save your results

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

Code
# 5. Save your results
# ---------------------------------------------------------------------------
saveRDS(sub_obj, file = "subtyping_obj.rds")

cat("\nDone! subtyping_obj.rds saved.\n",
    "Want to compare K-means/LPA, validate clusters more thoroughly,",
    "or deploy a prediction app? See the ADVANCED version of this script.\n")

Done! subtyping_obj.rds saved.
 Want to compare K-means/LPA, validate clusters more thoroughly, or deploy a prediction app? See the ADVANCED version of this script.