# Testing non-linear effects

# Introduction

Typical analysis using regression models assumes a linear affect of the covariate on the response. Here we consider testing non-linear effects in the case of 1) continuous and 2) ordered categorical variables.

We demonstrate this feature on a lightly modified analysis of PBMCs from 8 individuals stimulated with interferon-β (Kang, et al, 2018, Nature Biotech).

# Standard processing

Here is the code from the main vignette:

    library(dreamlet)
    library(muscat)
    library(ExperimentHub)
    library(scater)

    # Download data, specifying EH2259 for the Kang, et al study
    eh <- ExperimentHub()
    sce <- eh[["EH2259"]]

    # only keep singlet cells with sufficient reads
    sce <- sce[rowSums(counts(sce) > 0) > 0, ]
    sce <- sce[, colData(sce)$multiplets == "singlet"]

    # compute QC metrics
    qc <- perCellQCMetrics(sce)

    # remove cells with few or many detected genes
    ol <- isOutlier(metric = qc$detected, nmads = 2, log = TRUE)
    sce <- sce[, !ol]

    # set variable indicating stimulated (stim) or control (ctrl)
    sce$StimStatus <- sce$stim

    sce$id <- paste0(sce$StimStatus, sce$ind)

    # Create pseudobulk
    pb <- aggregateToPseudoBulk(sce,
      assay = "counts",
      cluster_id = "cell",
      sample_id = "id",
      verbose = FALSE
    )

# Continuous variable

Consider the continuous variable `Age`. Typical analysis only considers linear effects using a single regression coefficient, but we also want to consider the non-linear effects of age. We can peform a basis expansion using splines instead use 3 coefficients to model the age effect.

    # Simulate age between 18 and 65
    pb$Age <- runif(ncol(pb), 18, 65)

    # formula included non-linear effects of Age
    # by using a natural spline of degree 3
    # This corresponds to using 3 coefficients instead of 1
    form <- ~ splines::ns(Age, 3)

    # Normalize and apply voom/voomWithDreamWeights
    res.proc <- processAssays(pb, form, min.count = 5)

    # Differential expression analysis within each assay
    res.dl <- dreamlet(res.proc, form)

    # The spline has degree 3, so there are 3 coefficients
    # estimated for Age effects
    coefNames(res.dl)

    ## [1] "(Intercept)"          "splines::ns(Age, 3)1" "splines::ns(Age, 3)2"
    ## [4] "splines::ns(Age, 3)3"

    # Jointly test effects of the 3 spline components
    # The test of the 3 coefficients is performed with an F-statistic
    topTable(res.dl, coef = coefNames(res.dl)[2:4], number = 3)

    ## DataFrame with 3 rows and 9 columns
    ##               assay                  ID splines..ns.Age..3.1
    ##         <character>         <character>            <numeric>
    ## 1 FCGR3A+ Monocytes                CCL4             -1.37647
    ## 2   CD14+ Monocytes TNF_ENSG00000232810              1.78005
    ## 3 FCGR3A+ Monocytes               IL1RN              2.90578
    ##   splines..ns.Age..3.2 splines..ns.Age..3.3   AveExpr         F     P.Value
    ##              <numeric>            <numeric> <numeric> <numeric>   <numeric>
    ## 1              1.82686              3.49095   9.57447   17.8454 2.00849e-05
    ## 2             -0.59536              2.47836   4.33720   16.8436 3.95495e-05
    ## 3             -7.22186              6.04113   8.06191   15.6967 4.38939e-05
    ##   adj.P.Val
    ##   <numeric>
    ## 1  0.212037
    ## 2  0.212037
    ## 3  0.212037

# Ordered categorical

We can also test non-linear effects in the case of categorical variables with a natural ordering to the categories. Consider time course data with 4 time points. Each time point is a category and has a natural ordering from first to last.

We have multiple options to model the time course.

- Continuous: Modeling time point as a continuous variable uses a single regression coefficient to model the linear effects of the time course. This is simple, models the order of the time points, but ignores non-linear effects

  Model using `as.numeric(TimePoint)`

- Categorical: Including time point as a typical categorical variable uses estimated the mean response value for each category. So it estimates 4 coefficients. While this can be useful for comparing two categories, it ignores the order of the time points.

  Model using `factor(TimePoint)`

- Ordered categorical: Here, the trend across ordered time points is modled using orthogonal polynomials. The trend is decomposed into independent linear, quadratic, etc., effects that can be tested either jointly or by themselves.

  Model using:

      ord <- c("time_1", "time_2", "time_3", "time_4")
      ordered(factor(TimePoint), ord)

Here we simulated 4 time points, and perform differential expression analysis.

    # Consider data generated across 4 time points
    # While there are no time points in the real data
    # we can add some for demonstration purposes
    pb$TimePoint <- ordered(paste0("time_", rep(1:4, 4)))

    # examine the ordering
    pb$TimePoint

    ##  [1] time_1 time_2 time_3 time_4 time_1 time_2 time_3 time_4 time_1 time_2
    ## [11] time_3 time_4 time_1 time_2 time_3 time_4
    ## Levels: time_1 < time_2 < time_3 < time_4

    # Use formula including time point
    form <- ~TimePoint

    # Normalize and apply voom/voomWithDreamWeights
    res.proc <- processAssays(pb, form, min.count = 5)

    # Differential expression analysis within each assay
    res.dl <- dreamlet(res.proc, form)

    # Examine the coefficient estimated
    # for TimePoint it estimates
    # linear (i.e. L)
    # quadratic (i.e. Q)
    # and cubic (i.e. C) effects
    coefNames(res.dl)

    ## [1] "(Intercept)" "TimePoint.L" "TimePoint.Q" "TimePoint.C"

    # Test only linear effect
    topTable(res.dl, coef = "TimePoint.L", number = 3)

    ## DataFrame with 3 rows and 9 columns
    ##         assay          ID     logFC   AveExpr         t     P.Value adj.P.Val
    ##   <character> <character> <numeric> <numeric> <numeric>   <numeric> <numeric>
    ## 1 CD4 T cells        DCXR -0.671645   6.52867  -5.42880 4.78666e-05  0.393058
    ## 2 CD4 T cells        GGA2 -0.890112   4.98987  -5.26372 6.69370e-05  0.393058
    ## 3 CD8 T cells        FTH1 -0.759875  14.55380  -4.99199 8.13672e-05  0.393058
    ##           B     z.std
    ##   <numeric> <numeric>
    ## 1  1.686147  -5.42880
    ## 2  0.936906  -5.26372
    ## 3  1.627753  -4.99199

    # Test linear, quadratic and cubic effcts
    coefs <- c("TimePoint.L", "TimePoint.Q", "TimePoint.C")
    topTable(res.dl, coef = coefs, number = 3)

    ## DataFrame with 3 rows and 9 columns
    ##         assay                   ID TimePoint.L TimePoint.Q TimePoint.C
    ##   <character>          <character>   <numeric>   <numeric>   <numeric>
    ## 1 CD8 T cells                 CD52    0.903032   -1.528456   -0.984065
    ## 2 CD8 T cells CCL5_ENSG00000161570    0.626201   -0.986502   -0.645468
    ## 3 CD8 T cells                  CD2    0.887578   -1.152415   -0.195616
    ##     AveExpr         F     P.Value adj.P.Val
    ##   <numeric> <numeric>   <numeric> <numeric>
    ## 1   8.69458   16.0946 1.90868e-05 0.0853298
    ## 2  11.88811   16.0433 1.94982e-05 0.0853298
    ## 3   9.67181   15.7793 2.17777e-05 0.0853298

## Sample filtering

Due to variation in cell and read count for each sample, `processAssays()` filters out some sample. This filtering is summarized here:

    details(res.dl)

    ##               assay n_retain    formula formDropsTerms n_genes n_errors
    ## 1           B cells       16 ~TimePoint          FALSE    1961        0
    ## 2   CD14+ Monocytes       16 ~TimePoint          FALSE    3087        0
    ## 3       CD4 T cells       16 ~TimePoint          FALSE    5262        0
    ## 4       CD8 T cells       16 ~TimePoint          FALSE    1030        0
    ## 5   Dendritic cells       13 ~TimePoint          FALSE     164        0
    ## 6 FCGR3A+ Monocytes       16 ~TimePoint          FALSE    1160        0
    ## 7    Megakaryocytes       13 ~TimePoint          FALSE     172        0
    ## 8          NK cells       16 ~TimePoint          FALSE    1656        0
    ##   error_initial
    ## 1         FALSE
    ## 2         FALSE
    ## 3         FALSE
    ## 4         FALSE
    ## 5         FALSE
    ## 6         FALSE
    ## 7         FALSE
    ## 8         FALSE

Whle all 16 samples are detained in B cells, only 9 are retained for megakaryocytes. This can result in a time point being dropped, and so the polynomial expansion for some cell types can have a lower degree. The combined results will then have `NA` values for these coefficients. For example, for `TIMP1` in `Megakaryocytes` above there is not enought data to fit the cubic term, so `TimePoint.C` is `NA`.

# Session Info

    ## R version 4.6.0 (2026-04-24)
    ## Platform: x86_64-pc-linux-gnu
    ## Running under: Ubuntu 24.04.4 LTS
    ##
    ## Matrix products: default
    ## BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
    ## LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so;  LAPACK version 3.12.0
    ##
    ## locale:
    ##  [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C
    ##  [3] LC_TIME=en_US.UTF-8        LC_COLLATE=en_US.UTF-8
    ##  [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8
    ##  [7] LC_PAPER=en_US.UTF-8       LC_NAME=C
    ##  [9] LC_ADDRESS=C               LC_TELEPHONE=C
    ## [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
    ##
    ## time zone: Etc/UTC
    ## tzcode source: system (glibc)
    ##
    ## attached base packages:
    ## [1] stats4    stats     graphics  grDevices utils     datasets  methods
    ## [8] base
    ##
    ## other attached packages:
    ##  [1] GO.db_3.23.1                org.Hs.eg.db_3.23.1
    ##  [3] AnnotationDbi_1.75.0        zenith_1.15.0
    ##  [5] muscData_1.27.0             scater_1.41.1
    ##  [7] scuttle_1.23.1              ExperimentHub_3.3.0
    ##  [9] AnnotationHub_4.3.0         BiocFileCache_3.3.0
    ## [11] dbplyr_2.5.2                muscat_1.27.0
    ## [13] dreamlet_1.11.0             SingleCellExperiment_1.35.1
    ## [15] SummarizedExperiment_1.43.0 Biobase_2.73.1
    ## [17] GenomicRanges_1.65.0        Seqinfo_1.3.0
    ## [19] IRanges_2.47.2              S4Vectors_0.51.3
    ## [21] BiocGenerics_0.59.6         generics_0.1.4
    ## [23] MatrixGenerics_1.25.0       matrixStats_1.5.0
    ## [25] variancePartition_1.43.1    BiocParallel_1.47.0
    ## [27] limma_3.69.1                ggplot2_4.0.3
    ## [29] BiocStyle_2.41.0
    ##
    ## loaded via a namespace (and not attached):
    ##   [1] bitops_1.0-9              httr_1.4.8
    ##   [3] RColorBrewer_1.1-3        doParallel_1.0.17
    ##   [5] Rgraphviz_2.57.0          numDeriv_2016.8-1.1
    ##   [7] tools_4.6.0               backports_1.5.1
    ##   [9] utf8_1.2.6                R6_2.6.1
    ##  [11] metafor_5.0-1             mgcv_1.9-4
    ##  [13] GetoptLong_1.1.1          withr_3.0.2
    ##  [15] prettyunits_1.2.0         gridExtra_2.3
    ##  [17] cli_3.6.6                 sandwich_3.1-1
    ##  [19] labeling_0.4.3            sass_0.4.10
    ##  [21] KEGGgraph_1.73.0          SQUAREM_2026.1
    ##  [23] mvtnorm_1.3-7             S7_0.2.2
    ##  [25] blme_1.0-7                mixsqp_0.3-54
    ##  [27] invgamma_1.2              RSQLite_3.53.1
    ##  [29] shape_1.4.6.1             gtools_3.9.5
    ##  [31] dplyr_1.2.1               Matrix_1.7-5
    ##  [33] metadat_1.6-0             ggbeeswarm_0.7.3
    ##  [35] abind_1.4-8               lifecycle_1.0.5
    ##  [37] yaml_2.3.12               edgeR_4.11.1
    ##  [39] mathjaxr_2.0-0            gplots_3.3.0
    ##  [41] SparseArray_1.13.2        grid_4.6.0
    ##  [43] blob_1.3.0                crayon_1.5.3
    ##  [45] lattice_0.22-9            beachmat_2.29.0
    ##  [47] msigdbr_26.1.0            annotate_1.91.0
    ##  [49] KEGGREST_1.53.0           sys_3.4.3
    ##  [51] maketools_1.3.2           pillar_1.11.1
    ##  [53] knitr_1.51                ComplexHeatmap_2.29.0
    ##  [55] rjson_0.2.23              boot_1.3-32
    ##  [57] corpcor_1.6.10            codetools_0.2-20
    ##  [59] glue_1.8.1                data.table_1.18.4
    ##  [61] vctrs_0.7.3               png_0.1-9
    ##  [63] Rdpack_2.6.6              gtable_0.3.6
    ##  [65] assertthat_0.2.1          cachem_1.1.0
    ##  [67] zigg_0.0.2                xfun_0.57
    ##  [69] rbibutils_2.4.1           S4Arrays_1.13.0
    ##  [71] Rfast_2.1.5.2             reformulas_0.4.4
    ##  [73] iterators_1.0.14          statmod_1.5.2
    ##  [75] nlme_3.1-169              pbkrtest_0.5.5
    ##  [77] bit64_4.8.2               progress_1.2.3
    ##  [79] EnvStats_3.1.0            filelock_1.0.3
    ##  [81] bslib_0.11.0              TMB_1.9.21
    ##  [83] irlba_2.3.7               vipor_0.4.7
    ##  [85] KernSmooth_2.23-26        colorspace_2.1-2
    ##  [87] rmeta_3.0                 DBI_1.3.0
    ##  [89] tidyselect_1.2.1          bit_4.6.0
    ##  [91] compiler_4.6.0            curl_7.1.0
    ##  [93] httr2_1.2.2               graph_1.91.0
    ##  [95] BiocNeighbors_2.7.2       DelayedArray_0.39.3
    ##  [97] scales_1.4.0              caTools_1.18.3
    ##  [99] remaCor_0.0.20            rappdirs_0.3.4
    ## [101] stringr_1.6.0             digest_0.6.39
    ## [103] minqa_1.2.8               rmarkdown_2.31
    ## [105] aod_1.3.3                 XVector_0.53.0
    ## [107] RhpcBLASctl_0.23-42       htmltools_0.5.9
    ## [109] pkgconfig_2.0.3           lme4_2.0-1
    ## [111] sparseMatrixStats_1.25.0  mashr_0.2.79
    ## [113] fastmap_1.2.0             rlang_1.2.0
    ## [115] GlobalOptions_0.1.4       DelayedMatrixStats_1.35.0
    ## [117] farver_2.1.2              jquerylib_0.1.4
    ## [119] zoo_1.8-15                jsonlite_2.0.0
    ## [121] BiocSingular_1.29.0       RCurl_1.98-1.18
    ## [123] magrittr_2.0.5            Rcpp_1.1.1-1.1
    ## [125] babelgene_22.9            viridis_0.6.5
    ## [127] EnrichmentBrowser_2.43.0  stringi_1.8.7
    ## [129] MASS_7.3-65               plyr_1.8.9
    ## [131] parallel_4.6.0            ggrepel_0.9.8
    ## [133] Biostrings_2.81.2         splines_4.6.0
    ## [135] hms_1.1.4                 circlize_0.4.18
    ## [137] locfit_1.5-9.12           buildtools_1.0.0
    ## [139] reshape2_1.4.5            ScaledMatrix_1.21.0
    ## [141] BiocVersion_3.24.0        XML_3.99-0.23
    ## [143] evaluate_1.0.5            RcppParallel_5.1.11-2
    ## [145] BiocManager_1.30.27       nloptr_2.2.1
    ## [147] foreach_1.5.2             tidyr_1.3.2
    ## [149] purrr_1.2.2               clue_0.3-68
    ## [151] scattermore_1.2           ashr_2.2-63
    ## [153] rsvd_1.0.5                broom_1.0.13
    ## [155] xtable_1.8-8              fANCOVA_0.6-1
    ## [157] viridisLite_0.4.3         truncnorm_1.0-9
    ## [159] tibble_3.3.1              lmerTest_3.2-1
    ## [161] glmmTMB_1.1.14            memoise_2.0.1
    ## [163] beeswarm_0.4.0            cluster_2.1.8.2
    ## [165] GSEABase_1.75.0