In the previous heatmap activity, we used the small built-in
mtcars dataset. That was useful because the dataset was
small enough to see clearly.
Now we will use real gene expression data from TCGA breast cancer samples. Gene expression data which tells us how many messenger RNAs (mRNAs) per gene are present in a patient sample. The amount of a gene’s mRNA corresponds (roughly) to the amount of protein in the sample.
This is more realistic, but also more challenging:
That is normal in real computational biology.
Our goal is to use heatmaps to ask:
Do breast tumors with similar gene expression patterns also share clinical features, such as estrogen receptor status?
By the end of this activity, you should be able to:
This activity expects the following files:
brca_expr_matbrca_clin.csv# This chunk sets up file path for the activity.
data_dir <- "/shared/dreamhigh/data"
The expression file is an RDS file.
An RDS file (which ends in .rds) is a special file format used by R to save exactly one specific piece of data (like a single data table, a list, or a machine learning model) from your computer’s memory onto your hard drive.
Reading and writing RDS files is significantly faster than processing text-based files.
Rows are genes.
Columns are patient tumor samples.
brca_expr_mat <- readRDS(file.path(data_dir,"brca_expr_mat.rds"))
Inspect the matrix.
dim(brca_expr_mat)
## [1] 18351 1082
brca_expr_mat[1:5, 1:5]
## TCGA-3C-AAAU TCGA-3C-AALI TCGA-3C-AALJ TCGA-3C-AALK TCGA-4H-AAAK
## TSPAN6 7.5636463 7.705439 9.975045 10.110718 9.881960
## TNMD 0.4272843 1.061776 5.353718 1.164271 2.506120
## DPM1 9.0367945 9.662019 9.919403 8.859174 8.928912
## SCYL3 8.3493520 10.607275 8.395877 9.103957 8.704889
## C1orf112 6.9691735 8.106778 7.922947 7.776269 7.495535
Question: What do the rows represent? What do the columns represent?
Your answer: The rows represent specific genes, and the columns represent patient tumor samples. The table depicts each gene’s expression level in each tumor sample.
Reflection: Why do you think genes are stored as rows and patients as columns? Could the data have been organized the other way around?
Your answer: Genes appear as rows because each one acts as a shared feature that can be compared across all patients. The layout could be flipped, but keeping genes as rows mirrors how most biological datasets are organized and keeps analyses consistent.
The values in this expression matrix are mostly between 0 and about 21.
summary(as.vector(brca_expr_mat))
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.000 3.669 8.056 6.852 9.875 20.978
Reflection: The largest expression values are only around 20 instead of thousands or millions. Why does this suggest the data have already been log-transformed?
Your answer: Raw RNA‑seq counts often reach into the thousands, so seeing values capped near 20 signals they have already been compressed by a log transformation. Log scaling shrinks large counts into a tighter range, which matches the distribution shown in the summary.
The distribution of values is a strong clue that these values are already on a transformed scale, likely a log-like expression scale.
We log-transform gene expression data to make highly skewed numbers more symmetrical. This fixes a common problem where a few highly active genes distort your and data dominate statistical analyses purely due to their massive raw numerical values, rather than their actual biological relevance.
The clinical data contain patient and tumor information.
brca_clin_df <- read.csv(
file.path(data_dir, "brca_clin.csv"),
stringsAsFactors = FALSE
)
dim(brca_clin_df)
## [1] 1082 27
head(brca_clin_df[, 1:6])
## bcr_patient_barcode gender race ethnicity
## 1 TCGA-3C-AAAU FEMALE WHITE NOT HISPANIC OR LATINO
## 2 TCGA-3C-AALI FEMALE BLACK OR AFRICAN AMERICAN NOT HISPANIC OR LATINO
## 3 TCGA-3C-AALJ FEMALE BLACK OR AFRICAN AMERICAN NOT HISPANIC OR LATINO
## 4 TCGA-3C-AALK FEMALE BLACK OR AFRICAN AMERICAN NOT HISPANIC OR LATINO
## 5 TCGA-4H-AAAK FEMALE WHITE NOT HISPANIC OR LATINO
## 6 TCGA-5L-AAT0 FEMALE WHITE HISPANIC OR LATINO
## age_at_diagnosis year_of_initial_pathologic_diagnosis
## 1 55 2004
## 2 50 2003
## 3 62 2011
## 4 52 2011
## 5 50 2013
## 6 42 2010
As we saw previously, the clinical data includes receptor status.
table(brca_clin_df$estrogen_receptor_status)
##
## [Not Evaluated] Indeterminate Negative Positive
## 48 2 236 796
table(brca_clin_df$progesterone_receptor_status)
##
## [Not Evaluated] Indeterminate Negative Positive
## 49 4 340 689
table(brca_clin_df$her2_receptor_status)
##
## [Not Available] [Not Evaluated] Equivocal Indeterminate Negative
## 8 170 177 12 554
## Positive
## 161
This is a very important step.
The expression matrix columns are sample IDs.
The clinical data rows are patient/sample IDs.
We should match them by name, not just assume they are in the same order.
sample_ids <- colnames(brca_expr_mat)
match_index <- match(sample_ids, brca_clin_df$bcr_patient_barcode)
sum(is.na(match_index))
## [1] 0
If the result is 0, which should be the case here, every expression sample matched a clinical row.
If the result wasn’t zero, we can use match_index to
sort the rows of the clinical data to match the columns of the
expression data.
clin_matched <- brca_clin_df[match_index, ]
all(clin_matched$bcr_patient_barcode == sample_ids)
## [1] TRUE
Now clin_matched is aligned to the columns of
brca_expr_mat.
For each gene, we can calculate its average expression across all tumors.
mean_expr <- apply(brca_expr_mat, 1, mean)
summary(mean_expr)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.000 3.759 8.036 6.852 9.771 16.470
What does this look like in boxplot form?
mean_expr <- apply(brca_expr_mat, 1, mean)
# Draw boxplot but don't plot outliers
bp <- boxplot(mean_expr,
boxwex = 0.35,
horizontal = TRUE,
col = "lightblue",
outline = FALSE,
main = "Distribution of Mean Gene Expression",
xlab = "Mean Expression")
# Add the mean as a red diamond
mean.val <- mean(mean_expr)
points(mean.val, 1, pch = 23, bg = "red", cex = 1.5)
# Label the mean
text(mean.val, 1.15,
labels = paste0("Mean = ", sprintf("%.2f", mean.val)),
col = "red")
# Label the five-number summary
stats <- bp$stats
text(stats[1], 0.82, sprintf("%.1f", stats[1])) # Min
text(stats[2] - 0.10, 0.82, sprintf("%.1f", stats[2])) # Q1
text(stats[3], 0.82, sprintf("%.1f", stats[3])) # Median
text(stats[4] + 0.10, 0.82, sprintf("%.1f", stats[4])) # Q3
text(stats[5], 0.82, sprintf("%.1f", stats[5])) # Max
# Check out the actual values again:
summary(mean_expr)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.000 3.759 8.036 6.852 9.771 16.470
Reflection: Is the mean (red diamond) larger or smaller than the median (black line)? What does this tell you about the distribution of gene expression?
(Clue: Skewness measures the asymmetry of data. In a symmetrical distribution, the mean and median are identical. In an asymmetrical (skewed) distribution, extreme values or a long tail “pull” the mean toward the direction of the tail, while the median remains closer to the center of the data.)
Your answer: The mean sits below the median, showing a left‑skewed pattern where low‑expression genes pull the average downward. This connects to typical RNA‑seq data, which often contains many moderately expressed genes and a long tail of genes expressed near zero.
Reflection: About half of the genes have an average expression below the median. Does that mean half of the genes are “unimportant”? Why or why not?
Your answer: Lower average expression does not make a gene any less meaningful, since functional relevance depends on what the gene actually does rather than where it falls in the distribution. Many genes with modest expression still shape regulation, cell‑type identity, and disease pathways, so expression magnitude alone is not a reliable marker of importance.
hist(
mean_expr,
breaks = 50,
main = "Mean gene expression across breast tumors",
xlab = "Mean expression"
)
Reflection: Do all genes appear to be expressed at similar levels, or do some genes appear much more active than others? Why might cells regulate genes differently?
Your answer: The histogram shows that genes are not expressed at similar levels; some cluster near very low expression while others reach much higher activity. Cells regulate genes differently because each gene serves a distinct role, and expression is tuned to match needs like maintaining basic functions, responding to signals, or supporting specialized cell behaviors.
A gene can have a high average expression but not vary much between patients.
For heatmaps, genes that vary across patients are often more informative.
var_genes <- apply(brca_expr_mat, 1, var)
summary(var_genes)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.0000 0.3211 0.6607 1.2801 1.5830 26.8820
Let’s look at the distribution of variance values.
hist(
var_genes,
breaks = 50,
main = "Variance of gene expression across breast tumors",
xlab = "Variance"
)
Most genes have relatively low variance. *
Reflection: Why might genes that change a lot from patient to patient be more useful for studying cancer than genes whose expression hardly changes?
Your answer: Genes with high patient‑to‑patient variation are often more informative because that variability can reflect meaningful biological differences such as tumor subtype, pathway activation, or disease progression. In contrast, genes that barely change tend to capture basic housekeeping functions, so they offer less insight into what distinguishes one tumor from another.
We will begin with a small number of highly variable genes.
This is easier to interpret than trying to plot all genes at once.
Prediction: What do you think would happen if we plotted all 20,000 genes instead of only the 100 most variable genes?
Your answer: Plotting all 20,000 genes would overwhelm the visualization, producing a dense cloud of points where meaningful patterns disappear. Focusing on the most variable genes instead highlights the signals that actually differentiate tumors, making the structure in the data far easier to interpret.
order_var <- order(var_genes, decreasing = TRUE)
num_genes <- 100
expr_top <- brca_expr_mat[order_var[1:num_genes], ]
dim(expr_top)
## [1] 100 1082
There are many patient samples. For an introductory heatmap, we will plot every fourth sample.
our_samples <- seq(1, ncol(expr_top), by = 4)
expr_sub <- expr_top[, our_samples]
clin_sub <- clin_matched[our_samples, ]
dim(expr_sub)
## [1] 100 271
This is the key idea.
For a gene expression heatmap, we usually want to ask:
Is each gene higher or lower than its own average across tumors?
That means we should scale each row of the matrix, because rows are genes.
Base R’s scale() function scales columns by default.
Since our columns are patients, this would scale patients, not
genes.
So we use t(scale(t(matrix))).
This transposes the matrix, scales the genes, and transposes it back.
expr_sub_scaled <- t(scale(t(expr_sub)))
# Replace any NA values that could occur for genes with zero variance
expr_sub_scaled[is.na(expr_sub_scaled)] <- 0
summary(as.vector(expr_sub_scaled))
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## -3.31947 -0.76921 -0.07953 0.00000 0.71035 3.85797
Now each gene is shown relative to its own average across samples.
**Reflection: Before scaling, some genes naturally have much higher expression than others. After scaling, what does the color represent?
Your answer: After scaling, the color reflects how a gene behaves relative to its own baseline, not how large its raw expression is compared with other genes. The transformation centers each gene around its average, so the color shows whether a gene is higher than usual (red), lower than usual (blue), or close to its typical level (white) in a given patient. This makes the heatmap about patterns of deviation, allowing the fair comparison of genes even though their original expression magnitudes differ widely.
We will use a blue-white-red palette.
heat_colors <- colorRampPalette(c("blue", "white", "red"))(100)
heatmap(
expr_sub_scaled,
labRow = "",
labCol = "",
margins = c(3, 3),
xlab = "Tumor samples",
ylab = "Variable genes",
col = heat_colors,
zlim = c(-2, 2),
main = "Most variable genes in TCGA breast cancer samples"
)
Reflection: Do you think selecting only the most variable genes helped make patterns easier to see? Explain your reasoning.
Your answer: Selecting only the most variable genes made the patterns much easier to see because those genes highlight the strongest differences between tumors, so the heatmap becomes more structured instead of being dominated by rows that barely change. For example, in this heatmap, we can actually see coordinated blocks of red and blue where a group of tumors shows higher expression for one set of genes and lower expression for another, which creates a clear cluster on the left side of the heatmap. Patterns like that would be almost impossible to detect if all 20,000 genes were included.
Reflection: If you saw two tumors with nearly identical expression patterns, what might you predict about those tumors? What additional information would you need before concluding they are biologically similar?
Your answer: If two tumors have nearly identical expression patterns, they are likely using the same biological pathways and may fall into the same general subtype. Tumors that look this similar often share features like growth behavior or pathway activity. To ensure they are truly alike, additional information such as clinical fearures, mutation status, or other molecular data would still be needed.
Now we will add clinical labels.
We use:
+ for ER-positive. for ER-negativeer_status <- clin_sub$estrogen_receptor_status
er_label <- rep("", length(er_status))
er_label[er_status == "Positive"] <- "+"
er_label[er_status == "Negative"] <- "."
table(er_label)
## er_label
## . +
## 13 57 201
heatmap(
expr_sub_scaled,
labRow = "",
labCol = er_label,
cexCol = 0.5,
margins = c(3, 3),
xlab = "Tumor samples labeled by ER status",
ylab = "Variable genes",
col = heat_colors,
zlim = c(-2, 2),
main = "Gene expression heatmap with ER status labels"
)
Interpretation question: Do ER-negative tumors appear concentrated in any part of the heatmap?
Your answer: Yes, the ER‑negative tumors do appear concentrated in a specific part of the heatmap. Most of the ER‑negative samples cluster together toward one side, where they share a similar red‑and‑blue expression pattern that differs from the ER‑positive group. This kind of grouping suggests that ER‑negative tumors tend to have a distinct expression signature compared with ER‑positive tumors.
Reflection: Suppose the ER labels matched the heatmap perfectly. Would that prove that estrogen receptor status causes the expression patterns? Why or why not?
Your answer: Even if the ER labels matched the heatmap perfectly, that would not prove that estrogen receptor status causes the expression patterns. Matching labels would only show an association, meaning ER‑positive and ER‑negative tumors tend to differ in their expression profiles. To establish causation, additional evidence such as experimental data or longitudinal clinical information would be needed to show that ER status directly drives those expression changes.
Careful science note:
It is okay if the separation is not perfect. Real tumor data are
complex. We are looking for patterns, not expecting every sample to
behave perfectly.
Sometimes a small set of biologically meaningful genes is easier to interpret than the top 100 variable genes.
Here are several genes related to breast cancer subtype or tumor biology:
ESR1: estrogen receptorPGR: progesterone receptorERBB2: HER2FOXA1: luminal breast cancer biologyKRT5, KRT14: basal-like featuresMKI67: proliferationEPCAM: epithelial markermarker_genes <- c("ESR1", "PGR", "ERBB2", "FOXA1", "KRT5", "KRT14", "MKI67", "EPCAM")
marker_genes <- marker_genes[marker_genes %in% rownames(brca_expr_mat)]
marker_mat <- brca_expr_mat[marker_genes, our_samples]
marker_scaled <- t(scale(t(marker_mat)))
marker_scaled[is.na(marker_scaled)] <- 0
marker_genes
## [1] "ESR1" "PGR" "ERBB2" "FOXA1" "KRT5" "KRT14" "MKI67" "EPCAM"
heatmap(
marker_scaled,
labRow = rownames(marker_scaled),
labCol = er_label,
cexRow = 0.9,
cexCol = 0.5,
margins = c(4, 8),
xlab = "Tumor samples labeled by ER status",
ylab = "Marker genes",
col = heat_colors,
zlim = c(-2, 2),
main = "Breast cancer marker genes"
)
Question: How does ESR1 expression
relate to ER status?
Your answer: ESR1 expression aligns closely with ER status because ESR1 encodes the estrogen receptor itself. ER‑positive tumors show high ESR1 expression, while ER‑negative tumors show very low expression, creating a clear red‑versus‑blue contrast across the samples. This relationship makes ESR1 one of the strongest markers for distinguishing ER‑positive from ER‑negative tumors.
Reflection: Why is this heatmap easier to interpret than the heatmap containing 100 genes?
Your answer: This heatmap is easier to interpret because it focuses on a small set of well‑known marker genes rather than a large panel of 100 genes. With fewer rows, each gene’s pattern stands out clearly, and the differences between ER‑positive and ER‑negative tumors become immediately visible. The simplified layout makes it easy to connect specific genes, like ESR1 or ERBB2, to the sample groups without having to sift through dense or noisy patterns.
This marker-gene heatmap may be easier to explain than the larger unsupervised heatmap.
Create labels for progesterone receptor status.
pr_status <- clin_sub$progesterone_receptor_status
pr_label <- rep("", length(pr_status))
pr_label[pr_status == "Positive"] <- "+"
pr_label[pr_status == "Negative"] <- "."
table(pr_label)
## pr_label
## . +
## 13 81 177
Now plot the heatmap with PR labels.
heatmap(
expr_sub_scaled,
labRow = "",
labCol = pr_label,
cexCol = 0.5,
margins = c(3, 3),
xlab = "Tumor samples labeled by PR status",
ylab = "Variable genes",
col = heat_colors,
zlim = c(-2, 2),
main = "Gene expression heatmap with PR status labels"
)
Question: Does PR status look similar to ER status?
Your answer: PR status looks broadly similar to ER status because the PR‑positive tumors tend to cluster together and show a coordinated red‑and‑blue pattern, while the PR‑negative tumors group on the opposite side with a contrasting expression profile.
Create labels for HER2 status.
her2_status <- clin_sub$her2_receptor_status
her2_label <- rep("", length(her2_status))
her2_label[her2_status == "Positive"] <- "+"
her2_label[her2_status == "Negative"] <- "."
table(her2_label)
## her2_label
## . +
## 108 125 38
Now plot the heatmap with HER2 labels.
heatmap(
expr_sub_scaled,
labRow = "",
labCol = her2_label,
cexCol = 0.5,
margins = c(3, 3),
xlab = "Tumor samples labeled by HER2 status",
ylab = "Variable genes",
col = heat_colors,
zlim = c(-2, 2),
main = "Gene expression heatmap with HER2 status labels"
)
Question: Does HER2 status separate as clearly as ER status?
Your answer: HER2 status does not separate as clearly as ER status. In the HER2 heatmap, the positive and negative samples are more mixed across the clusters, and the red‑and‑blue patterns do not form the same clean blocks seen with ER. This reflects the fact that HER2 is a more specific marker, so only a small subset of tumors shows the strong, unified expression pattern that creates a sharp separation.
Reflection: Which receptor (ER, PR, or HER2) seems to show the strongest relationship with gene expression patterns? Were you surprised?
Your answer: ER shows the strongest relationship with gene expression patterns. The ER‑positive and ER‑negative samples form the clearest, most consistent separation, and the expression blocks shift in a very coordinated way between the two groups. PR looks similar but slightly less clean, and HER2 shows the weakest separation because only a small subset of tumors has the strong HER2‑driven signature. It’s not surprising, since ER status is one of the main features that defines the major breast cancer subtypes.
Triple-negative breast cancer means:
tnbc <- er_status == "Negative" &
pr_status == "Negative" &
her2_status == "Negative"
tnbc_label <- rep("", length(tnbc))
tnbc_label[tnbc] <- "TN"
table(tnbc_label)
## tnbc_label
## TN
## 244 27
Now plot the heatmap with TN labels.
heatmap(
expr_sub_scaled,
labRow = "",
labCol = tnbc_label,
cexCol = 0.5,
margins = c(3, 3),
xlab = "Tumor samples labeled by triple-negative status",
ylab = "Variable genes",
col = heat_colors,
zlim = c(-2, 2),
main = "Gene expression heatmap with triple-negative labels"
)
Question: Do triple-negative tumors appear as one clean group, or are they mixed with other tumors?
Your answer: Triple‑negative tumors do not fall into one neat cluster, and instead appear scattered among other samples with a wide range of expression patterns. Their placement suggests that triple‑negative status captures the absence of ER, PR, and HER2 rather than a single unified expression program, so the tumors show more internal diversity.
Reflection: If triple-negative tumors do not all cluster together, what are two possible biological explanations?
Your answer: Two biological explanations fit well here. Triple‑negative tumors can fall into different intrinsic subtypes, so some may resemble basal‑like biology while others align more with mesenchymal or immune‑rich programs. They can also be driven by different underlying mutations, meaning the loss of ER, PR, and HER2 does not force them into one shared expression pattern.
Final Reflection: Imagine you are a cancer researcher who has never seen these data before. Based on today’s analyses, what is one conclusion you feel confident making, and what is one question you would want to investigate next?
Your answer: I’d conclude that ER status has the strongest and most consistent relationship with overall gene expression patterns, since the ER‑positive and ER‑negative groups separate cleanly across multiple heatmaps. I’d want to investigate why triple‑negative tumors are so heterogeneous, and whether distinct subgroups within that category can be tied to particular pathways or driver mutations.
The most important biological lesson is:
Gene expression patterns can reflect important tumor features, but real cancer data are complex and must be interpreted carefully.
Your final report should include: