# Factorial Designs with GLM

    library(pepdiff)
    library(dplyr)

## When you need GLM

Pairwise tests work well for simple two-group comparisons. But real experiments often have multiple factors:

- Treatment × Timepoint (does treatment effect change over time?)
- Genotype × Treatment (does treatment work differently in wildtype vs knockout?)
- Condition × Dose × Timepoint (complex factorial)

GLM (Generalised Linear Model) handles these designs naturally. It models all factors simultaneously, accounting for their individual and combined effects.

## Why Gamma GLM?

Proteomics abundances have particular properties:

1. Always positive: You can’t have negative abundance
2. Right-skewed: Many low values, few high values
3. Variance scales with mean: More abundant peptides have more variable measurements

The Gamma distribution models data with exactly these properties. The log link function means model coefficients are interpretable as fold changes - which is what you want.

Important: Zeros cause Gamma GLM to fail. This is correct behaviour - a zero abundance is either a missing value (below detection limit) or a data error. pepdiff treats zeros as missing data.

## Example: 2×2 factorial design

A 2×2 factorial design means two factors, each with two levels. Here: treatment (ctrl, trt) × timepoint (0h, 24h). This gives four experimental conditions:

| 0h | 24h
--- | --- | ---
ctrl | ✓ | ✓
trt | ✓ | ✓

Each peptide is measured in all four conditions (with replicates), letting us ask: does treatment have an effect? Does that effect depend on timepoint?

We’ll use 5 replicates per condition (20 total observations per peptide) and simulate different effect patterns to see how GLM handles them.

### The simulated peptide groups

To illustrate how GLM handles different effect patterns, we simulate 40 peptides in four groups:

Group | Peptides | Effect Pattern | What GLM should find
--- | --- | --- | ---
A | PEP_001–010 | Treatment effect only | Main effect significant, same at both timepoints
B | PEP_011–020 | Timepoint effect only | No treatment effect (treatment comparison)
C | PEP_021–030 | Interaction | Treatment works at 24h but not 0h
D | PEP_031–040 | No effect | Nothing significant

### What these patterns look like in raw data

Here’s what the mean abundances look like for each group across the four conditions (using a baseline of ~10 for illustration):

Group | ctrl @ 0h | ctrl @ 24h | trt @ 0h | trt @ 24h | Pattern
--- | --- | --- | --- | --- | ---
A | 10 | 10 | 30 | 30 | trt 3× higher at both timepoints
B | 10 | 30 | 10 | 30 | 24h 3× higher, same for ctrl and trt
C | 10 | 10 | 10 | 40 | trt effect only at 24h (interaction)
D | 10 | 10 | 10 | 10 | no differences

Notice how Group C is the tricky one: treatment has no effect at 0h (10 vs 10) but a 4-fold effect at 24h (10 vs 40). The main effect would average these, giving ~2-fold - which doesn’t represent either timepoint accurately.

We’ll refer back to these groups as we analyse the results.

    set.seed(456)

    n_peptides <- 40
    n_reps <- 5  # 5 reps per condition = 20 observations per peptide

    peptides <- paste0("PEP_", sprintf("%03d", 1:n_peptides))
    genes <- paste0("GENE_", LETTERS[((1:n_peptides - 1) %% 26) + 1])

    # Step 1: Create base abundance for each peptide
    # Each peptide gets ONE base value shared across all its observations
    peptide_info <- tibble(
      peptide = peptides,
      gene_id = genes,
      pep_num = 1:n_peptides,
      base = rgamma(n_peptides, shape = 6, rate = 0.6)  # Random baseline abundance
    )

    # Step 2: Create the experimental design (all combinations)
    # 40 peptides × 2 treatments × 2 timepoints × 5 reps = 800 observations
    design <- expand.grid(
      peptide = peptides,
      treatment = c("ctrl", "trt"),
      timepoint = c("0h", "24h"),
      bio_rep = 1:n_reps,
      stringsAsFactors = FALSE
    )

    # Step 3: Join design with peptide info and apply effects
    sim_data <- design %>%
      left_join(peptide_info, by = "peptide") %>%
      mutate(
        # Group A (peptides 1-10): Treatment effect only
        # trt is 3-fold higher than ctrl, same at both timepoints
        trt_effect = ifelse(pep_num <= 10 & treatment == "trt", 3, 1),

        # Group B (peptides 11-20): Timepoint effect only
        # 24h is 3-fold higher than 0h, same for both treatments
        # NO treatment effect - ctrl and trt should be identical
        time_effect = ifelse(pep_num > 10 & pep_num <= 20 & timepoint == "24h", 3, 1),

        # Group C (peptides 21-30): Interaction
        # Treatment works at 24h (4-fold) but NOT at 0h
        int_effect = case_when(
          pep_num > 20 & pep_num <= 30 & treatment == "trt" & timepoint == "24h" ~ 4,
          TRUE ~ 1
        ),

        # Group D (peptides 31-40): No effect
        # All effect multipliers are 1 (no change)

        # Final abundance = base × all effects, with Gamma noise
        value = rgamma(n(), shape = 15, rate = 15 / (base * trt_effect * time_effect * int_effect))
      ) %>%
      select(peptide, gene_id, treatment, timepoint, bio_rep, value)

    # Import
    temp_file <- tempfile(fileext = ".csv")
    write.csv(sim_data, temp_file, row.names = FALSE)

    dat <- read_pepdiff(
      temp_file,
      id = "peptide",
      gene = "gene_id",
      value = "value",
      factors = c("treatment", "timepoint"),
      replicate = "bio_rep"
    )

    dat
    #> pepdiff_data object
    #> -------------------
    #> Peptides: 40
    #> Observations: 800
    #> Factors: treatment, timepoint
    #>
    #> Design:
    #>   treatment=ctrl, timepoint=0h: 5 reps
    #>   treatment=ctrl, timepoint=24h: 5 reps
    #>   treatment=trt, timepoint=0h: 5 reps
    #>   treatment=trt, timepoint=24h: 5 reps
    #>
    #> No missing values

## Basic GLM analysis: main effects

    results <- compare(
      dat,
      compare = "treatment",
      ref = "ctrl",
      method = "glm"
    )
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions
    #> NOTE: Results may be misleading due to involvement in interactions

    results
    #> pepdiff_results object
    #> ----------------------
    #> Method: glm
    #> Peptides: 40
    #> Comparisons: 1
    #> Total tests: 40
    #>
    #> Significant (FDR < 0.05): 21 (52.5%)
    #> Marked significant: 21

This fits a Gamma GLM for each peptide with both treatment and timepoint as predictors, then extracts the treatment contrast (trt vs ctrl) averaged across timepoints. This averaged effect is called a “marginal” or “main” effect.

### Did GLM find the treatment effects?

Since we simulated this data, we know the truth:

- Group A: 3-fold treatment effect at both timepoints
- Group B: No treatment effect (the timepoint change affects ctrl and trt equally)
- Group C: 4-fold treatment effect at 24h, but no effect at 0h
- Group D: No effect

Let’s see what the GLM main effect analysis finds:

    results$results %>%
      mutate(
        pep_num = as.numeric(gsub("PEP_", "", peptide)),
        group = case_when(
          pep_num <= 10 ~ "A: Treatment only",
          pep_num <= 20 ~ "B: Timepoint only",
          pep_num <= 30 ~ "C: Interaction",
          TRUE ~ "D: No effect"
        )
      ) %>%
      group_by(group) %>%
      summarise(
        n_significant = sum(significant),
        n_peptides = n(),
        median_fc = round(median(fold_change, na.rm = TRUE), 2)
      )
    #> # A tibble: 4 × 4
    #>   group             n_significant n_peptides median_fc
    #>   <chr>                     <int>      <int>     <dbl>
    #> 1 A: Treatment only            10         10      2.77
    #> 2 B: Timepoint only             0         10      0.99
    #> 3 C: Interaction               10         10      2.05
    #> 4 D: No effect                  1         10      0.97

- Group A: All 10 detected ✓ — the consistent 3-fold treatment effect is correctly found
- Group B: 0 detected ✓ — correctly identifies no treatment effect (the timepoint change affects both ctrl and trt equally, so trt/ctrl ≈ 1)
- Group C: All 10 detected — but note the diluted fold change (~2 instead of 4). The main effect averages the 24h effect (4-fold) with the 0h effect (1-fold), giving ~2-fold
- Group D: 0-1 detected ✓ — correctly negative (1 false positive at α=0.05 is expected by chance)

    plot(results)

## Stratified comparisons with `within`

The main effect averages across timepoints. But Group C has an effect at 24h only - the main effect diluted a 4-fold change to ~2-fold. Can we recover the true effect?

Use the `within` argument to get conditional effects - the treatment effect within each timepoint separately:

    results_stratified <- compare(
      dat,
      compare = "treatment",
      ref = "ctrl",
      within = "timepoint",
      method = "glm"
    )

    results_stratified
    #> pepdiff_results object
    #> ----------------------
    #> Method: glm
    #> Peptides: 40
    #> Comparisons: 1
    #> Total tests: 80
    #>
    #> Significant (FDR < 0.05): 30 (37.5%)
    #> Marked significant: 30

Now we get separate comparisons: treatment effect at 0h, and treatment effect at 24h.

### Stratified results by group

Let’s see how each group looks when we analyse timepoints separately:

    results_stratified$results %>%
      mutate(
        pep_num = as.numeric(gsub("PEP_", "", peptide)),
        group = case_when(
          pep_num <= 10 ~ "A: Treatment only",
          pep_num <= 20 ~ "B: Timepoint only",
          pep_num <= 30 ~ "C: Interaction",
          TRUE ~ "D: No effect"
        )
      ) %>%
      group_by(group, timepoint) %>%
      summarise(
        n_significant = sum(significant),
        median_fc = round(median(fold_change, na.rm = TRUE), 2),
        .groups = "drop"
      ) %>%
      tidyr::pivot_wider(
        names_from = timepoint,
        values_from = c(n_significant, median_fc)
      )
    #> # A tibble: 4 × 5
    #>   group            n_significant_0h n_significant_24h median_fc_0h median_fc_24h
    #>   <chr>                       <int>             <int>        <dbl>         <dbl>
    #> 1 A: Treatment on…               10                10         2.6           2.86
    #> 2 B: Timepoint on…                0                 0         0.99          0.94
    #> 3 C: Interaction                  0                10         1.01          4.09
    #> 4 D: No effect                    0                 0         1.01          0.96

What stratified analysis reveals:

- Group A: FC ≈ 3 at both timepoints — the main effect was accurate
- Group B: FC ≈ 1 at both timepoints, 0 significant — correctly negative at both
- Group C: FC ≈ 1 at 0h, FC ≈ 4 at 24h — now we see the true 4-fold effect!
- Group D: FC ≈ 1 at both, ~0 significant — correctly negative

For Group C, stratified analysis recovers the true 4-fold effect at 24h that was diluted to ~2-fold in the main effect.

## Handling interactions

Group C illustrates an interaction: the treatment effect depends on timepoint. The main effect (FC ≈ 2) doesn’t represent either condition accurately - it’s the average of a 4-fold effect and no effect.

The emmeans warning: If you see “Results may be misleading due to involvement in interactions”, it means exactly this - the averaged main effect may not represent any real biological condition.

### When to use main effects vs stratified

    # Compare main vs stratified for all groups
    main_summary <- results$results %>%
      mutate(
        pep_num = as.numeric(gsub("PEP_", "", peptide)),
        group = case_when(
          pep_num <= 10 ~ "A", pep_num <= 20 ~ "B",
          pep_num <= 30 ~ "C", TRUE ~ "D"
        )
      ) %>%
      group_by(group) %>%
      summarise(main_fc = round(median(fold_change, na.rm = TRUE), 2))

    strat_summary <- results_stratified$results %>%
      mutate(
        pep_num = as.numeric(gsub("PEP_", "", peptide)),
        group = case_when(
          pep_num <= 10 ~ "A", pep_num <= 20 ~ "B",
          pep_num <= 30 ~ "C", TRUE ~ "D"
        )
      ) %>%
      group_by(group, timepoint) %>%
      summarise(fc = round(median(fold_change, na.rm = TRUE), 2), .groups = "drop") %>%
      tidyr::pivot_wider(names_from = timepoint, values_from = fc)

    left_join(main_summary, strat_summary, by = "group")
    #> # A tibble: 4 × 4
    #>   group main_fc  `0h` `24h`
    #>   <chr>   <dbl> <dbl> <dbl>
    #> 1 A        2.77  2.6   2.86
    #> 2 B        0.99  0.99  0.94
    #> 3 C        2.05  1.01  4.09
    #> 4 D        0.97  1.01  0.96

Use main effects when: the effect is consistent across conditions (Group A: ~3 at both timepoints, main ≈ 3)

Use stratified (`within`) when: the effect differs across conditions (Group C: ~1 at 0h, ~4 at 24h, main ≈ 2 is misleading)

## Checking convergence

Not every peptide’s model will converge. This happens when: - Too few observations for that peptide - Extreme values or near-zero variance - Data patterns that the model can’t fit

    # Check diagnostics
    n_converged <- sum(results$diagnostics$converged)
    n_failed <- sum(!results$diagnostics$converged)

    cat("Converged:", n_converged, "\n")
    #> Converged: 40
    cat("Failed:", n_failed, "\n")
    #> Failed: 0

Failed peptides are excluded from results. This is correct behaviour - if the model can’t fit, you shouldn’t trust its estimates.

    # View failed peptides (if any)
    results$diagnostics %>%
      filter(!converged) %>%
      select(peptide, converged)

Common causes of convergence failure: - Peptide measured in too few samples - All values identical (zero variance) - Extreme outliers - Too many missing values

What to do: - Check if failed peptides have data quality issues - Consider whether they have enough observations for the model complexity - Don’t force convergence - if it fails, you need more data or a simpler model - See peppwR for power analysis and sample size planning

## Model checking

The diagnostics slot contains model information:

    # Deviance: lower is better fit (relative to other peptides)
    results$diagnostics %>%
      arrange(desc(deviance)) %>%
      head()
    #> # A tibble: 6 × 7
    #>   peptide converged error deviance residuals    std_residuals fitted
    #>   <chr>   <lgl>     <chr>    <dbl> <named list> <named list>  <named list>
    #> 1 PEP_025 TRUE      <NA>      1.98 <dbl [20]>   <dbl [20]>    <dbl [20]>
    #> 2 PEP_015 TRUE      <NA>      1.89 <dbl [20]>   <dbl [20]>    <dbl [20]>
    #> 3 PEP_040 TRUE      <NA>      1.62 <dbl [20]>   <dbl [20]>    <dbl [20]>
    #> 4 PEP_019 TRUE      <NA>      1.42 <dbl [20]>   <dbl [20]>    <dbl [20]>
    #> 5 PEP_017 TRUE      <NA>      1.37 <dbl [20]>   <dbl [20]>    <dbl [20]>
    #> 6 PEP_008 TRUE      <NA>      1.34 <dbl [20]>   <dbl [20]>    <dbl [20]>

The `error` column contains diagnostic messages only when models fail to converge - `NA` means no error occurred (success). The `deviance` column measures model fit: high deviance relative to other peptides may indicate poor fit. Investigate these peptides if they’re in your significant results.

## Exporting results

The results are stored in a tidy tibble, ready for export:

    # Main effect results
    glimpse(results$results)
    #> Rows: 40
    #> Columns: 11
    #> $ peptide     <chr> "PEP_001", "PEP_002", "PEP_003", "PEP_004", "PEP_005", "PE…
    #> $ gene_id     <chr> "GENE_A", "GENE_B", "GENE_C", "GENE_D", "GENE_E", "GENE_F"…
    #> $ comparison  <chr> "trt - ctrl", "trt - ctrl", "trt - ctrl", "trt - ctrl", "t…
    #> $ fold_change <dbl> 2.5219150, 2.9008270, 2.5137042, 3.1246797, 2.8298924, 2.3…
    #> $ log2_fc     <dbl> 1.33451967, 1.53646428, 1.32981487, 1.64370830, 1.50074718…
    #> $ estimate    <dbl> 0.92501854, 1.06499588, 0.92175743, 1.13933177, 1.04023868…
    #> $ se          <dbl> 0.08385427, 0.10236298, 0.12294039, 0.13358011, 0.10630226…
    #> $ test        <chr> "glm", "glm", "glm", "glm", "glm", "glm", "glm", "glm", "g…
    #> $ p_value     <dbl> 6.895588e-09, 1.578496e-08, 1.273022e-06, 2.392305e-07, 3.…
    #> $ fdr         <dbl> 1.379118e-07, 2.104661e-07, 4.629170e-06, 1.063247e-06, 3.…
    #> $ significant <lgl> TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE…

Stratified results include the `within` factor as an additional column:

    # Stratified results - note the timepoint column
    glimpse(results_stratified$results)
    #> Rows: 80
    #> Columns: 13
    #> $ peptide     <chr> "PEP_001", "PEP_002", "PEP_003", "PEP_004", "PEP_005", "PE…
    #> $ gene_id     <chr> "GENE_A", "GENE_B", "GENE_C", "GENE_D", "GENE_E", "GENE_F"…
    #> $ comparison  <chr> "trt - ctrl", "trt - ctrl", "trt - ctrl", "trt - ctrl", "t…
    #> $ fold_change <dbl> 2.3064900, 2.6433935, 2.5347717, 3.2471510, 2.7527029, 2.5…
    #> $ log2_fc     <dbl> 1.20569901, 1.40239120, 1.34185580, 1.69917447, 1.46084893…
    #> $ estimate    <dbl> 0.83572687, 0.97206351, 0.93010356, 1.17777799, 1.01258332…
    #> $ se          <dbl> 0.11726930, 0.16455790, 0.13108476, 0.22764781, 0.15943591…
    #> $ test        <chr> "glm", "glm", "glm", "glm", "glm", "glm", "glm", "glm", "g…
    #> $ p_value     <dbl> 9.935613e-05, 3.588548e-04, 1.024537e-04, 8.492950e-04, 2.…
    #> $ fdr         <dbl> 0.0010245372, 0.0017942740, 0.0010245372, 0.0037746444, 0.…
    #> $ significant <lgl> TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE, TRUE…
    #> $ stratum     <chr> "timepoint=0h", "timepoint=0h", "timepoint=0h", "timepoint…
    #> $ timepoint   <chr> "0h", "0h", "0h", "0h", "0h", "0h", "0h", "0h", "0h", "0h"…

Save to CSV for downstream analysis:

    # All results
    write.csv(results$results, "glm_results.csv", row.names = FALSE)

    # Significant hits only
    results$results %>%
      filter(significant) %>%
      write.csv("significant_hits.csv", row.names = FALSE)

    # Stratified results (one row per peptide per timepoint)
    write.csv(results_stratified$results, "stratified_results.csv", row.names = FALSE)

## Practical recommendations

1. Start simple: Run without `within` first to get main effects
2. Check for interactions: If effects differ across factor levels, use `within` (see below)
3. Inspect convergence: Many failures suggest the model is too complex for your data
4. Look at diagnostics: Volcano and p-value histogram should look reasonable
5. Don’t over-interpret: These are statistical estimates, not ground truth

### Quick interaction check

To check for interactions in your own data, compare fold changes across strata:

    compare(dat, compare = "treatment", ref = "ctrl",
            within = "timepoint", method = "glm")$results %>%
      group_by(timepoint) %>%
      summarise(median_fc = median(fold_change), .groups = "drop")

If these FCs differ substantially (e.g., 1 vs 4), you have an interaction - use stratified results.

## When GLM struggles

- Very small sample sizes (< 3 per condition)
- Heavy-tailed or highly non-normal data
- Many zeros (consider whether these are true zeros or missing)

If GLM diagnostics look poor, try the ART method - see `vignette("art_analysis")`.