Thousands of RNA-seq experiments on human patients are already public. Re-using them is one of the most direct ways to answer a biological question without a new animal experiment. Today you will take one such study from the public database GEO and turn it into clean objects you can analyse.
The study is GSE161731: blood samples from patients with COVID_19, influenza, a common-cold coronavirus, or a bacterial infection, plus healthy volunteers. We will
ENSG00000165949 with gene names you recognise, such as
IFI27;At the end we save everything, so Lab 7 can start straight away with PCA and differential expression.
How each part works. Every part ends with an Exercise (questions 1–3, then a Solution tab — try before you peek) followed by an Assignment. The assignment is almost the same as the exercise: you copy the solution code and change a small thing (a gene name, a colour, a group). If you can do the exercise, you can do the assignment.
You do not need to be a programmer. Every line of code has a comment. If a line does not make sense, run it alone and look at what comes out — that is the fastest way to learn.
| Part | Topic | Ends with |
|---|---|---|
| 1 | Get the data: download GSE161731 into R | Exercise 1 → Assignment 12 |
| 2 | Annotate genes: from Ensembl IDs to gene symbols | Exercise 2 → Assignment 13 |
| 3 | Describe the cohort: understanding the sample metadata | Exercise 3 → Assignment 14 |
| — | Save the objects for Lab 7 |
# Run this ONCE, before the session. It takes a while.
if (!requireNamespace("BiocManager", quietly = TRUE)) install.packages("BiocManager")
BiocManager::install(c(
"GEOquery", # download from GEO
"org.Hs.eg.db", # human gene names (works offline)
"AnnotationDbi", # the tool to look things up in org.Hs.eg.db
"edgeR" # today only used for a quick sex check
))
install.packages(c("ggplot2", "dplyr", "RColorBrewer"))
library(GEOquery) # getGEOSuppFiles()
library(org.Hs.eg.db) # human gene annotation database
library(AnnotationDbi) # mapIds() to look up gene names
library(edgeR) # cpm()
library(ggplot2) # all plots
library(dplyr) # small table summaries
theme_set(theme_bw(base_size = 12)) # one plot style for the whole document
The same experiment is stored at three levels of processing. Knowing which one you download is the whole game.
| Level | What it is | Where | Size |
|---|---|---|---|
| Raw reads | FASTQ files: the sequences from the machine | SRA / ENA | 1–10 GB per sample |
| Aligned reads | BAM files: reads placed on the genome | rarely public | large |
| Processed data | count table: how many reads each gene got in each sample | GEO supplementary files | a few MB |
Today we take the processed level: a gene × sample table of raw counts. Each number says “this many RNA fragments from this gene were found in this blood sample”. That is exactly what DESeq2 needs in Lab 7.
Re-use data responsibly: cite the accession number and the paper, respect patient consent (some human data is controlled access), and read the methods.
McClain et al. (2021), Nature Communications: whole-blood RNA-seq from patients who came to the emergency department at Duke University with an acute respiratory infection, plus healthy controls.
>89, some people sampled twice, groups of
different sizes.There are many public datasets available.
GSE… = a study,
GSM… = one sample.We are going to use Gene Expression Omnibus today. GEO keeps the authors’ processed files as “supplementary files”. For GSE161731, there are two: the count table and a count key (the patient table).
# Change this to YOUR folder. Everything is downloaded once and kept here.
setwd("/Users/birzh586/Desktop/RNASeq_course")
data_dir <- "data" # downloads and saved objects go here
dir.create(data_dir, showWarnings = FALSE) # showWarnings = FALSE: no message if it exists
# Download every supplementary file of the study into data/GSE161731/
supp <- getGEOSuppFiles("GSE161731", baseDir = data_dir, makeDirectory = TRUE)
list.files(file.path(data_dir, "GSE161731")) # which files did we get?
## [1] "GSE161731_counts_key.csv.gz" "GSE161731_counts.csv.gz" "GSE161731_key.csv.gz"
## [4] "GSE161731_xpr_nlcpm.csv.gz" "GSE161731_xpr_tpm_geo.txt.gz"
# The two file paths. file.path() glues folder and file names together.
counts_file <- file.path(data_dir, "GSE161731", "GSE161731_counts.csv.gz")
key_file <- file.path(data_dir, "GSE161731", "GSE161731_counts_key.csv.gz")
counts_file;key_file
## [1] "data/GSE161731/GSE161731_counts.csv.gz"
## [1] "data/GSE161731/GSE161731_counts_key.csv.gz"
# read.csv() reads a comma-separated file (it can read .gz without unzipping).
# row.names = 1 -> the first column (gene ID) becomes the row names
# check.names = FALSE -> keep the column names exactly as they are =>
# Why? In R, when check.names = TRUE (the default in functions such as read.csv()), column names that begin with numbers may be automatically modified to make them syntactically valid, for example, 123 may become X123.
counts_raw <- read.csv(counts_file, row.names = 1, check.names = FALSE)
key_raw <- read.csv(key_file, row.names = 1, check.names = FALSE)
# If you encounter internet connectivity or download issues, please use `read.csv()` to load the data you downloaded from Studium.
counts_raw <- read.csv("data/gse161731_counts_raw.csv", row.names = 1, check.names = FALSE)
key_raw <- read.csv("data/gse161731_key_raw.csv", row.names = 1, check.names = FALSE)
dim(counts_raw) # rows = genes, columns = samples
## [1] 60675 201
dim(key_raw) # rows = samples, columns = patient information
## [1] 198 8
counts_raw[1:5, 1:4] # top-left corner of the count table
head(key_raw) # first rows of the patient table
Three quick checks to do with every table you download:
# 1. Are these whole numbers? Raw counts must be integers (you cannot find half a read).
all(counts_raw == round(counts_raw)) # TRUE = raw counts
## [1] TRUE
# 2. How many reads does each sample have in total? This is the "library size".
head(colSums(counts_raw))
## SS94189 SSDU09.03S0000604 SSDU09.03S0000611 SS105920 SSDU09.03S0000774
## 52431553 50351794 52172232 53276313 43639732
## SSDU09.03S0000775
## 56439096
# 3. What do the gene names look like? These are Ensembl gene IDs.
head(rownames(counts_raw))
## [1] "ENSG00000223972" "ENSG00000227232" "ENSG00000278267" "ENSG00000243485" "ENSG00000274890"
## [6] "ENSG00000237613"
The columns of the count table and the rows of the patient table must be the same samples, in the same order. If they are shifted by one, every result afterwards is wrong — and R will not warn you. So we check.
# The sample names are written slightly differently in the two files.
# We make them identical: add an "S" in front, and turn "-" and "_" into ".".
# We added the prefix `"S"` to sample and row identifiers to prevent R from automatically modifying names that start with numbers, ensuring consistent identifiers throughout the analysis.
head(colnames(counts_raw))
## [1] "SS94189" "SSDU09.03S0000604" "SSDU09.03S0000611" "SS105920"
## [5] "SSDU09.03S0000774" "SSDU09.03S0000775"
head(rownames(key_raw))
## [1] "SS94189" "SSDU09.03S0000604" "SSDU09.03S0000611" "SS105920"
## [5] "SSDU09.03S0000774" "SSDU09.03S0000775"
# paste0() combines strings or character values together without adding any separator.
# paste0("S","a")
colnames(counts_raw) <- paste0("S", colnames(counts_raw))
rownames(key_raw) <- paste0("S", rownames(key_raw))
# gsub() searches for all occurrences of a specified pattern in a string and replaces them with a given value.
# gsub("S","s", "Star")
colnames(counts_raw) <- gsub("[-_]", ".", colnames(counts_raw))
rownames(key_raw) <- gsub("[-_]", ".", rownames(key_raw))
# intersect(): the sample names found in BOTH tables.
common <- intersect(colnames(counts_raw), rownames(key_raw))
length(common)
## [1] 198
# Keep only those samples, sorted by cohort, and put the counts in the SAME order.
meta <- key_raw[common, ]
# order(): arranges the data in ascending order based on the specified variable.
meta <- meta[order(meta$cohort), ]
counts <- counts_raw[, rownames(meta)]
# checks whether the column names of counts exactly match the row names of meta in the same order, returning TRUE if all match and FALSE otherwise.
all(colnames(counts) == rownames(meta))
## [1] TRUE
# The most important check of the day. Why is this important? It ensures that each sample's gene expression counts correspond to the correct metadata, preventing samples from being incorrectly assigned to cohorts or other experimental groups.
str(meta) # which patient variables do we have, and of which type?
## 'data.frame': 198 obs. of 8 variables:
## $ subject_id : chr "A1BD46" "ADC430" "AB87B2" "896282" ...
## $ age : chr "57" "69" "72" "68" ...
## $ gender : chr "Female" "Male" "Female" "Female" ...
## $ race : chr "Black/African American" "Unknown/Not reported" "Black/African American" "Black/African American" ...
## $ cohort : chr "Bacterial" "Bacterial" "Bacterial" "Bacterial" ...
## $ time_since_onset: chr NA NA NA NA ...
## $ hospitalized : chr NA NA NA NA ...
## $ batch : int 1 1 1 2 1 1 1 1 1 1 ...
table(meta$cohort) # how many samples per group
##
## Bacterial CoV other COVID-19 healthy Influenza
## 24 61 77 19 17
counts?# 1 dim() gives: number of rows (genes), number of columns (samples)
dim(counts)
## [1] 60675 198
# 2
#colSums() calculates the sum of each column in a matrix or data frame
lib <- colSums(counts) # total reads per sample
names(which.max(lib)) # which.max() finds the position; names() gives the sample ID/name
## [1] "SSS434325"
# 3 setdiff(a, b): things in a that are not in b
setdiff(colnames(counts_raw), rownames(key_raw))
## [1] "SSSDU09.02S0000150.batch2" "SSSDU09.02S0000154.batch2" "SSSDU09.02S0000158.batch2"
length(setdiff(colnames(counts_raw), rownames(key_raw))) # in counts_raw, not in the key_raw
## [1] 3
Hint: start from the code of Exercise 1 and change one small thing.
meta.min)patient table but not in
the count file?ENSG00000165949 is precise but unreadable.
IFI27 is readable, but gene symbols sometimes change, and a few
symbols belong to more than one Ensembl ID. Think of Ensembl IDs as
personal ID numbers and symbols as first
names: the number is unique, the name is what people
recognise.
We translate once, early, so that later we can simply write
counts["IFI27", ].
org.Hs.eg.db is a human gene “dictionary” stored on your
computer. mapIds() looks up each Ensembl ID and gives back
one answer per gene.
gene_ids <- rownames(counts) # the Ensembl IDs we have
head(gene_ids)
## [1] "ENSG00000223972" "ENSG00000227232" "ENSG00000278267" "ENSG00000243485" "ENSG00000274890"
## [6] "ENSG00000237613"
# # columns(org.Hs.eg.db) lists all the types of annotation information available in the org.Hs.eg.db database, such as gene symbols, Entrez Gene IDs, Ensembl IDs, and gene names.
columns(org.Hs.eg.db)
## [1] "ACCNUM" "ALIAS" "ENSEMBL" "ENSEMBLPROT" "ENSEMBLTRANS" "ENTREZID"
## [7] "ENZYME" "EVIDENCE" "EVIDENCEALL" "GENENAME" "GENETYPE" "GO"
## [13] "GOALL" "IPI" "MAP" "OMIM" "ONTOLOGY" "ONTOLOGYALL"
## [19] "PATH" "PFAM" "PMID" "PROSITE" "REFSEQ" "SYMBOL"
## [25] "UCSCKG" "UNIPROT"
# Retrieves the gene name for each Ensembl gene ID
gene_symbol <- mapIds(
org.Hs.eg.db,
keys = gene_ids, # what we have
keytype = "ENSEMBL", # what type it is
column = "SYMBOL", # what we want back
multiVals = "first" # if there are several answers, take the first
)
# Retrieves the gene type associated with each Ensembl gene ID, such as protein-coding or non-coding RNA
gene_biotype <- mapIds(org.Hs.eg.db,
keys = gene_ids,
keytype = "ENSEMBL",
column = "GENETYPE",
multiVals = "first")
# How well did it work?
sum(!is.na(gene_symbol)) # IDs that got a symbol
## [1] 35529
sum(is.na(gene_symbol)) # IDs with no symbol (NA)
## [1] 25146
sum(duplicated(gene_symbol[!is.na(gene_symbol)])) # symbols used more than once
## [1] 82
Notes Some symbols appear on more than one row. For each symbol we keep the row with the most reads on average — the version of the gene that is really expressed in blood — and drop the others. Genes with no symbol are dropped too.
Some symbols appear on more than one row. For each symbol we keep the row with the most reads on average — the version of the gene that is really expressed in blood — and drop the others. Genes with no symbol are dropped too.
# 1. Sort all rows from highest to lowest average count.
o <- order(rowMeans(counts), decreasing = TRUE)
counts <- counts[o, ]
gene_ids <- gene_ids[o]
gene_symbol <- gene_symbol[o]
gene_biotype <- gene_biotype[o]
# 2. Keep a row if it HAS a symbol AND that symbol was not already seen higher up.
keep_row <- !is.na(gene_symbol) & !duplicated(gene_symbol)
sum(keep_row) # number of genes we keep
## [1] 35447
counts <- counts[keep_row, ]
gene_ids <- gene_ids[keep_row]
gene_biotype <- gene_biotype[keep_row]
rownames(counts) <- gene_symbol[keep_row] # from now on, rows are gene symbols
# 3. A small annotation table: one row per gene, same order as counts.
ann <- data.frame(symbol = rownames(counts),
ensembl = gene_ids,
biotype = gene_biotype,
row.names = rownames(counts))
dim(ann)
## [1] 35447 3
head(ann)
# If you find `One row per gene symbol` diffcult to understand, please use `read.csv()` to load the annotation file you downloaded from Studium.
ann <- read.csv("data/gse161731_gene_annotation.csv")
# Which kinds of genes are in the table?
table(ann$biotype)
##
## ncRNA other protein-coding pseudo rRNA scRNA
## 6155 368 19208 8893 24 4
## snoRNA snRNA
## 726 69
sort(table(ann$biotype), decreasing = TRUE)
##
## protein-coding pseudo ncRNA snoRNA other snRNA
## 19208 8893 6155 726 368 69
## rRNA scRNA
## 24 4
sort(table(ann$biotype), decreasing = F)
##
## scRNA rRNA snRNA other snoRNA ncRNA
## 4 24 69 368 726 6155
## pseudo protein-coding
## 8893 19208
# Are the genes we care about present?
c("IFI27", "ISG15", "S100A8", "XIST", "RPS4Y1") %in% ann$symbol
## [1] TRUE TRUE TRUE TRUE TRUE
Biology note. Most blood studies focus on protein-coding genes: they are the best annotated and the easiest to interpret. Non-coding RNAs are real, but harder to link to a function.
"IFI"
(interferon-induced genes).# 1 == compares every value; sum() counts the TRUEs;
sum(ann$biotype == "protein-coding", na.rm = TRUE)
## [1] 19208
# 2 grep(): find names matching a pattern; "^" means "starts with"
grep("^IFI", ann$symbol, value = TRUE)
## [1] "IFITM2" "IFITM3" "IFIT3" "IFI6" "IFIT2" "IFI16" "IFITM1" "IFIT1" "IFI44L"
## [10] "IFI27" "IFI35" "IFI44" "IFIH1" "IFIT5" "IFIT1B" "IFI27L2" "IFI30" "IFI27L1"
## [19] "IFITM10" "IFITM9P" "IFITM4P" "IFITM5"
# 3
g <- c("IFI6", "IFI27", "IFI44L", "ISG15")
g %in% ann$symbol # %in% checks whether each element in one vector is present in another vector, returning TRUE for matches and FALSE otherwise.
## [1] TRUE TRUE TRUE TRUE
Hint: start from the code of Exercise 2 and change the names.
"ncRNA" (non-coding
RNA)?"S100" (a family of
neutrophil proteins).Before any analysis, get to know your patients. This is where you catch mislabelled samples, repeated patients, and groups that differ in age or sex. Finding these now is much cheaper than after the analysis.
# Look at the raw values of each variable before changing anything.
# useNA = "ifany": displays the number of missing (NA) values if they exist
table(meta$cohort,useNA = "ifany")
##
## Bacterial CoV other COVID-19 healthy Influenza
## 24 61 77 19 17
table(meta$gender,useNA = "ifany")
##
## Female Male <NA>
## 84 93 21
table(meta$hospitalized, useNA = "ifany")
##
## No Yes <NA>
## 65 12 121
# sort() arranges the elements of a vector in ascending order by default.
# unique() returns the distinct elements of a vector, removing duplicates.
head(sort(unique(meta$age))) # note the text value ">89"
## [1] ">89" "0" "14" "15" "16" "18"
# 1. Delete cohort=NA and group names contain spaces and dashes ("COVID_19"). Replace them with "_".
unique(meta$cohort)
## [1] "Bacterial" "CoV other" "COVID-19" "healthy" "Influenza"
# [^A-Za-z0-9]+ is a regular expression (regex) that matches one or more consecutive characters that are not letters or numbers.
# gsub("[^A-Za-z0-9]+", "_", "ENSG-00001.2 (gene)")
meta$cohort <- gsub("[^A-Za-z0-9]+", "_", meta$cohort)
table(meta$cohort)
##
## Bacterial CoV_other COVID_19 healthy Influenza
## 24 61 77 19 17
# 2. Age is stored as text because of ">89" (very old patients are not given an
# exact age, to protect their privacy). Replace it with 90 and make it a number.
meta$age <- gsub(">89", "90", meta$age)
meta$age <- as.numeric(meta$age)
summary(meta$age)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.00 18.00 27.50 33.93 50.00 90.00
# 3. Delete gender=NA
meta <- meta[meta$gender %in% c("Female", "Male"), ]
table(meta$gender)
##
## Female Male
## 84 93
# 4. Make grouping variables "factors" (R's word for a category).
# factor() converts a variable into a categorical variable and optionally sets the category order.
# We choose the order of the groups ourselves: healthy first.
meta$cohort <- factor(meta$cohort,
levels = c("healthy", "Bacterial", "CoV_other", "COVID_19", "Influenza"))
meta$gender <- factor(meta$gender)
meta$race <- factor(meta$race)
str(meta)
## 'data.frame': 177 obs. of 8 variables:
## $ subject_id : chr "A1BD46" "ADC430" "AB87B2" "896282" ...
## $ age : num 57 69 72 68 75 38 76 55 38 87 ...
## $ gender : Factor w/ 2 levels "Female","Male": 1 2 1 1 1 1 2 2 2 1 ...
## $ race : Factor w/ 7 levels "American Indian/Alaska Native",..: 3 6 3 3 3 3 3 3 3 3 ...
## $ cohort : Factor w/ 5 levels "healthy","Bacterial",..: 2 2 2 2 2 2 2 2 2 2 ...
## $ time_since_onset: chr NA NA NA NA ...
## $ hospitalized : chr NA NA NA NA ...
## $ batch : int 1 1 1 2 1 1 1 1 1 1 ...
counts <- counts[, rownames(meta)] # remove those samples from counts
all(colnames(counts) == rownames(meta)) # check again — always
## [1] TRUE
Which plot and which test? It depends on the type of variable.
| Variable | Example | Plot | Test (2 groups) |
|---|---|---|---|
| Category | sex, hospitalised | bar chart | chi-square test, or Fisher’s test for small groups |
| Number | age | box plot with the points | Wilcoxon test (or t-test if the data look bell-shaped) |
ggplot(meta, aes(x = cohort, fill = gender)) +
geom_bar(position = position_dodge(preserve = "single"), na.rm = TRUE) +
# position_dodge(preserve = "single"): places bars side by side and preserves the width of each individual bar.
geom_text(stat = "count", aes(label = after_stat(count)), # write n on top
position = position_dodge(width = 0.9), vjust = -0.5, size = 3.2) +
scale_fill_brewer(palette = "Set2") + # the colours
labs(x = NULL, y = "number of samples", title = "Samples per cohort and sex")
# The same information as a pie chart.
tab <- as.data.frame(table(cohort = meta$cohort)) # counts per cohort
tab$prop <- tab$Freq / sum(tab$Freq) # as a proportion
ggplot(tab, aes(x = "", y = prop, fill = cohort)) +
geom_col(width = 1, colour = "white") +
coord_polar(theta = "y") + # this bends the bar into a circle
geom_text(aes(label = scales::percent(prop, accuracy = 1)),
position = position_stack(vjust = 0.5), size = 3.5) +
scale_fill_brewer(palette = "Set2") +
labs(title = "Cohort composition") +
theme_void()
Note. People compare angles badly, so a bar chart is almost always clearer than a pie chart.
ggplot(meta, aes(x = cohort, y = age, fill = cohort)) +
geom_boxplot(width = 0.5, outlier.shape = NA, alpha = 0.7) + # the box
geom_jitter(width = 0.15, size = 1.4, alpha = 0.6) + # one dot per patient
scale_fill_brewer(palette = "Set2") +
labs(x = NULL, y = "age (years)", title = "Age by cohort") +
theme(legend.position = "none")
Always show the points. A box made of 4 patients looks the same as a box made of 400. The dots show how many patients there really are.
In Lab 7 we compare COVID_19 with healthy. If the two groups differ in age or sex, some gene differences may be caused by age or sex rather than by the virus.
# Keep only the two groups we will compare in Lab 7.
sub <- meta[meta$cohort %in% c("COVID_19", "healthy"), ]
table(sub$cohort)
##
## healthy Bacterial CoV_other COVID_19 Influenza
## 19 0 0 77 0
sub$cohort <- droplevels(sub$cohort) # removes unused factor levels after filtering data.
table(sub$cohort)
##
## healthy COVID_19
## 19 77
# Age (number) vs group -> Wilcoxon test
tapply(sub$age, sub$cohort, median) # median age in each group
## healthy COVID_19
## 18 33
wilcox.test(age ~ cohort, data = sub)
##
## Wilcoxon rank sum test with continuity correction
##
## data: age by cohort
## W = 1.5, p-value = 1.762e-11
## alternative hypothesis: true location shift is not equal to 0
# Sex (category) vs group -> Fisher when groups are small (Chi-squared test; suitable for sufficiently large expected counts)
tab2 <- table(sub$gender, sub$cohort)
tab2
##
## healthy COVID_19
## Female 8 32
## Male 11 45
fisher.test(tab2) # Fisher’s exact test; suitable for small expected counts.
##
## Fisher's Exact Test for Count Data
##
## data: tab2
## p-value = 1
## alternative hypothesis: true odds ratio is not equal to 1
## 95 percent confidence interval:
## 0.3181799 3.1610478
## sample estimates:
## odds ratio
## 1.022496
Discuss. Healthy volunteers are often younger than hospital patients. If age differs between the groups, age and disease are mixed up (confounded). Only a table like this tells you so.
"Pastel1".# 1 how many samples are left in each cohort
table(meta$cohort)
##
## healthy Bacterial CoV_other COVID_19 Influenza
## 19 24 40 77 17
# 2 Only the palette name changed.
ggplot(meta, aes(x = cohort, y = age, fill = cohort)) +
geom_boxplot(width = 0.5, outlier.shape = NA, alpha = 0.7) +
geom_jitter(width = 0.15, size = 1.4, alpha = 0.6) +
scale_fill_brewer(palette = "Pastel1") +
labs(x = NULL, y = "age (years)", title = "Age by cohort") +
theme(legend.position = "none")
# 3 Age is a number and there are two groups -> Wilcoxon test.
sub_flu <- meta[meta$cohort %in% c("Influenza", "healthy"), ]
wilcox.test(age ~ cohort, data = sub_flu)
##
## Wilcoxon rank sum test with continuity correction
##
## data: age by cohort
## W = 180, p-value = 0.541
## alternative hypothesis: true location shift is not equal to 0
Hint: start from the code of Exercise 3 and change one small thing.
table(meta$))"Dark2".stats
chunk)?Lab 7 starts from exactly these three objects.
write.csv(counts, file.path(data_dir, "gse161731_counts.csv")) # raw counts, gene symbols
write.csv(meta, file.path(data_dir, "gse161731_meta.csv")) # clean patient table
write.csv(ann, file.path(data_dir, "gse161731_gene_annotation.csv")) # symbol / ensembl / biotype
The RNA itself tells us the sex of each sample:
If the label in the table disagrees with the RNA, the sample may have been swapped or mislabelled.
# A quick normalisation so samples with more reads do not look "higher".
# (log2 CPM is explained properly in Lab 7.)
logcpm_quick <- cpm(counts, log = TRUE, prior.count = 1)
y_genes <- c("RPS4Y1", "DDX3Y", "UTY", "KDM5D")
y_genes <- y_genes[y_genes %in% rownames(logcpm_quick)] # keep the ones present
# One row per sample: XIST level, average Y-gene level, and the recorded sex.
sex_df <- data.frame(
sample = colnames(logcpm_quick),
XIST = logcpm_quick["XIST", ],
y_score = colMeans(logcpm_quick[y_genes, ]),
label = meta$gender
)
ggplot(sex_df, aes(x = XIST, y = y_score, colour = label)) +
geom_point(size = 2, alpha = 0.8) +
labs(x = "XIST (log2 CPM)", y = "mean Y-gene expression (log2 CPM)",
colour = "recorded sex", title = "Recorded sex vs sex seen in the RNA")
Discuss. You should see two clear clouds: females (high XIST, no Y) and males (Y genes, low XIST). A dot of the “wrong” colour in a cloud is a suspicious sample.
# A simple rule: high Y-gene expression = male. The cut-off is halfway
# between the lowest and highest Y score.
cutoff <- mean(range(sex_df$y_score))
sex_df$inferred <- ifelse(sex_df$y_score > cutoff, "Male", "Female")
table(recorded = sex_df$label, inferred = sex_df$inferred)
## inferred
## recorded Female Male
## Female 80 4
## Male 1 92
# Samples where the recorded sex disagrees with the RNA
discordant <- sex_df$sample[!is.na(sex_df$label) &
as.character(sex_df$label) != sex_df$inferred]
# Samples with no recorded sex
label_missing <- sex_df$sample[is.na(sex_df$label)]
exclude_ids <- c(discordant, label_missing)
exclude_ids
## [1] "SSS434766" "SSS434777" "SSSDU18.02S0011674" "SSSDU18.02S0011675"
## [5] "SSSDU09.02S0000101"
# Remove them from meta AND counts, and check the order again.
meta_clean <- meta[!rownames(meta) %in% exclude_ids, ]
meta_clean$gender <- droplevels(meta_clean$gender) # forget empty categories
counts_clean <- counts[, rownames(meta_clean)]
all(colnames(counts_clean) == rownames(meta_clean))
## [1] TRUE
# A small "Table 1", like in a clinical paper.
Table <- meta %>%
group_by(cohort) %>%
summarise(
n = n(),
median_age = median(age, na.rm = TRUE),
pct_female = round(100 * mean(gender == "Female"), 1)
)
Table