In this workshop, we introduce linear modeling in R from a t-test to ANOVA to simple regression to mixed effects models. We also touch on tools for large modeling projects such as RNA-seq differential expression.
Video at https://youtu.be/UGtgeRPmc6I
Please install R, RStudio, and the following packages.
#Install (once per computer)
# install.packages(c("tidyverse", "lme4", "car",
# "BiocManager","devtools"))
# BiocManager::install(c("limma","variancePartition))
# devtools::install_github("BIGslu/kimma")
#Load (every time you open a new R/RStudio session)
library(tidyverse)
library(broom)
library(lme4)
library(car)
library(limma)
library(variancePartition) #dream
library(kimma)
Briefly, these data are from RNA-sequencing of human dendritic cells cultured with and without virus. Samples are from 3 donors and a random subset of 1000 genes were selected. Expression data are in an limma EList object (named example.voom
) containing expression (E
), sample/patient metadata (targets
), and gene metadata (genes
). Expression is expressed as TMM-normalized log2 counts per million (CPM).
We combine the expression, sample, and gene metadata for 1 gene in a single table for use in modeling.
dat <- as.data.frame(example.voom$E) %>%
rownames_to_column("geneName") %>%
pivot_longer(-geneName, names_to = "libID") %>%
inner_join(example.voom$targets, by = "libID") %>%
inner_join(example.voom$genes, by = "geneName") %>%
filter(hgnc_symbol == "ZNF439")
dat
## # A tibble: 12 × 15
## geneName libID value group lib.size norm.factors donorID median_cv_cover…
## <chr> <chr> <dbl> <fct> <dbl> <dbl> <chr> <dbl>
## 1 ENSG0000017… lib1 8.01 1 79646. 1.00 donor1 0.514
## 2 ENSG0000017… lib2 7.15 1 88008. 0.951 donor1 0.435
## 3 ENSG0000017… lib3 9.30 1 178020. 1.09 donor2 0.374
## 4 ENSG0000017… lib4 5.07 1 133836. 0.943 donor2 0.388
## 5 ENSG0000017… lib5 8.09 1 192547. 1.00 donor3 0.353
## 6 ENSG0000017… lib6 8.67 1 175144. 0.974 donor3 0.349
## 7 ENSG0000017… lib7 9.42 1 205377. 1.02 donor4 0.339
## 8 ENSG0000017… lib8 7.81 1 149311. 0.995 donor4 0.350
## 9 ENSG0000017… lib9 9.26 1 182080. 1.04 donor5 0.342
## 10 ENSG0000017… lib10 8.84 1 181755. 0.980 donor5 0.330
## 11 ENSG0000017… lib11 5.75 1 176991. 1.03 donor6 0.345
## 12 ENSG0000017… lib12 5.39 1 131592. 0.977 donor6 0.356
## # … with 7 more variables: virus <fct>, asthma <chr>, batch <dbl>,
## # hgnc_symbol <chr>, `Previous symbols` <chr>, `Alias symbols` <chr>,
## # gene_biotype <chr>
We do not have time to extensively cover all that should go into experimental design prior to statistical modeling. However, some key aspects to consider are:
In R, formulae are written in the form y ~ x
where the ~
functions like an =
in what you’d traditionally see written in text like y = x
. In the models we explore here, you will only have 1 y
variable. However, you may have more than 1 x
variable, and these are added to the model like y ~ x1 + x2 ...
. You may also have interaction terms in a model such as the impact of x1
within x2
groups. This is written with a :
as in y ~ x1 + x2 + x1:x2
or the shorthand *
which stands for all individual variables and all interactions. This would be y ~ x1 * x2
which is equivalent to y ~ x1 + x2 + x1:x2
.
Formulae are a specific type of R object. Similar to other types, you can check if what you have is of this type with class( )
.
class(y ~ x)
## [1] "formula"
Importantly, this does not check that this is the most correct model for your data. It merely let’s you know that you’ve formatted the syntax correctly for R to recognize a formula.
Under the hood, a t-test is simply a special case of a simple linear regression where there is only 1 categorical x
variable with exactly 2 levels to predict 1 numeric y
variable.
Here, we run a t-test of gene expression value
in control none
vs virus HRV
infected samples for our gene of interest. We see that gene expression does not significantly change with viral infection (P = 0.22).
TT <- t.test(formula = value ~ virus, data = dat)
TT
##
## Welch Two Sample t-test
##
## data: value by virus
## t = 1.3151, df = 9.8045, p-value = 0.2184
## alternative hypothesis: true difference in means between group none and group HRV is not equal to 0
## 95 percent confidence interval:
## -0.8027454 3.0999532
## sample estimates:
## mean in group none mean in group HRV
## 8.304152 7.155548
Similarly, analysis of variance (ANOVA) is another special case of simple linear regression. In this case, it is when there are 1 or more categorical x
variables with 2 or more levels to predict 1 numeric y
variable.
So, we could run the same model as in our t-test and get the same result. Notice that the aov
function does not automatically estimate the P-value. So, we use another function to extract that in a data frame.
AV <- aov(formula = value ~ virus, data = dat)
AV
## Call:
## aov(formula = value ~ virus, data = dat)
##
## Terms:
## virus Residuals
## Sum of Squares 3.957873 22.885276
## Deg. of Freedom 1 10
##
## Residual standard error: 1.512788
## Estimated effects may be unbalanced
tidy(AV)
## # A tibble: 2 × 6
## term df sumsq meansq statistic p.value
## <chr> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 virus 1 3.96 3.96 1.73 0.218
## 2 Residuals 10 22.9 2.29 NA NA
In future, you may see other functions to extract p-values such as summary( )
. These work here as well; I just like the broom
package’s tidy
function as it puts results in a data frame with consistent column names. However, tidy
does not work on all model types.
Now, let’s move beyond 1 x
variable. If we were to add another variable to our t-test, it will not run since it no longer fulfills the special case of 1 x
variable.
t.test(formula = value ~ virus + asthma, data = dat)
## Error in t.test.formula(formula = value ~ virus + asthma, data = dat): 'formula' missing or incorrect
So, we must use an ANOVA. We see that gene expression does not change significantly with either virus or asthma. Note that the p-value for virus is slightly different that the first model value ~ virus
because adding variables impacts degrees of freedom for all variables in the model.
AV <- aov(formula = value ~ virus + asthma, data = dat)
AV
## Call:
## aov(formula = value ~ virus + asthma, data = dat)
##
## Terms:
## virus asthma Residuals
## Sum of Squares 3.957873 0.002241 22.883035
## Deg. of Freedom 1 1 9
##
## Residual standard error: 1.594541
## Estimated effects may be unbalanced
tidy(AV)
## # A tibble: 3 × 6
## term df sumsq meansq statistic p.value
## <chr> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 virus 1 3.96 3.96 1.56 0.244
## 2 asthma 1 0.00224 0.00224 0.000881 0.977
## 3 Residuals 9 22.9 2.54 NA NA
We continue to generalize our model to the heart of both t-tests and ANOVA. Linear regression is the most general version of these tests and can be used for any number of x
variables of any type (numeric, categorical).
We see that our last ANOVA model gave the same results as the lm
model.
LM <- lm(formula = value ~ virus + asthma, data = dat)
LM
##
## Call:
## lm(formula = value ~ virus + asthma, data = dat)
##
## Coefficients:
## (Intercept) virusHRV asthmahealthy
## 8.31782 -1.14860 -0.02733
tidy(LM)
## # A tibble: 3 × 5
## term estimate std.error statistic p.value
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 (Intercept) 8.32 0.797 10.4 0.00000251
## 2 virusHRV -1.15 0.921 -1.25 0.244
## 3 asthmahealthy -0.0273 0.921 -0.0297 0.977
All of our models thus far have compared categorical groups to a reference level. R assumes the reference is the first level alphabetically unless you code a factor to tell it otherwise. For example, asthma
assumes the reference is “asthma” and then compares the other level, “healthy”, to this reference. Hence why are see asthmahealthy in the model results.
sort(unique(dat$asthma))
## [1] "asthma" "healthy"
In contrast, virus
is a factor with levels forcing the reference to be “none” even though it is alphabetically after “HRV”.
sort(unique(dat$virus))
## [1] none HRV
## Levels: none HRV
You could achieve this for the asthma
variable re-coding it as a factor.
dat <- dat %>% mutate(asthma.fct = factor(asthma, levels = c("healthy", "asthma")))
tidy(lm(formula = value ~ virus + asthma.fct, data = dat))
## # A tibble: 3 × 5
## term estimate std.error statistic p.value
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 (Intercept) 8.29 0.797 10.4 0.00000258
## 2 virusHRV -1.15 0.921 -1.25 0.244
## 3 asthma.fctasthma 0.0273 0.921 0.0297 0.977
You may have noticed that we appear to have gained a variable with linear regression. This is the intercept of the fit line. In the case of categorical variables such as here, this variable is not meaningful. However, if you had a numeric x
variable, you may wish to know what the estimated value of y
is at x
= 0 and if this significantly differs from (0,0). This variable does not appear for t.test
or aov
because they only work for categorical x
.
You can remove the intercept from a linear model by starting with 0
such as below. However, even when the intercept isn’t meaningful to your interpretation, this might not be what you want to do. In this case, removing the intercept means we have two results for virus
, each asking if gene expression of that virus group is different from 0. We don’t want this and instead, want the original model asking if none and HRV infected differ.
LM2 <- lm(formula = value ~ 0 + virus + asthma, data = dat)
LM2
##
## Call:
## lm(formula = value ~ 0 + virus + asthma, data = dat)
##
## Coefficients:
## virusnone virusHRV asthmahealthy
## 8.31782 7.16921 -0.02733
tidy(LM2)
## # A tibble: 3 × 5
## term estimate std.error statistic p.value
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 virusnone 8.32 0.797 10.4 0.00000251
## 2 virusHRV 7.17 0.797 8.99 0.00000860
## 3 asthmahealthy -0.0273 0.921 -0.0297 0.977
In practice in R, you never have to make this decision. You can simply use lm( )
and it will take care of the cases where your data fit a t-test or ANOVA. You’ll get the same result regardless of the function.
Linear modeling makes a number of assumptions about your data. You can see a more complete list here but the assumptions of importance for these data are:
y
is roughly equal in all groups (homoscedasticity)Let’s check each of these. We’ll use functions in the tidyverse here. To learn more about working with data in the tidyverse, see previous workshops
All groups are balanced with 6 observations per group.
dat %>% count(virus)
## # A tibble: 2 × 2
## virus n
## <fct> <int>
## 1 none 6
## 2 HRV 6
dat %>% count(asthma)
## # A tibble: 2 × 2
## asthma n
## <chr> <int>
## 1 asthma 6
## 2 healthy 6
Variance of the y
variable are roughly equal. To be considered unequal, you’d expect differences in order of magnitude like 1 vs 10.
dat %>% group_by(virus) %>% summarise(variance = var(value))
## # A tibble: 2 × 2
## virus variance
## <fct> <dbl>
## 1 none 1.97
## 2 HRV 2.61
dat %>% group_by(asthma) %>% summarise(variance = var(value))
## # A tibble: 2 × 2
## asthma variance
## <chr> <dbl>
## 1 asthma 3.17
## 2 healthy 2.20
By design, our samples are not independent because we have a control and virus sample from each donor. Thus, this assumption of linear regression is broken and the results above are not valid.
dat %>% count(donorID)
## # A tibble: 6 × 2
## donorID n
## <chr> <int>
## 1 donor1 2
## 2 donor2 2
## 3 donor3 2
## 4 donor4 2
## 5 donor5 2
## 6 donor6 2
But never fear! We can account for our paired sample design in our model. To do this, we move away from simple linear regression into mixed effects regression. This term refers to the use of both fixed effects (x
) and random effects (have not seen yet) in one model.
To add a random effect, you use the syntax (across | within)
such as expression across time within each donor (time | donor)
. If you don’t have an across variable (usually time), you fill in a 1 as a placeholder such as (1 | donor)
. The syntax for random effects in a model can vary in different R packages. Here, we are using the lme4
package and its associated syntax. If you use another package in future, be sure to check its syntax!
Thus, our model of interest is as follows. Notes that like aov
and lm
, lmer
does not automatically give p-values. We must estimate them separately.
LME <- lmer(formula = value ~ virus + asthma + (1 | donorID), data = dat)
LME
## Linear mixed model fit by REML ['lmerMod']
## Formula: value ~ virus + asthma + (1 | donorID)
## Data: dat
## REML criterion at convergence: 37.412
## Random effects:
## Groups Name Std.Dev.
## donorID (Intercept) 1.136
## Residual 1.182
## Number of obs: 12, groups: donorID, 6
## Fixed Effects:
## (Intercept) virusHRV asthmahealthy
## 8.31782 -1.14860 -0.02733
tidy(car::Anova(LME))
## # A tibble: 2 × 4
## term statistic df p.value
## <chr> <dbl> <dbl> <dbl>
## 1 virus 2.84 1 0.0922
## 2 asthma 0.000564 1 0.981
What a change!! The p-value for virus is less than half what is was when we didn’t include a random effect. This represents a fundamental difference in simple linear vs mixed effects regression.
In simple regression, you are comparing means with error. In our model, this is comparing the two red square values (which is equivalent to 1 slope), keeping the overlap of error bars in mind. As is clear in the plot, the means are not very different and error bars overlap a lot between the two virus groups. Thus, our p-value is large.
ggplot(dat, aes(x=virus, y=value)) +
#individual data points
geom_jitter(width=0.1, height=0) +
#error bars
stat_summary(fun.data=mean_sdl, fun.args = list(mult=1),
geom="errorbar", color="red", width=0.2) +
#mean
stat_summary(fun=mean, geom="point", color="red", shape="square", size=3) +
theme_classic() +
labs(y="Log2 normalized expression")
Note that jitter
randomly distributes the dots along the width. Therefore, you’re plot will not look exactly the same along x but the y-values will be consistent.
In contrast, incorporating donor as a random effect in a mixed effects model allows up to pair the appropriate samples and instead compare multiple slopes. In a sense, it corrects for the initial variation between donors and looks for a consistent change in expression instead of a difference in means. On the left, we see the real data where most (but not all) donors have decreased expression with virus, thus driving a lower p-value but not significant. If, instead, all donors showed a consistent trend, such as the fake example on the right, we would have seen an even smaller, significant p-value.
p1 <- ggplot(dat, aes(x=virus, y=value)) +
#individual data points
geom_point() +
#connect points
geom_line(aes(group=donorID)) +
theme_classic() +
labs(y="Log2 normalized expression", title="Real paired donors")
p2 <- dat %>%
arrange(virus, value) %>%
mutate(group = c(1:6,1:6)) %>%
ggplot(aes(x=virus, y=value)) +
#individual data points
geom_point() +
#connect points
geom_line(aes(group=group)) +
theme_classic() +
labs(y="Log2 normalized expression", title="Fake paired donors")
p1+p2
We initially reduced this RNA-seq data set to 1 gene. In reality, we want to model expression of all genes, which is 1000 models! There are a number of R packages that help us achieve this in less time and with a lot less code that repeating what we’ve done previously.
The limma package is wicked fast and great for simple linear regression. Syntax is a little different than what we’ve used previously. For the sake of time, an analysis of these data is summarized below.
# Make model matrix. This is limma's version of a formula
mm <- model.matrix(~ virus + asthma, data = example.voom$targets)
# Fit linear regression. Similar to lm( )
fit <- lmFit(object = example.voom$E, design = mm)
# Estimate p-values. Similar to tidy( ) or Anova( )
efit <- eBayes(fit)
# Get results in a data frame
fdr <- extract_lmFit(design = mm, fit = efit)
The results contain all genes (1000) and all variables (3) = 3000 rows.
nrow(fdr)
## [1] 3000
limma provides a shortcut to account for random effects by treating paired samples as pseudo replicates. It uses a single random effect estimate for all genes from a given donor based on correlation of expression between these “replicates”. This is not a full mixed effects model but it can account for some of the random effect.
# Make model matrix
mm <- model.matrix(~ virus + asthma, data = example.voom$targets)
# Estimate correlation of gene expression in paired (block) samples
dupCor <- duplicateCorrelation(object = example.voom$E,
design = mm,
block = example.voom$targets$donorID)
# Fit linear regression
fit2 <- lmFit(object = example.voom$E, design = mm,
block=example.voom$targets$donorID,
correlation=dupCor$consensus.correlation)
# Estimate p-values
efit2 <- eBayes(fit2)
# Get results in a data frame
fdr2 <- extract_lmFit(design = mm, fit = efit2)
dream is an extension to fit true mixed effects models of RNA-seq data. Instead of 1 random effect estimate per donor, dream allows different estimates for each gene within a donor. This is important as the impacts of repeated measures or paired sample design are not consistent across all genes.
Note that the weights used in this model (in example.voom$weights
) are voom
weights from limma, not the correct weights for dream. Thus, this example is to show you how to run dream but the results are not valid with these incorrect weights. See the dream tutorial in Resources for more info.
fit3 <- dream(exprObj = example.voom,
formula = ~ virus + asthma + (1|donorID),
data = example.voom$targets)
## Dividing work into 50 chunks...
##
## Total:22 s
efit3 <- eBayes(fit3)
fdr3 <- extract_lmFit(design = fit3$design, fit=efit3)
kimma is an under-development package to run true linear mixed effects models on RNA-seq data. Under the hood, it uses lmer( )
to fit models as we did earlier for the 1 gene. Unlike limma or dream, it also allows you to have a matrix of pairwise comparisons as a random effect such as kinship measures (not shown in this workshop).
fit4 <- kmFit(dat = example.voom, patientID = "donorID",
model = "~ virus + asthma + (1|donorID)",
run.lme = TRUE)
## lme/lmekin model: expression~virus+asthma+(1|donorID)
## Input: 12 libraries from 6 unique patients
## Model: 12 libraries
## Complete: 1000 genes
## Failed: 0 genes
fdr4 <- fit4$lme
#List 5 genes most signif for virus
top.genes <- fdr %>%
filter(variable == "virusHRV") %>%
top_n(5, -adj.P.Val) %>%
distinct(geneName) %>% unlist(use.names = FALSE)
#Combine and format limma results
fdr.limma <- fdr %>%
mutate(group = "limma LM") %>%
bind_rows(mutate(fdr2, group="limma LME")) %>%
#bind_rows(mutate(fdr3, group="dream LME")) %>%
filter(geneName %in% top.genes & variable == "virusHRV") %>%
select(group, geneName, adj.P.Val) %>%
rename(gene=geneName, FDR=adj.P.Val)
#Add kimma results
fdr4 %>%
mutate(group = "kimma LME") %>%
filter(gene %in% top.genes & variable == "virus") %>%
select(group, gene, FDR) %>%
bind_rows(fdr.limma) %>%
mutate(group = factor(group, levels=c("limma LM", "limma LME", #"dream LME",
"kimma LME")),
FDR = formatC(FDR, digits=2)) %>%
arrange(group) %>%
pivot_wider(names_from = group, values_from = FDR)
## # A tibble: 6 × 4
## gene `limma LM` `limma LME` `kimma LME`
## <chr> <chr> <chr> <chr>
## 1 ENSG00000175183 0.0013 0.00052 1.7e-22
## 2 ENSG00000164761 0.0013 0.00075 9.9e-15
## 3 ENSG00000128383 0.0037 0.0027 1.5e-10
## 4 ENSG00000135070 0.0037 0.0024 1.4e-12
## 5 ENSG00000119917 0.0045 0.0028 1e-09
## 6 ENSG00000169248 0.0045 0.0028 6e-09
Looking at 5 smallest FDR values from limma simple linear regression, we can see that the mixed effect methods result in changes in significance estimates. While all of these genes would be classified as differentially expression genes (DEG) at FDR < 0.01, differences exist in many genes as seen by the number of overlapping DEG at FDR < 0.05 in the Venn below. Thus, as always, it’s important to use a model appropriate to your data.
Remember that the dream results were not valid because of the example data weights. Thus, it are not included here.
library(venn)
venn.ls <- list()
venn.ls [["limma LM"]] <- fdr %>%
filter(variable == "virusHRV" & adj.P.Val < 0.05) %>%
distinct(geneName) %>% unlist(use.names = FALSE)
venn.ls [["limma LME"]] <- fdr2 %>%
filter(variable == "virusHRV" & adj.P.Val < 0.05) %>%
distinct(geneName) %>% unlist(use.names = FALSE)
venn.ls [["kimma LME"]] <- fdr4 %>%
filter(variable == "virus" & FDR < 0.05) %>%
distinct(gene) %>% unlist(use.names = FALSE)
venn(venn.ls, box = FALSE)
sessionInfo()
## R version 4.1.2 (2021-11-01)
## Platform: x86_64-apple-darwin17.0 (64-bit)
## Running under: macOS Big Sur 10.16
##
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/4.1/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/4.1/Resources/lib/libRlapack.dylib
##
## locale:
## [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] venn_1.10 kimma_1.1.1 variancePartition_1.24.0
## [4] BiocParallel_1.28.3 limma_3.50.1 car_3.0-12
## [7] carData_3.0-5 lme4_1.1-28 Matrix_1.4-0
## [10] broom_0.7.12 forcats_0.5.1 stringr_1.4.0
## [13] dplyr_1.0.8 purrr_0.3.4 readr_2.1.2
## [16] tidyr_1.2.0 tibble_3.1.6 ggplot2_3.3.5
## [19] tidyverse_1.3.1 patchwork_1.1.1
##
## loaded via a namespace (and not attached):
## [1] minqa_1.2.4 colorspace_2.0-3 ellipsis_0.3.2
## [4] htmlTable_2.4.0 base64enc_0.1-3 fs_1.5.2
## [7] rstudioapi_0.13 farver_2.1.0 fansi_1.0.2
## [10] lubridate_1.8.0 xml2_1.3.3 codetools_0.2-18
## [13] splines_4.1.2 doParallel_1.0.17 knitr_1.37
## [16] Formula_1.2-4 jsonlite_1.8.0 nloptr_2.0.0
## [19] pbkrtest_0.5.1 cluster_2.1.2 dbplyr_2.1.1
## [22] png_0.1-7 compiler_4.1.2 httr_1.4.2
## [25] backports_1.4.1 assertthat_0.2.1 fastmap_1.1.0
## [28] cli_3.2.0 admisc_0.24 htmltools_0.5.2
## [31] prettyunits_1.1.1 tools_4.1.2 lmerTest_3.1-3
## [34] gtable_0.3.0 glue_1.6.1 reshape2_1.4.4
## [37] Rcpp_1.0.8 Biobase_2.54.0 cellranger_1.1.0
## [40] jquerylib_0.1.4 vctrs_0.3.8 nlme_3.1-155
## [43] iterators_1.0.14 xfun_0.29 rvest_1.0.2
## [46] lifecycle_1.0.1 gtools_3.9.2 statmod_1.4.36
## [49] MASS_7.3-55 scales_1.1.1 hms_1.1.1
## [52] parallel_4.1.2 RColorBrewer_1.1-2 yaml_2.3.5
## [55] gridExtra_2.3 sass_0.4.0 rpart_4.1.16
## [58] latticeExtra_0.6-29 stringi_1.7.6 highr_0.9
## [61] foreach_1.5.2 checkmate_2.0.0 caTools_1.18.2
## [64] BiocGenerics_0.40.0 boot_1.3-28 rlang_1.0.1
## [67] pkgconfig_2.0.3 bitops_1.0-7 evaluate_0.15
## [70] lattice_0.20-45 labeling_0.4.2 htmlwidgets_1.5.4
## [73] tidyselect_1.1.2 plyr_1.8.6 magrittr_2.0.2
## [76] R6_2.5.1 gplots_3.1.1 generics_0.1.2
## [79] Hmisc_4.6-0 DBI_1.1.2 pillar_1.7.0
## [82] haven_2.4.3 foreign_0.8-82 withr_2.4.3
## [85] survival_3.2-13 abind_1.4-5 nnet_7.3-17
## [88] modelr_0.1.8 crayon_1.5.0 KernSmooth_2.23-20
## [91] utf8_1.2.2 tzdb_0.2.0 rmarkdown_2.11
## [94] jpeg_0.1-9 progress_1.2.2 grid_4.1.2
## [97] readxl_1.3.1 data.table_1.14.2 reprex_2.0.1
## [100] digest_0.6.29 numDeriv_2016.8-1.1 munsell_0.5.0
## [103] bslib_0.3.1