Differentielle Gen-Expression

RNA-Seq wird häufig angewendet, um zu untersuchen, welche Gene in ihrer Expressionsstärke von einem gegebenem äußerem Umstand abhängen. Im einfachsten fall vergleicht man dann zwei Gruppen von Proben, zum Beispiel:

Oft ist das experimentelle Design komplizierter als ein einfacher Vergleich zweier Gruppen.

Wie bereits erwähnt, gibt es spezialisierte Software, die solche Analsyen durchführen kann. EIn Paket (an dessen Enwticklung ich beteilgt war) ist DESeq2. Es kann mit recht komplexen Designs arbeiten; hier möchte ich aber nur die Anwendung für einen einfachen Zwei-Gruppen-Vergleich vorführen.

Beispiel einer DESeq2-Analyse

Als Bespiel verwenden wir die Mausdaten von der Kaessmann-Gruppe und vergleichen die Genexpression im Herz neugeborener Mäuse mit denen in von 2 Monate alten Mäusen.

Wir laden die Count-Matrix

suppressPackageStartupMessages( library(tidyverse) )

read_tsv( "Downloads/evodevo_mouse_counts.tsv.gz" ) -> mouse_counts_all
Rows: 36141 Columns: 317
── Column specification ────────────────────────────────────────────────────────
Delimiter: "\t"
chr   (1): gene_id
dbl (316): Brain_e10.5_1, Brain_e10.5_2, Brain_e10.5_3, Brain_e11.5_1, Brain...

ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.

Wir verwenden nur die Proben zu “Heart” von Zeitpunkt “P0” und “P63”, und wählen daher nur die benötigten Spalten. Dann wandeln wir die Tabelle in eine Matrix um, weil DESeq2 eine solche Matrix aus Read-Zahlen als Eingabe benötigt.:

mouse_counts_all %>% select( gene_id, starts_with("Heart_P0"), starts_with("Heart_P63") ) %>%
column_to_rownames( "gene_id" ) %>% 
as.matrix -> count_matrix

count_matrix[1:5, ]
                   Heart_P0_1 Heart_P0_2 Heart_P0_3 Heart_P0_4 Heart_P63_1
ENSMUSG00000000001       2167       1705       1911       2466        1501
ENSMUSG00000000003          0          0          0          0           0
ENSMUSG00000000028        358        361        377        476          97
ENSMUSG00000000037        164         86        253        206          74
ENSMUSG00000000049          5          2          2          2           4
                   Heart_P63_2 Heart_P63_3 Heart_P63_4
ENSMUSG00000000001        1012         756        1137
ENSMUSG00000000003           0           0           0
ENSMUSG00000000028          46          68         102
ENSMUSG00000000037          32          18          50
ENSMUSG00000000049           0           8          16

Außerdem benötigt DESeq2 eine Proben-Tabelle, d.h. eine Tabelle mit einer Zeile pro Probe und Spalten für die Kovariaten (d.h., die Informationen, die die Unterschiede der Proben beschreiben). Unsere Tabelle ist einfach:

tibble( sampleName = colnames(count_matrix) ) %>%
mutate( timepoint = str_extract( sampleName, "P\\d+" ) ) -> sample_table

sample_table
# A tibble: 8 × 2
  sampleName  timepoint
  <chr>       <chr>    
1 Heart_P0_1  P0       
2 Heart_P0_2  P0       
3 Heart_P0_3  P0       
4 Heart_P0_4  P0       
5 Heart_P63_1 P63      
6 Heart_P63_2 P63      
7 Heart_P63_3 P63      
8 Heart_P63_4 P63      

Wir laden das Paket “DESeq2”:

suppressPackageStartupMessages(
  library( DESeq2 )
)  

Einschub: DESeq2 ist als Teil des Bioconductor-Projekts veröffentlicht, welches eine Sammlung von R-Paketen füë die Biologie darstellt, die alle gewisse Mindest-Qualitäts-Standards aufweisen, und (zumindest im Prinzip) so aufeinander abgestimnmt sind, dass man sie gut kombinieren kann. Bioconductor-Pakete installiert man nicht, wie andere R-Pakete, mit install.packages, sondern mit der Funktion install aus dem Paket BiocManager. Letzteres muss man also zuerst installieren, und zwar wie gewohnt mit install.packages.

Zur Installation schreibt man also:

install.packages( "BiocManager" )
BiocManager::install( "DESeq2" )

DESeq2 verwendet ein objekt-orientiertes Interface. Das bedeutet, dass alle zu einem Analyse-Projekt gehörenden Daten, zusammen mit Zwischenergebnissen, in einem einzelnen komplexen Objekt (das als eine Variable vorliegt) gebündelt sind.

Wir erstellen so in Objekt (genannt ein “DESeq Data Set”) wie folgt:

DESeqDataSetFromMatrix( count_matrix, sample_table, ~timepoint ) -> dds
converting counts to integer mode
Warning in DESeqDataSet(se, design = design, ignoreRank): some variables in
design formula are characters, converting to factors
dds
class: DESeqDataSet 
dim: 36141 8 
metadata(1): version
assays(1): counts
rownames(36141): ENSMUSG00000000001 ENSMUSG00000000003 ...
  ENSMUSG00000102131 ENSMUSG00000102132
rowData names(0):
colnames(8): Heart_P0_1 Heart_P0_2 ... Heart_P63_3 Heart_P63_4
colData names(2): sampleName timepoint

Das Objekt dds bündelt jetzt die drei Informationen, die wir übergeben haben: die Matrix mit den Read-Zahlen, die Proben-Tabelle und die sog. Design-Formel. Letztere beschreibt, wie die Spalten in der Probentabelle bei der Auswertung zu nutzen sind. Bei einem Zwei-Gruppen-Vergleich ist die Design-Formel sehr einfach, nämlich eine Tilde, gefolgt vom Namen der Spalte, die Zuordnung zu den zwei Gruppen angibt.

Die eigentlich Analyse wird dann automatisch durch die Funktion DESeq durchgeführt. Sie liefert eine Kopie des DESeq-Datasets, in das die Ergebmnisse eingefügt wurden. Wir schreiben das in die Variable zurück:

dds %>% DESeq -> dds
estimating size factors
estimating dispersions
gene-wise dispersion estimates
mean-dispersion relationship
final dispersion estimates
fitting model and testing

Das Ergebnis können wir nun mit der results-Funktion als Tabelle erhalten:

results(dds)
log2 fold change (MLE): timepoint P63 vs P0 
Wald test p-value: timepoint P63 vs P0 
DataFrame with 36141 rows and 6 columns
                     baseMean log2FoldChange     lfcSE      stat      pvalue
                    <numeric>      <numeric> <numeric> <numeric>   <numeric>
ENSMUSG00000000001 1513.35599      -0.606287  0.142385  -4.25808 2.06190e-05
ENSMUSG00000000003    0.00000             NA        NA        NA          NA
ENSMUSG00000000028  216.41887      -2.059059  0.152941 -13.46313 2.57748e-41
ENSMUSG00000000037  101.21551      -1.800258  0.373524  -4.81965 1.43808e-06
ENSMUSG00000000049    4.79734       1.570935  1.015467   1.54701 1.21861e-01
...                       ...            ...       ...       ...         ...
ENSMUSG00000102128          0             NA        NA        NA          NA
ENSMUSG00000102129          0             NA        NA        NA          NA
ENSMUSG00000102130          0             NA        NA        NA          NA
ENSMUSG00000102131          0             NA        NA        NA          NA
ENSMUSG00000102132          0             NA        NA        NA          NA
                          padj
                     <numeric>
ENSMUSG00000000001 7.19981e-05
ENSMUSG00000000003          NA
ENSMUSG00000000028 1.07213e-39
ENSMUSG00000000037 5.85795e-06
ENSMUSG00000000049 1.96935e-01
...                        ...
ENSMUSG00000102128          NA
ENSMUSG00000102129          NA
ENSMUSG00000102130          NA
ENSMUSG00000102131          NA
ENSMUSG00000102132          NA

results leifert das Ergebnis als eine Tabelle vom Typ DataFrame (eine Variante von data.frame, die spezifisch für Bioconductor ist). Wir wandeln diese Bioconductor-Tabelle in eine Tidyverse-Tabelle um (über den Umweg eine Base-R-Tabelle), damit wir wie gewohnt mit ihr arbeiten können:

results(dds) %>% as.data.frame() %>% as_tibble( rownames="gene_id" ) -> res

head(res)
# A tibble: 6 × 7
  gene_id            baseMean log2FoldChange   lfcSE   stat    pvalue      padj
  <chr>                 <dbl>          <dbl>   <dbl>  <dbl>     <dbl>     <dbl>
1 ENSMUSG00000000001  1513.           -0.606  0.142   -4.26  2.06e- 5  7.20e- 5
2 ENSMUSG00000000003     0            NA     NA       NA    NA        NA       
3 ENSMUSG00000000028   216.           -2.06   0.153  -13.5   2.58e-41  1.07e-39
4 ENSMUSG00000000037   101.           -1.80   0.374   -4.82  1.44e- 6  5.86e- 6
5 ENSMUSG00000000049     4.80          1.57   1.02     1.55  1.22e- 1  1.97e- 1
6 ENSMUSG00000000056  3441.            0.995  0.0930  10.7   9.81e-27  1.90e-25

Die Bedeutung der Spalten:

  • gene_id ist die Ensembl-ID des Gens, entnommen den Rownames der Count-Matrix
  • baseMean ist die durschnitttliche Expression des Gens über alle Proben. Die Einheit ist diesmal nicht “counts per million” sondern “normalized counts”. Das bedeutet das für eine durschnittlich tief sequenzierte Probe eine Einheit in etwa einem Read entspricht. Genaueres siehe unten.
  • log2FoldChange: Das logarithmische Expressionsverhältnis zwischen den (geometrischen) Mittelwerten der normalisierten Counts, also der Logarithmus zur Basis 2 des Mittelwerts von P63 geteilt duirch den Mittelwert von P0.
  • lfcSE: log2 fold change standard error, d.h. der Standardfehler des log2FoldChange.
  • stat: die Test-Statistik (ein Zwischenwert bei der Berechnung von lfcSE und p-Wert)
  • pvalue: der p-Wert, mit dem die Nullhypothese “Die Mittelwerte [eigentlich: Erwartungswerte] sind gleich” abgelehnt wird.
  • padj: Der nachd er Methode von Benjamini und Hochberg (BH) adjustierte p-Wert. Siehe unten.

Wir ergänzen noch die Gen-Namen:

read_tsv( "Downloads/Ensembl_102_GRCm38.p6_Gene_names.tsv" ) %>% 
select( "gene_id" = "Gene stable ID", "gene_name" = "Gene name", "descr" = "Gene description" ) -> gene_names
Rows: 56305 Columns: 3
── Column specification ────────────────────────────────────────────────────────
Delimiter: "\t"
chr (3): Gene stable ID, Gene name, Gene description

ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
res %>% left_join( gene_names ) -> res
Joining with `by = join_by(gene_id)`

Ein Blick auf ein einzelnes Gen

Wir fragen zunächst, für welche Gene wir einen besonders kleinen p-Wert erhalten haben:

res %>% arrange( pvalue )
# A tibble: 36,141 × 9
   gene_id    baseMean log2FoldChange  lfcSE  stat    pvalue      padj gene_name
   <chr>         <dbl>          <dbl>  <dbl> <dbl>     <dbl>     <dbl> <chr>    
 1 ENSMUSG00…    4513.           6.47 0.162   39.8 0         0         Rpl3l    
 2 ENSMUSG00…    2975.           6.21 0.167   37.2 3.09e-302 3.01e-298 Art1     
 3 ENSMUSG00…   33472.           4.78 0.134   35.6 8.83e-278 5.73e-274 Ckmt2    
 4 ENSMUSG00…    6748.           4.68 0.149   31.5 8.97e-218 4.36e-214 Lrrc2    
 5 ENSMUSG00…     682.           3.67 0.117   31.5 1.39e-217 5.41e-214 Cdnf     
 6 ENSMUSG00…   28695.           2.93 0.0961  30.5 2.59e-204 8.41e-201 Oxct1    
 7 ENSMUSG00…     765.          -4.76 0.164  -29.0 1.85e-185 5.16e-182 Clca3a1  
 8 ENSMUSG00…     597.           4.19 0.148   28.4 3.77e-177 9.19e-174 Itgb6    
 9 ENSMUSG00…     817.          -4.77 0.169  -28.2 3.04e-175 6.57e-172 Bmp7     
10 ENSMUSG00…    5189.           4.67 0.167   28.0 8.72e-173 1.70e-169 Trim72   
# ℹ 36,131 more rows
# ℹ 1 more variable: descr <chr>

Den kleinsten p-Wert haben wir erhalten für das Gen Rpl3l (ENSMUSG00000002500). Wir werfen einen Blick auf die Read-Zahlen für dieses Gen:

count_matrix[ "ENSMUSG00000002500", ]
 Heart_P0_1  Heart_P0_2  Heart_P0_3  Heart_P0_4 Heart_P63_1 Heart_P63_2 
        108         102         108         136       10037        4645 
Heart_P63_3 Heart_P63_4 
       8249       10381 

Man kann die Count-Matrix auch aus dem DESeqDataset-Objekt wieder heraus holen:

counts(dds)[ "ENSMUSG00000002500", ]
 Heart_P0_1  Heart_P0_2  Heart_P0_3  Heart_P0_4 Heart_P63_1 Heart_P63_2 
        108         102         108         136       10037        4645 
Heart_P63_3 Heart_P63_4 
       8249       10381 

Das ist nützlich, weil wir so auch an die “normalisierten” Counts kommen:

counts( dds, normalized=TRUE )[ "ENSMUSG00000002500", ]
 Heart_P0_1  Heart_P0_2  Heart_P0_3  Heart_P0_4 Heart_P63_1 Heart_P63_2 
   92.11382   101.91092   104.99698   104.52141  8468.25035  6872.85168 
Heart_P63_3 Heart_P63_4 
10700.23639  9655.88657 

Wir sehen wie die Normalisierung die Zahlen tiefer sequenzierter Proben etwas verringert bzw. weniger tief sequenzierter Proben etwas erhöht hat. In unserer früheren Analyse haben wir zu diesem Zweck durch die Gesamtzahl der Reads pro Probe geteilt und mit 1 Million multipliziert (“cpm”). DESeq2 macht etwas leicht anderes, wie wir späteer sehen werden, aber zum selben Zweck.

Was ist das für ein Gen? Eine kurz Google-Suche führt z.B. zu diesem Paper.

Aufgabe: Lesen Sie den Abstract des Papers und erstellen Sie dann einen geeigneten Plot, der darstellt, wie sich das Expressionsverhältnis der Gene Rpl3l zu Rpl3 über alle Zeitpunkte ändert. Wie sieht das in den anderen Geweben aus?

converting counts to integer mode
`summarise()` has grouped output by 'tissue', 'timepoint'. You can override
using the `.groups` argument.
Warning: Transformation introduced infinite values in continuous y-axis

Überblick über alle Gene

Zurück zur Ergebnis-Tabelle: Für wie viele Gene konnten wir die Nullhypothese ablehnen?

Wir betrachten zunächst den MA-Plot:

res %>%
ggplot + 
  geom_point( aes( x=baseMean, y=log2FoldChange, col=(padj<.1) ), size=.1, alpha=.3 ) +
  scale_x_log10()
Warning: Transformation introduced infinite values in continuous x-axis
Warning: Removed 12468 rows containing missing values (`geom_point()`).

False discovery rate

Wir haben für jedes Gen einen p-Wert.

Nehmen Sie nun für einen Moment an, es gäbe in Wirklichkeit keinen Unterschied zwischen P0 und P63. Irgendjemand hat die Proben falsch beschriftet, und in Wirklichkeit sind alle 8 Proben von Mäusen desselben Alters. Die Nullhypothese, dass es keinen Unterschied zwischen den Gruppen gibt, ist also wahr. Der wahre Wert für alle log fold changes ist als 0.

Für wie viele Gene erwarten wir dann, dass das 95%-KI des LFC die Null überdeckt? – Antwort: Für 95% aller Gene.

Für wie viele Gene erwarten wir einen p-Wert unter 5%? – Antwort: Für 5% der Gene.

Für wie viele Gene erwarten wir einen p-Wert unter \(p\)? – Antwort: Für einen Bruchteil \(p\) der Gene.

Wir haben hier 23483 Gene, für die ein p-Wert berechnet werden konnte. (Die anderen sind Gene, die in allen Proben 0 Reads hatten.):

res %>% filter( !is.na(pvalue) ) %>% nrow()
[1] 23483

Für wie viele Gene würden wir einen p-Wert unter 5% erwarten, wenn die Nullhypothese für alle Gene war wäre?

23483 * 0.05
[1] 1174.15

Für wie viele Gene haben wir tatsächlich einen p-Wert unter 5%?

res %>% filter( pvalue < 0.05 ) %>% nrow()
[1] 10643

Wenn wir diese 10643 Gene als “differentiell exprimiert” betrachten, sollten wir uns also im Klaren sein, dass darunter um die 1174 falsch positive sind, denn so viele Gene hätten wir sogar zu erwarten, wenn es in Wahrheit keinen Unterschied gäbe. Unser Anteil an Falsch-Positiven unter unseren Positiven (“Hits”, “Discoveries”) ist also etwa 11%.

1174 / 10643
[1] 0.1103072

Benjamini und Hochberg haben vorgeschlagen, dies so auszudrücken: Bei einer p-Wert-Grenze von 5% liegt die “false discovery rate” (FDR) bei 11%.

Man kann das auch so berechnen:

res %>% filter( !is.na(pvalue) ) %>% summarise( fdr = n() * 0.05 / sum( pvalue < 0.05 ) )
# A tibble: 1 × 1
    fdr
  <dbl>
1 0.110

Wie sähe es bei eine p-Wert-Grenze von 1% aus?

res %>% filter( !is.na(pvalue) ) %>% summarise( fdr = n() * 0.01 / sum( pvalue < 0.01 ) )
# A tibble: 1 × 1
     fdr
   <dbl>
1 0.0265

B&H schlagen nun weiterhin vor, zu jedem einzelnen p-Wert zu berechnen, was die FDR wäre, wenn man die Grenze genau bei diesem Wert setzen würde. Diesen Wert nennt man den BH-adjustierten p-Wert. Man kann diese Werte in R mit p.adjust( pvalues, method="BH" ) berechnen.

DESeq2 hat das schon für uns gemacht und die Ergebnisse in der Spalte padj eingetragen. Allerdings hat es vorab einige Gene entfernt, um etwas bessere adjustierte p-Werte zu erhalten (sog. independent filtering). Deshalb sind einige padj-Werte NA.

Häufig betrachtet man eine FDR von 10% als akzeptabel. Wenn wir alle die Gene als “Hits” betrachten, deren padj-Wert unter 0.1 liegt, haben wir eine p-Wert-Schwelle gewählt, die zu 10% FDR führt. Das ist, was wir oben im MA-Plot verwendet haben.

DESeq kann denselben Plot auch selbst erstellen

plotMA( dds )

Macht die Nullhypothese Sinn?

Die Genregulation in einer Zell ist hochgradig vernetzt. Kein Gen ist unabhängig von einem anderen, und wenn sich auch nur ein Gen ändert, ändern sich alle, wenn auch vielleicht nur ein ganz klein wenig.

Wir können also die Frage “Welche Gene sind unterschiedlich zwischen P0 und P63?” ohne Experiment beantworten mit: “Vermutlich alle!”

Was wir eigentlich wissen wollen ist: Welche Gene werden stärker und welche schwächer? Nur wenn das Konfidenzintervall die Null nicht überlappt, können wir sagen, ob die Änderung positiv oder negativ ist. Die wahre Bedeutung der blauen Punkte im MA-Plot ist also nicht: “Bei diesen Gene ändert sich die Expression mit dem Alter und bei den anderen nicht”, sondern: “Bei diesen Genen is der Effekt groß genug, dass wir mit einer gewissen Zuversicht sagen können, in welche Richtung sich das Gen geändert hat.”

Die FDR ist also eigentlich eine “false sign rate”: 10% der blauen Punkte sind also auf der falschen Seite der Nulllinie. Oder, anders ausgedrückt: Wenn wir das Experiment wiederholen würden, dann werden etwa 90% der blauen Gene wieder auf der selben Seite (über oder unter der x-Achse) zu liegen kommen.

Es wird aber keine Aussage über die Stärke des Effekt (Abstand zur x-Achse gemacht).

Verzerrung der Effektstärke durch Poisson-Statistik

Ein Urnenmodell: Wir betrachten ein Gen mit eienr Expressionsstärke von 1000 CPM, d.h., ein Bruchteil von \(10^{-3}\) aller Reads kommt von diesem Gen. Für einen einzelnen Read beträgt die Wahrscheinlichkeit, dass er von diesem Read kommt, also \(10^-3\). Wir sequenzieren 1 Million Reads in der Probe. Wie viele werden auf dieses Gen gemappt? Wir simulieren dies 8 mal:

rbinom( 8, 1e6, 1e-3 )
[1] 1017 1017  986  979  992 1023 1000 1021

Was wäre, wenn wir über die ersten 4 und die zweiten4 Proben jeweils mitteln würden und das Verhältnis der Mittelwerte berechnen?

mean( rbinom( 4, 1e6, 1e-3 ) ) / mean( rbinom( 4, 1e6, 1e-3 ) )
[1] 1.013463

Wie liegen nahe bei 1.

WIr machen das 100 mal. man erkennt nur wenig Abweichung von 1:

hist( replicate( 100, mean( rbinom( 4, 1e6, 1e-3 ) ) / mean( rbinom( 4, 1e6, 1e-3 ) ) ), 30 )

Dasselbe auf der log2-Skala:

hist( replicate( 100, log2( mean( rbinom( 4, 1e6, 1e-3 ) ) / mean( rbinom( 4, 1e6, 1e-3 ) ) ) ), 30 ) 

Wieder liegen wir nahe beim erwarteten Wert 0.

Nun dasselbe mit einem schwach exprimierten Gen, mit nur 10 CPM, also Wahrscheinlichkeit \(10^{-5}\). Hier 8 Ziehungen, wie zuvor:

rbinom( 8, 1e6, 1e-5 )
[1]  6  9  9 14  9  6 10  9

Was wäre, wenn wir über die ersten 4 und die zweiten 4 Proben jeweils mitteln würden und das Verhältnis der Mittelwerte berechnen?

mean( rbinom( 4, 1e6, 1e-5 ) ) / mean( rbinom( 4, 1e6, 1e-5 ) )
[1] 0.8918919

Wie weichen deutlich stärker von 1 ab.

WIr machen das 100 mal. man erkennt nur deutlich mehr Abweichung von 1:

hist( replicate( 100, mean( rbinom( 4, 1e6, 1e-5 ) ) / mean( rbinom( 4, 1e6, 1e-5 ) ) ), 30 )

Dasselbe auf der log2-Skala:

hist( replicate( 100, log2( mean( rbinom( 4, 1e6, 1e-5 ) ) / mean( rbinom( 4, 1e6, 1e-5 ) ) ) ), 30 ) 

Folgerung: Bei schwach exprimierten Genen muss man auch unter der Nullhypothese deutliche Abweichungen des LFC von der Mitte (0) erwarten. Deshalb hat der MA-Plot links mehr “Dicke”, und deshalb braucht es dort auch sehr viel stärkere Effekte damit DESeq statistische Signifikanz meldet:

plotMA( dds )

Starke und schwache Effekte

Können wir dann Effektstärken (y-Werte im MA-Plot) überhaupt zwischen verschiedenen Expressionsstärken vergleichen? Nein, denn links im MA-Plot tendieren LFCs dazu, “übertrieben” zu sein.

DESeq2 hat eine Funktion, um dies zu kompensieren. Sie “schrumpft” die LFC-Werte zur 0 hin, wenn sie unsicher sind.

lfcShrink( dds, coef=2 ) -> res2
using 'apeglm' for LFC shrinkage. If used in published research, please cite:
    Zhu, A., Ibrahim, J.G., Love, M.I. (2018) Heavy-tailed prior distributions for
    sequence count data: removing the noise and preserving large differences.
    Bioinformatics. https://doi.org/10.1093/bioinformatics/bty895

Hier der neue MA-Plot

plotMA( res2 )

Welche Gene haben sich nun am stärksten geändert?

res2 %>% as.data.frame %>% as_tibble( rownames="gene_id" ) %>%
left_join( gene_names ) %>%
arrange( -log2FoldChange ) %>% head(100)
Joining with `by = join_by(gene_id)`
# A tibble: 100 × 8
   gene_id       baseMean log2FoldChange lfcSE   pvalue     padj gene_name descr
   <chr>            <dbl>          <dbl> <dbl>    <dbl>    <dbl> <chr>     <chr>
 1 ENSMUSG00000…    213.           12.7  3.46  3.29e-11 2.16e-10 Inmt      indo…
 2 ENSMUSG00000…     36.0          10.1  2.89  2.07e-14 1.79e-13 Igha      immu…
 3 ENSMUSG00000…    149.            9.17 0.997 9.46e-20 1.21e-18 Cyp2e1    cyto…
 4 ENSMUSG00000…     19.1           8.96 2.80  3.52e-11 2.30e-10 Gm15743   pred…
 5 ENSMUSG00000…     60.8           8.60 1.40  3.47e-14 2.94e-13 Retnla    resi…
 6 ENSMUSG00000…     51.1           8.38 1.35  1.48e-14 1.30e-13 Cnga3     cycl…
 7 ENSMUSG00000…     90.6           8.36 2.67  2.83e- 5 9.69e- 5 Prss22    prot…
 8 ENSMUSG00000…     21.5           8.19 3.65  1.87e- 4 5.67e- 4 Akr1c21   aldo…
 9 ENSMUSG00000…     38.4           7.89 1.45  2.67e-11 1.77e-10 Prss55    prot…
10 ENSMUSG00000…    160.            7.84 1.20  5.10e-12 3.60e-11 Acsm5     acyl…
# ℹ 90 more rows