---
title: "LIPIDIFy"
author: "Fayrouz Hammal"
date: "`r Sys.Date()`"
package: LIPIDIFy
output:
  BiocStyle::html_document:
    toc: true
    toc_float: true
    toc_depth: 3
    number_sections: true
vignette: >
  %\VignetteIndexEntry{LIPIDIFy}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>",
  fig.width = 9,
  fig.height = 5,
  fig.align = "center",
  warning = FALSE,
  message = FALSE
)
```

# Introduction

**LIPIDIFy** provides an end-to-end toolkit for mass-spectrometry lipidomics
data analysis: missing-value imputation, batch-effect correction, multiple
normalization strategies, differential abundance analysis (limma and edgeR),
gene-set enrichment analysis, and extensive visualization. Lipid names are
automatically classified by class, subclass, and fatty-acid saturation, and
the package supports user-defined classification schemes and enrichment sets
for lipidomes that don't fit a fixed ontology.

## Why LIPIDIFy?

Several excellent packages already support lipidomics analysis in
Bioconductor, most notably
[lipidr](https://bioconductor.org/packages/lipidr), which centres on a
`SummarizedExperiment`-derived class for annotation, normalization, and
class-level statistical testing. LIPIDIFy is a complementary tool rather than
a replacement, aimed at two audiences at once:

- **Bench biologists**, via `launch_lipidomics_app()`, an interactive Shiny
  dashboard that requires no R programming: upload a CSV, click through
  normalization/QC/differential/enrichment steps, and export a report.
- **Bioinformaticians**, via the same underlying functions used
  programmatically, for reproducible, scriptable analyses.

Relative to existing workflows, LIPIDIFy additionally emphasises:

- **Flexible classification and enrichment.** Lipid classes are inferred
  automatically from standard nomenclature, but users can supply their own
  classification table (any hierarchy, any number of columns) and custom
  enrichment sets, rather than being tied to one fixed ontology.
- **Side-by-side pipeline comparison.** `create_pipeline_plot()` lets users
  visually compare two full normalization pipelines (e.g. TIC+Log2 vs.
  PQN+Log2) before committing to one, which is otherwise a manual, repetitive
  step.
- **Imputation and batch correction as first-class steps**, with multiple
  interchangeable methods (`impute_missing_values()`,
  `correct_batch_effects()`) integrated into the same workflow as
  normalization and differential analysis, rather than requiring separate
  tooling.

## Interoperability with the Bioconductor ecosystem

Internally, LIPIDIFy represents data as plain matrices and data frames
(`list(data, metadata, numeric_data)`) rather than a custom S4 class, which
keeps the API approachable for users who are not already familiar with
Bioconductor's class system. This representation converts trivially to a
`SummarizedExperiment`, so results can be handed off to other Bioconductor
tools (or a `lipidr` `LipidomicsExperiment`, which extends
`SummarizedExperiment`) for further analysis. See the
[Interoperability](#interoperability-with-summarizedexperiment) section near
the end of this vignette for a worked example.

## Installation

```{r installation, eval=FALSE}
if (!requireNamespace("BiocManager", quietly = TRUE)) {
  install.packages("BiocManager")
}

BiocManager::install("LIPIDIFy")
```

```{r load_package}
library(LIPIDIFy)
```

The same workflow below is available without any coding via the Shiny
application:

```{r launch-app, eval=FALSE}
launch_lipidomics_app()
```

***

# Example Data

This vignette uses a real, publicly available lipidomics dataset rather than
simulated data: **Metabolomics Workbench study
[ST001359](https://www.metabolomicsworkbench.org/data/DRCCMetadata.php?StudyID=ST001359)**,
"Monophasic lipidomics extraction in cancer cell lines" (Rodriguez Blanco G.,
Beatson Institute for Cancer Research; Project PR000929, Analysis AN002263;
licensed CC BY 4.0). It profiles HepG2 hepatocellular carcinoma cells by
LC-MS (reversed phase, positive ion mode) under two conditions: untreated
controls and cells treated with an SDC (syndecan) inhibitor, 3 replicates
each. The processed data matrix is bundled with the package at
`inst/extdata/ST001359_lipidomics.csv`; the script that downloaded and
prepared it is `inst/scripts/prepare_ST001359_lipidomics.R`.

```{r load-data}
file_path <- system.file("extdata", "ST001359_lipidomics.csv", package = "LIPIDIFy")

raw_data <- load_lipidomics_data(
  file_path,
  metadata_columns = c("Sample Name", "Sample Group")
)

cat("Samples  :", nrow(raw_data$numeric_data), "\n")
cat("Lipids   :", ncol(raw_data$numeric_data), "\n")
cat("Groups   :", paste(unique(raw_data$metadata$`Sample Group`), collapse = ", "), "\n")
```

`load_lipidomics_data()` returns a **named list** with three elements:

- `$numeric_data` - matrix of lipid abundances (samples x lipids)
- `$metadata` - data frame of sample metadata
- `$data` - the original full data frame

```{r peek-data}
head(raw_data$metadata, 6)
raw_data$numeric_data[, 1:4]
```

Values are the study's own "peak area, normalized" units, which the
submitters already log2-scaled; we use them unchanged (not re-normalized or
back-transformed) to preserve fidelity to the deposited data. This matters
below: a second application of Log2 or TIC normalization would not be
meaningful on data that is already log-scaled and normalized, so we apply
only light median-centring further on.

If you would rather experiment with a larger, fully synthetic dataset (20
samples, 4 groups, un-transformed intensities), `generate_example_data()`
creates one on the fly -- it is used in this vignette only for the
normalization-pipeline comparison in the next section, where raw-scale
intensities make the effect of each method clearer.

***

# Lipid Classification

`classify_lipids()` parses lipid names and assigns three classification
levels:

- **LipidGroup** - broad category (e.g., Glycerophospholipids, Sphingolipids)
- **LipidType** - specific subclass (e.g., Phosphatidylcholine)
- **Saturation** - SFA (0 double bonds), MUFA (1), PUFA (> 1)

```{r classify}
lipid_names <- colnames(raw_data$numeric_data)
classification <- classify_lipids(lipid_names)

head(classification, 8)
```

```{r classify-summary}
cat("Lipid groups:\n")
print(sort(table(classification$LipidGroup), decreasing = TRUE))

cat("\nFatty-acid saturation:\n")
print(table(classification$Saturation))
```

Real-world lipid identifiers are rarely as tidy as textbook nomenclature.
Here, roughly a quarter of features (e.g. `BMP`, `CL`, acylcarnitines, ether
lipids reported as `Plasmanyl-`/`Plasmenyl-`) fall outside the built-in
pattern set and are labelled `"Other/Unclassified"`. Rather than guessing,
you can supply your own classification table with `load_custom_classification()`
(any number of columns, any hierarchy) to cover exactly the lipid space of
your dataset.

***

# Raw Data Visualization

Examining distributions **before normalization** helps detect outlier
samples, batch effects, and scaling differences.

```{r raw-boxplot, fig.cap="Per-sample intensity distributions before normalization. Boxes are coloured by group."}
raw_list <- list(numeric_data = raw_data$numeric_data, metadata = raw_data$metadata)

visualize_raw_data_improved(
  raw_list,
  plot_type    = "boxplot",
  view_mode    = "sample",
  metadata     = raw_data$metadata,
  group_column = "Sample Group"
)
```

```{r raw-density, fig.cap="Intensity density curves before normalization, one curve per sample."}
visualize_raw_data_improved(
  raw_list,
  plot_type    = "density",
  view_mode    = "sample",
  metadata     = raw_data$metadata,
  group_column = "Sample Group"
)
```

***

# Normalization

## What methods are available?

```{r norm-methods}
get_normalization_methods()
```

## VSN and Log2Median are different methods

Two of these deserve a note, because their names invite confusion:

* **`"Log2Median"`** applies a *fixed* transformation: `log2(x + 1)`, then a
  per-sample median shift onto a common baseline, back-transformed to the
  original scale. Nothing is estimated from the data.
* **`"VSN"`** performs genuine variance-stabilising calibration with
  `vsn::justvsn()` from the Bioconductor
  [vsn](https://bioconductor.org/packages/vsn/) package
  (Huber *et al.*, *Bioinformatics* **18**, S96--S104, 2002).
  It fits an arcsinh (generalised-log) transformation whose
  parameters are estimated by robust maximum likelihood, simultaneously
  calibrating between-sample differences and stabilising the
  variance-versus-mean relationship.

VSN may be appropriate for positive, intensity-like measurements whose
variance grows with the mean -- a common pattern in raw MS lipidomics data.
It is **not** simply another name for a log transformation, and it is not
automatically the best choice for every lipidomics dataset; compare pipelines
on your own data before committing to one.

Practical restrictions:

* The Bioconductor `vsn` package must be installed
  (`BiocManager::install("vsn")`). If it is missing, `"VSN"` raises an error
  -- it never silently falls back to another method.
* At least 2 samples and at least 42 lipid features are required (vsn's
  `minDataPointsPerStratum` default), and values must be finite. Infinite
  values, samples or features that are entirely missing, and samples with
  fewer than two observed values are rejected with a descriptive error.
* Missing values are permitted: vsn fits on the observed values and returns
  `NA` in the same positions. They are never imputed.
* Negative values are passed to vsn unchanged but produce a warning, since
  they usually indicate data that has already been log-transformed or
  background-subtracted.
* The output is already on a log-like scale, so do not chain a `"Log2"` step
  after `"VSN"`.

```{r norm-vsn, eval = requireNamespace("vsn", quietly = TRUE)}
synthetic_obj_vsn <- load_lipidomics_data_from_df(generate_example_data())

vsn_norm <- apply_normalizations(synthetic_obj_vsn$numeric_data, "VSN")
dim(vsn_norm) # samples x lipids - orientation and dimnames preserved

# Distinct from Log2Median, which is a different method:
l2m_norm <- apply_normalizations(synthetic_obj_vsn$numeric_data, "Log2Median")
isTRUE(all.equal(vsn_norm, l2m_norm))
```

## Comparing pipelines side-by-side

It is good practice to compare normalization strategies visually before
committing to one. This is most informative on raw-scale intensities, so we
illustrate it here with the built-in synthetic dataset, comparing TIC+Log2
(a common starting point) against PQN+Log2 (more robust when large
fold-changes are expected):

```{r norm-compare, fig.cap="Side-by-side comparison of two normalization pipelines applied to the same raw, synthetic data.", fig.height=9}
synthetic_df <- generate_example_data()
synthetic_obj <- load_lipidomics_data_from_df(synthetic_df)

pipeline1 <- apply_normalizations(synthetic_obj$numeric_data, c("TIC", "Log2"))
pipeline2 <- apply_normalizations(synthetic_obj$numeric_data, c("PQN", "Log2"))

p1 <- create_pipeline_plot(pipeline1,
  title     = "Pipeline 1: TIC + Log2",
  metadata  = synthetic_obj$metadata,
  plot_type = "boxplot"
)

p2 <- create_pipeline_plot(pipeline2,
  title     = "Pipeline 2: PQN + Log2",
  metadata  = synthetic_obj$metadata,
  plot_type = "boxplot"
)

gridExtra::grid.arrange(p1, p2, ncol = 1)
```

## Applying normalization to the real dataset

The ST001359 data is already log2-scaled and normalized by the original
submitters, so we apply only median-centring here rather than a second
TIC/PQN/Log2 pass, which would not be meaningful on top of an existing
log-scale normalization:

```{r normalize}
norm_data <- apply_normalizations(raw_data$numeric_data, methods = "Median")

dim(norm_data) # samples x lipids - same dimensions as raw data
```

```{r norm-boxplot, fig.cap="Per-sample distributions after median-centring."}
norm_list <- list(numeric_data = norm_data, metadata = raw_data$metadata)

visualize_raw_data_improved(
  norm_list,
  plot_type    = "boxplot",
  view_mode    = "sample",
  metadata     = raw_data$metadata,
  group_column = "Sample Group"
)
```

***

# Missing Value Imputation

Missing values in lipidomics data typically arise when a lipid's signal
falls below the instrument's limit of detection. LIPIDIFy provides six
imputation strategies via `impute_missing_values()`. Imputing **after**
normalisation ensures that imputed values sit on the same scale as observed
values.

```{r impute}
cat("Missing values in this dataset:", sum(is.na(norm_data)), "\n")
cat("Imputation methods available:\n")
print(get_imputation_methods())
```

```{r impute-apply}
# For demonstration: introduce 5% artificial missing values
demo_norm <- norm_data
set.seed(42)
demo_norm[sample(length(demo_norm), size = round(0.05 * length(demo_norm)))] <- NA
cat(
  "Missingness introduced:", sum(is.na(demo_norm)),
  sprintf("(%.1f%%)\n", 100 * mean(is.na(demo_norm)))
)

imputed_norm <- impute_missing_values(demo_norm, method = "half_min")
cat("Missing after imputation:", sum(is.na(imputed_norm)), "\n")
```

**Choosing a method:**

| Method | Best for |
|--------|----------|
| `half_min` | Most lipidomics data; models the LOD on normalised scale |
| `knn` | Data missing at random (MAR); requires `impute` Bioconductor package |
| `zero` | Only when absence of signal truly means zero |
| `mean` / `median` | Small proportions of missing values |

For the remainder of this vignette we use the complete normalised data
without artificial missingness.

***

# Batch Effect Correction

When samples are processed across multiple runs, instruments, or operators,
technical batch effects can obscure biological differences.
`correct_batch_effects()` removes these effects while preserving biological
signal, and should be applied after normalisation and imputation, then
verified with PCA. The ST001359 dataset has no recorded batch structure, so
this step is illustrated but not evaluated here.

Two methods are available:

| Method | When to use |
|--------|------------|
| `"limma"` | Default; fast and reliable for balanced designs |
| `"combat"` | Better for large or unbalanced batch effects; requires `sva` package |

```{r batch-correction, eval=FALSE}
# Simulate batch labels (real data would have this in the metadata file)
raw_data$metadata$Batch <- rep(c("Run1", "Run2"), each = 3)

norm_corrected <- correct_batch_effects(
  data_matrix  = norm_data,
  metadata     = raw_data$metadata,
  batch_column = "Batch",
  group_column = "Sample Group",
  method       = "limma"
)

# Verify: PCA should now show clustering by biology, not by batch
pca_corrected <- perform_pca(norm_corrected, raw_data$metadata, "Sample Group")
create_pca_plot_with_ellipses(
  pca_data           = pca_corrected$pca_data,
  variance_explained = pca_corrected$variance_explained,
  ellipse_type       = "confidence",
  title              = "PCA after Batch Correction"
)
```

***

# Quality Control with PCA

PCA on normalized data should show samples clustering by biological group
rather than by technical artefact.

```{r pca, fig.cap="PCA of the normalized ST001359 data."}
pca_res <- perform_pca(norm_data, raw_data$metadata, "Sample Group")

create_pca_plot_with_ellipses(
  pca_data           = pca_res$pca_data,
  variance_explained = pca_res$variance_explained,
  ellipse_type       = "none",
  show_sample_labels = TRUE,
  title              = "PCA - Control vs. SDC Inhibition"
)
```

With only 3 replicates per group, confidence ellipses are not meaningful
here (`ellipse_type = "none"`); with larger sample sizes,
`ellipse_type = "confidence"` draws 95% confidence regions per group.

***

# Differential Analysis

## Automatic pairwise contrasts

`create_default_contrasts()` generates all pairwise comparisons from the
groups present in your data. You can also specify contrasts manually, or
supply your own `design` matrix (see `?perform_differential_analysis`) when
the default `~0 + group` design doesn't fit your experiment (e.g. paired
samples, additional covariates).

```{r contrasts}
groups <- unique(raw_data$metadata$`Sample Group`)
contrasts <- create_default_contrasts(groups)
print(contrasts)
```

## Choosing a method: limma or edgeR?

```{r diff-analysis}
diff_res <- perform_differential_analysis(
  data_matrix    = norm_data,
  metadata       = raw_data$metadata,
  group_column   = "Sample Group",
  contrasts_list = contrasts,
  method         = "limma"
)
```

**limma is the recommended default** for mass-spectrometry lipidomics: it
models continuous, approximately log-normal intensities directly.

**edgeR is provided only as an exploratory alternative.** edgeR's
negative-binomial model was designed for integer RNA-seq count data;
applying it to continuous MS intensities is an approximation, and
`perform_differential_analysis(..., method = "edger")` emits a warning to
that effect every time it is called. Consider edgeR only if you specifically
want to cross-check limma's results against a second modelling assumption,
or if you are integrating lipidomics into a broader pipeline that already
standardises on edgeR for consistency -- not as a first choice.

```{r diff-summary}
cat(sprintf("%-20s  %5s  %5s  %4s  %5s\n", "Contrast", "Total", "Sig", "Up", "Down"))
cat(strrep("-", 48), "\n")

for (nm in names(diff_res$results)) {
  res <- diff_res$results[[nm]]
  n_sig <- sum(res$adj.P.Val < 0.05, na.rm = TRUE)
  n_up <- sum(res$adj.P.Val < 0.05 & res$logFC > 0, na.rm = TRUE)
  n_down <- sum(res$adj.P.Val < 0.05 & res$logFC < 0, na.rm = TRUE)
  cat(sprintf("%-20s  %5d  %5d  %4d  %5d\n", nm, nrow(res), n_sig, n_up, n_down))
}
```

```{r top-results}
first_contrast <- names(diff_res$results)[1]
res_df <- diff_res$results[[first_contrast]]
res_df <- res_df[order(res_df$adj.P.Val), ]

top10 <- utils::head(res_df[, c("logFC", "AveExpr", "adj.P.Val")], 10)
top10$logFC <- round(top10$logFC, 3)
top10$AveExpr <- round(top10$AveExpr, 2)
top10$adj.P.Val <- signif(top10$adj.P.Val, 3)

cat("Top 10 features in:", first_contrast, "\n\n")
print(top10)
```

***

# Results Visualization

## Volcano plot

Significant lipids (|logFC| > 1 and adj.P.Val < 0.05) are coloured by lipid
class. The top 10 most significant are labelled.

```{r volcano, fig.cap="Volcano plot coloured by lipid class."}
create_volcano_plot_labeled(
  results             = diff_res$results[[first_contrast]],
  title               = paste("Volcano:", first_contrast),
  logfc_threshold     = 1,
  pval_threshold      = 0.05,
  top_labels          = 10,
  classification_data = classification,
  color_by            = "LipidGroup"
)
```

## Heatmap of significant features

```{r heatmap, fig.cap="Heatmap of significant lipids. Rows (lipids) and columns (samples) are hierarchically clustered. Colour scale is z-scored per lipid.", fig.height=7}
sig_lipids <- rownames(res_df)[
  !is.na(res_df$adj.P.Val) &
    res_df$adj.P.Val < 0.05 &
    abs(res_df$logFC) > 1
]

if (length(sig_lipids) >= 5) {
  top_n <- min(30, length(sig_lipids))
  avail <- intersect(sig_lipids[seq_len(top_n)], colnames(norm_data))
  hm_mat <- t(norm_data[, avail, drop = FALSE])

  create_heatmap_robust(
    data_matrix  = hm_mat,
    metadata     = raw_data$metadata,
    group_column = "Sample Group",
    top_n        = top_n,
    title        = paste("Significant lipids:", first_contrast)
  )
} else {
  message("Fewer than 5 significant lipids at these thresholds.")
}
```

***

# Lipid Expression

`create_lipid_expression_barplot()` shows per-sample abundance of individual
lipids, sorted by group. It returns a single ggplot object when one lipid is
selected, or a named list of ggplot objects when multiple lipids are
selected.

```{r lipid-expression, fig.cap="Per-sample abundance of the 3 most significant lipids."}
top_lipids <- utils::head(rownames(res_df)[!is.na(res_df$adj.P.Val)], 3)
cat("Selected lipids:\n")
print(top_lipids)

expr_result <- create_lipid_expression_barplot(
  data_matrix     = norm_data,
  metadata        = raw_data$metadata,
  selected_lipids = top_lipids,
  group_column    = "Sample Group",
  data_type       = "normalized"
)

if (inherits(expr_result, "ggplot")) {
  print(expr_result)
} else {
  for (lipid_plot in expr_result) print(lipid_plot)
}
```

***

# Enrichment Analysis

Enrichment analysis tests whether lipids in a particular class are
collectively more increased or decreased than expected by chance, using the
fgsea algorithm.

```{r enrichment}
enrich_res <- perform_enrichment_analysis(
  results_list        = diff_res$results,
  classification_data = classification,
  min_set_size        = 3,
  max_set_size        = 500
)

cat("Categories per contrast:", paste(names(enrich_res[[1]]), collapse = ", "), "\n")
```

```{r enrichment-table}
grp_enrich <- enrich_res[[first_contrast]][["LipidGroup"]]

if (!is.null(grp_enrich) && nrow(grp_enrich) > 0) {
  grp_enrich <- grp_enrich[order(grp_enrich$pval), ]
  out <- utils::head(grp_enrich[, c("pathway", "NES", "pval", "padj", "size")], 5)
  out$NES <- round(out$NES, 3)
  out$pval <- signif(out$pval, 3)
  out$padj <- signif(out$padj, 3)
  print(out, row.names = FALSE)
} else {
  cat("No enrichment results available.\n")
}
```

```{r enrichment-plot, fig.cap="Enrichment dot plot for the first contrast. Dot size reflects set size; colour reflects statistical significance."}
if (!is.null(grp_enrich) && nrow(grp_enrich) > 0) {
  create_enrichment_dotplot(
    enrichment_data = grp_enrich,
    title           = paste("Lipid Group Enrichment:", first_contrast),
    max_pathways    = 10
  )
}
```

Custom, user-defined pathway sets (not tied to `LipidGroup`/`LipidType`) can
be supplied via `load_custom_enrichment_sets()` and passed as `custom_sets`.

***

# Interoperability with SummarizedExperiment

LIPIDIFy's list-of-matrices representation converts directly to a
`SummarizedExperiment`, for interoperability with the rest of the
Bioconductor ecosystem (or with `lipidr`, whose `LipidomicsExperiment` class
extends `SummarizedExperiment`):

```{r summarized-experiment}
if (requireNamespace("SummarizedExperiment", quietly = TRUE)) {
  se <- SummarizedExperiment::SummarizedExperiment(
    assays  = list(normalized = t(norm_data)),
    colData = raw_data$metadata
  )
  print(se)
}
```

From here, `se` can be passed to any Bioconductor function expecting a
`SummarizedExperiment`.

***

# Funding

This work was supported by a grant to A/Prof Karen Sheppard from the National
Health and Medical Research Council of Australia (#2020050).

***

# Session Information

```{r session-info}
sessionInfo()
```
