{
function lcg(seed) {
let s = seed;
return () => { s = (1664525 * s + 1013904223) & 0xffffffff; return (s >>> 0) / 0xffffffff; };
}
const rand = lcg(42);
const randn = () => {
const u = rand() || 1e-10, v = rand();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
};
const n = 200;
const height = [], weight = [], bmi = [];
for (let i = 0; i < n; i++) {
const h = 170 + 10 * randn();
const w = 0.5 * h - 15 + 8 * randn();
const b = w / (h / 100) ** 2;
height.push(h); weight.push(w); bmi.push(b);
}
const mean = arr => arr.reduce((a, b) => a + b) / arr.length;
const std = arr => { const m = mean(arr); return Math.sqrt(arr.reduce((s, x) => s + (x - m) ** 2, 0) / arr.length); };
const scale = arr => { const m = mean(arr), s = std(arr); return arr.map(x => (x - m) / s); };
const H = scale(height), W = scale(weight), B = scale(bmi);
const dot = (a, b) => a.reduce((s, x, i) => s + x * b[i], 0) / a.length;
const C = [[dot(H,H), dot(H,W), dot(H,B)],
[dot(W,H), dot(W,W), dot(W,B)],
[dot(B,H), dot(B,W), dot(B,B)]];
function powerIter(M, iter=200) {
let v = [1/Math.sqrt(3), 1/Math.sqrt(3), 1/Math.sqrt(3)];
for (let k = 0; k < iter; k++) {
let nv = [0,0,0];
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) nv[i] += M[i][j] * v[j];
const norm = Math.sqrt(nv.reduce((s,x) => s + x*x, 0));
v = nv.map(x => x / norm);
}
const lam = v.reduce((s, x, i) => s + x * M[i].reduce((ss, y, j) => ss + y * v[j], 0), 0);
return { vec: v, val: lam };
}
const { vec: e1, val: l1 } = powerIter(C);
const C2 = C.map((row, i) => row.map((x, j) => x - l1 * e1[i] * e1[j]));
const { vec: e2, val: l2 } = powerIter(C2, 400);
const totalVar = C[0][0] + C[1][1] + C[2][2];
const pct1 = (l1 / totalVar * 100).toFixed(1);
const pct2 = (l2 / totalVar * 100).toFixed(1);
const pts3d = H.map((h, i) => [h, W[i], B[i]]);
const pc1 = pts3d.map(p => p[0]*e1[0] + p[1]*e1[1] + p[2]*e1[2]);
const pc2 = pts3d.map(p => p[0]*e2[0] + p[1]*e2[1] + p[2]*e2[2]);
const W_c = 380, H_c = 380;
const canvas3d = DOM.canvas(W_c, H_c);
canvas3d.style.cursor = "grab";
canvas3d.style.borderRadius = "6px";
canvas3d.style.border = "1px solid #ddd";
const ctx3 = canvas3d.getContext("2d");
let theta = -0.4, phi = 0.5, dragging = false, lastX = 0, lastY = 0;
function project([x, y, z]) {
const ct = Math.cos(theta), st = Math.sin(theta);
const cp = Math.cos(phi), sp = Math.sin(phi);
const rx = ct * x + st * z;
const ry = -sp * st * x + cp * y + sp * ct * z;
return [W_c/2 + rx*90, H_c/2 - ry*90, rx];
}
function draw3d() {
ctx3.clearRect(0, 0, W_c, H_c);
ctx3.fillStyle = "#333"; ctx3.font = "bold 13px sans-serif"; ctx3.textAlign = "center";
ctx3.fillText("Original 3D data (drag to rotate)", W_c/2, 22);
[{ vec:[1,0,0], label:"Height" }, { vec:[0,1,0], label:"Weight" }, { vec:[0,0,1], label:"BMI" }]
.forEach(({ vec, label }) => {
const [x0,y0] = project([0,0,0]);
const [x1,y1] = project(vec.map(v => v*2.2));
ctx3.strokeStyle="#888"; ctx3.lineWidth=1.2; ctx3.setLineDash([4,3]);
ctx3.beginPath(); ctx3.moveTo(x0,y0); ctx3.lineTo(x1,y1); ctx3.stroke();
ctx3.setLineDash([]);
ctx3.fillStyle="#888"; ctx3.font="11px sans-serif"; ctx3.textAlign="center";
ctx3.fillText(label, x1+(x1-x0)*0.12, y1+(y1-y0)*0.12);
});
pts3d.map((p,i) => ({ p2:project(p), i }))
.sort((a,b) => a.p2[2]-b.p2[2])
.forEach(({ p2:[px,py] }) => {
ctx3.beginPath(); ctx3.arc(px,py,3.5,0,2*Math.PI);
ctx3.fillStyle="rgba(70,130,180,0.55)"; ctx3.fill();
});
}
canvas3d.addEventListener("mousedown", e => { dragging=true; lastX=e.offsetX; lastY=e.offsetY; canvas3d.style.cursor="grabbing"; });
canvas3d.addEventListener("mousemove", e => {
if (!dragging) return;
theta += (e.offsetX-lastX)*0.012; phi += (e.offsetY-lastY)*0.012;
phi = Math.max(-Math.PI/2+0.05, Math.min(Math.PI/2-0.05, phi));
lastX=e.offsetX; lastY=e.offsetY; draw3d();
});
canvas3d.addEventListener("mouseup", () => { dragging=false; canvas3d.style.cursor="grab"; });
canvas3d.addEventListener("mouseleave", () => { dragging=false; canvas3d.style.cursor="grab"; });
canvas3d.addEventListener("touchstart", e => { e.preventDefault(); lastX=e.touches[0].clientX; lastY=e.touches[0].clientY; dragging=true; }, { passive:false });
canvas3d.addEventListener("touchmove", e => {
e.preventDefault(); if (!dragging) return;
theta += (e.touches[0].clientX-lastX)*0.012; phi += (e.touches[0].clientY-lastY)*0.012;
phi = Math.max(-Math.PI/2+0.05, Math.min(Math.PI/2-0.05, phi));
lastX=e.touches[0].clientX; lastY=e.touches[0].clientY; draw3d();
}, { passive:false });
canvas3d.addEventListener("touchend", () => { dragging=false; });
draw3d();
const canvas2d = DOM.canvas(W_c, H_c);
canvas2d.style.borderRadius = "6px";
canvas2d.style.border = "1px solid #ddd";
const ctx2 = canvas2d.getContext("2d");
const pad = 55;
const minP1=Math.min(...pc1), maxP1=Math.max(...pc1);
const minP2=Math.min(...pc2), maxP2=Math.max(...pc2);
const scX = x => pad + (x-minP1)/(maxP1-minP1)*(W_c-2*pad);
const scY = y => H_c-pad - (y-minP2)/(maxP2-minP2)*(H_c-2*pad);
ctx2.clearRect(0,0,W_c,H_c);
ctx2.fillStyle="#333"; ctx2.font="bold 13px sans-serif"; ctx2.textAlign="center";
ctx2.fillText("After PCA: 3 variables \u2192 2 components", W_c/2, 22);
ctx2.strokeStyle="#aaa"; ctx2.lineWidth=1;
ctx2.beginPath(); ctx2.moveTo(pad,pad); ctx2.lineTo(pad,H_c-pad); ctx2.stroke();
ctx2.beginPath(); ctx2.moveTo(pad,H_c-pad); ctx2.lineTo(W_c-pad,H_c-pad); ctx2.stroke();
ctx2.fillStyle="#555"; ctx2.font="12px sans-serif"; ctx2.textAlign="center";
ctx2.fillText(`PC1 (${pct1}% variance)`, W_c/2, H_c-12);
ctx2.save(); ctx2.translate(16,H_c/2); ctx2.rotate(-Math.PI/2);
ctx2.fillText(`PC2 (${pct2}% variance)`, 0, 0); ctx2.restore();
pc1.forEach((x,i) => {
ctx2.beginPath(); ctx2.arc(scX(x), scY(pc2[i]), 3.5, 0, 2*Math.PI);
ctx2.fillStyle="rgba(34,139,34,0.55)"; ctx2.fill();
});
return html`<div style="display:flex;gap:16px;justify-content:center;align-items:flex-start;flex-wrap:wrap;margin:12px 0">
${canvas3d}
${canvas2d}
</div>`;
}Principal Component Analysis (PCA)
Introduction
Welcome to the Principal Component Analysis (PCA) module. By the end of this workshop, you will be able to:
- Explain what PCA does and why it is useful in biological research
- Recognize what PCA output looks like (scores, loadings, a score plot)
- Apply three standard criteria to decide how many principal components to retain:
- Kaiser criterion (eigenvalue > 1)
- Variance explained threshold (>=80-90%)
- Scree plot visual inspection
- Interpret loadings to understand what each principal component represents biologically
- Carry out a basic PCA in both R and Python
Remember: if you get stuck on a coding exercise, click the Hint button for a nudge, or the Solution button if you need the full answer.
Motivation
Why do we need PCA?
In human biology, datasets are almost always high-dimensional. You might measure dozens of clinical traits, hundreds of gene expression values, or thousands of metabolite concentrations on each individual. Analyzing every variable separately is slow, and many of those variables are correlated with each other.
PCA is a technique that takes a set of correlated variables and replaces them with a smaller set of uncorrelated summary variables called principal components (PCs). Each PC is a weighted combination of the original variables, constructed so that:
- PC1 captures as much variation in the data as possible.
- PC2 captures as much of the remaining variation as possible (and is uncorrelated with PC1).
- And so on…
Note
PCA does not require you to have a response variable. It is an unsupervised method used to explore and summarize your data.
A visual intuition
The best way to understand PCA is to see what is happening. The interactive 3-D plot and static 2-D plot below show the same simulated dataset of 200 people (height, weight, BMI) before and after PCA. Drag to rotate the 3D plot on the left to see how the cloud of points is oriented in three dimensions. The plot on the right shows the same data after PCA collapses all three variables into two axes, together preserving 99.9% of the variance.
The 3D plot shows all three variables simultaneously as a cloud of points. No single viewing angle captures all the structure at once. The PCA plot on the right finds the best possible 2D view automatically. Note that being able to reduce to just 2 components and still make a useful scatterplot is not always possible it depends on whether most variance is concentrated in the first two PCs.
NoteThe challenge of high-dimensional data
While 3D data is manageable, the real issue arises with high-dimensional biological datasets. If you collect 4, 10, or 10,000 variables (like gene expression networks or extensive metabolic panels) on each patient, it becomes completely impossible to visualize the data geometrically. You cannot plot a 4D or 50D scatterplot. PCA is powerful because it compresses these hyper-dimensional spaces down to fewer principal components, allowing us to visualize, cluster, and analyze complex biological structures that would otherwise remain hidden.
Section 1: The Data
Study background
This module uses data from by a published RNA-seq study of human folliculogenesis (GSE107746, Zhang et al. 2018). That study profiled oocytes and granulosa cells from 148 samples across five follicular development stages using RNA-seq, generating log2 FPKM expression values for thousands of genes. PCA was a central tool in that paper for showing how samples from different stages separate in gene expression space.
For this module we use four marker genes that show strong stage-specific expression in folliculogenesis, based on the biology reported in the paper:
| Gene | Role |
|---|---|
| NOBOX | Oocyte-specific transcription factor; high in early stages |
| GDF9 | Oocyte growth factor; rises through primary and secondary stages |
| FSHR | FSH receptor in granulosa cells; increases toward antral stage |
| LHCGR | LH/CG receptor; peaks at preovulatory stage |
The 148 samples span five stages: Primordial (n=25), Primary (n=40), Secondary (n=18), Antral (n=47), and Preovulatory (n=18).
Note
The expression values below are simulated to match the group structure and approximate expression patterns described in the paper. They are not the raw GEO data, but they capture the biological signal that PCA was used to reveal.
1.1 Explore the data
Run the code below to load and inspect the dataset. You do not need to change anything. Just click Run Code and examine the output.
1.2 Visualize pairwise relationships
Before running PCA, it helps to see how correlated the variables are. Highly correlated variables are a sign that PCA may be able to compress the data effectively.
Section 2: Running PCA
2.1 Why scale first?
PCA is sensitive to the scale of variables. A variable with a large range will dominate one with a small range, even if they are conceptually equally important. The standard fix is to standardize each variable to have mean 0 and standard deviation 1 before running PCA (scale. = TRUE in R; StandardScaler in Python).
View code for plot
raw <- as.matrix(follicle_data[, 1:4])
scaled <- scale(raw)
par(mfrow = c(1, 2), mar = c(6, 4, 3, 1))
boxplot(raw, main = "Before scaling", ylab = "log2 FPKM", col = "steelblue", las = 2)
boxplot(scaled, main = "After scaling (mean=0, sd=1)", ylab = "Standardized value", col = "forestgreen", las = 2)
par(mfrow = c(1, 1))View code for plot
from sklearn.preprocessing import StandardScaler
raw = follicle_data[["NOBOX","GDF9","FSHR","LHCGR"]].values
scaled = StandardScaler().fit_transform(raw)
fig, axes = plt.subplots(1, 2, figsize=(9, 4))
axes[0].boxplot(raw, labels=["NOBOX","GDF9","FSHR","LHCGR"]); axes[0].set_title("Before scaling"); axes[0].set_ylabel("log2 FPKM")
axes[1].boxplot(scaled, labels=["NOBOX","GDF9","FSHR","LHCGR"]); axes[1].set_title("After scaling (mean=0, sd=1)"); axes[1].set_ylabel("Standardized value")
plt.tight_layout(); plt.show()
Tip
As a rule of thumb: always scale your variables unless you have a strong reason to believe they are already on the same scale.
2.2 Perform PCA: fill in the blanks
Complete the code to run PCA on all four gene expression columns, with scaling. The pca_result object you create here will carry forward into Sections 3, 4, and 5.
NoteHint
The four gene columns are columns 1:4. Use scale. = TRUE to standardize before PCA.
TipSolution
pca_result <- prcomp(follicle_data[, 1:4], scale. = TRUE)
summary(pca_result)
pca_result <- prcomp(follicle_data[, 1:4], scale. = TRUE)
summary(pca_result)
NoteHint
Use scaler.fit_transform(X) to scale. Then PCA(n_components=4) retains all 4 PCs.
TipSolution
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
pca = PCA(n_components=4)
pca.fit(X_scaled)
print("Variance explained by each PC:")
print(np.round(pca.explained_variance_ratio_, 4))
print("\nCumulative variance explained:")
print(np.round(np.cumsum(pca.explained_variance_ratio_), 4))Sections 3-5: How Many Components to Keep?
Running PCA gives you as many PCs as input variables (4 here). The goal is to reduce dimensionality, so we need principled rules for deciding how many PCs are sufficient.
There are three widely used approaches, each covered in its own section:
| Method | Rule |
|---|---|
| Section 3 — Kaiser criterion | Keep PCs with eigenvalue > 1 |
| Section 4 — Variance threshold | Keep PCs until cumulative variance >= 80-90% |
| Section 5 — Scree plot | Keep PCs before the “elbow” in the eigenvalue plot |
Section 3: The Kaiser Criterion
The eigenvalue of a PC equals the variance it explains (in standardized units, where each original variable has variance = 1). A PC with eigenvalue > 1 captures more information than any single original variable, so it is worth retaining.
Note
In R: eigenvalues = pca_result$sdev^2
In Python: eigenvalues = pca.explained_variance_
Exercise: Extract eigenvalues and apply the Kaiser criterion
NoteHint
pca_result$sdev gives standard deviations. Square them with ^2 to get eigenvalues. The Kaiser threshold is > 1.
TipSolution
eigenvalues <- pca_result$sdev ^ 2
print(round(eigenvalues, 3))
kaiser_keep <- eigenvalues > 1
print(paste("Keep PCs:", which(kaiser_keep)))
eigenvalues <- pca_result$sdev ^ 2
print(round(eigenvalues, 3))
kaiser_keep <- eigenvalues > 1
print(paste("Keep PCs:", which(kaiser_keep)))
NoteHint
Use pca.explained_variance_ (not explained_variance_ratio_). The threshold is > 1.
TipSolution
eigenvalues = pca.explained_variance_
print("Eigenvalues:", np.round(eigenvalues, 3))
kaiser_keep = eigenvalues > 1
print("PCs to keep (0-based index):", np.where(kaiser_keep)[0])
print("Number of PCs to keep:", np.sum(kaiser_keep))
WarningKaiser caveat
The Kaiser criterion is a rule of thumb, not a law. Always use it alongside the variance threshold and scree plot before deciding how many PCs to retain.
Section 4: Variance Explained Threshold
Keep enough PCs to explain at least 80-90% of the total variance. The right threshold depends on your field and how much information loss you can tolerate.
Exercise: Compute cumulative variance explained
NoteHint
cumsum() builds a running cumulative total. The threshold values are 0.80 and 0.90.
TipSolution
prop_var <- summary(pca_result)$importance["Proportion of Variance", ]
cum_var <- cumsum(prop_var)
print(round(prop_var, 3))
print(round(cum_var, 3))
n_80 <- which(cum_var >= 0.80)[1]
cat("PCs needed for >= 80% variance:", n_80, "\n")
n_90 <- which(cum_var >= 0.90)[1]
cat("PCs needed for >= 90% variance:", n_90, "\n")
prop_var <- summary(pca_result)$importance["Proportion of Variance", ]
cum_var <- cumsum(prop_var)
print(round(prop_var, 3))
print(round(cum_var, 3))
n_80 <- which(cum_var >= 0.80)[1]
cat("PCs needed for >= 80% variance:", n_80, "\n")
n_90 <- which(cum_var >= 0.90)[1]
cat("PCs needed for >= 90% variance:", n_90, "\n")
NoteHint
np.cumsum() gives a running total. Use >= 0.80 and >= 0.90 as thresholds. np.argmax() on a boolean array returns the first True index.
TipSolution
prop_var = pca.explained_variance_ratio_
cum_var = np.cumsum(prop_var)
print("Variance per PC: ", np.round(prop_var, 3))
print("Cumulative variance: ", np.round(cum_var, 3))
n_80 = int(np.argmax(cum_var >= 0.80)) + 1
print(f"PCs needed for >= 80% variance: {n_80}")
n_90 = int(np.argmax(cum_var >= 0.90)) + 1
print(f"PCs needed for >= 90% variance: {n_90}")Now let’s visualize the cumulative variance as a bar chart.
View code for plot
prop_var <- summary(pca_result)$importance["Proportion of Variance", ]
cum_var <- cumsum(prop_var)
barplot(cum_var, names.arg = paste0("PC", 1:4), ylim = c(0, 1.05),
col = ifelse(cum_var >= 0.9, "forestgreen", "steelblue"),
ylab = "Cumulative proportion of variance",
main = "Cumulative variance explained (folliculogenesis PCA)")
abline(h = 0.80, lty = "dashed", col = "orange", lwd = 2)
abline(h = 0.90, lty = "dashed", col = "red", lwd = 2)
legend("bottomright", legend = c("80% threshold","90% threshold"),
lty = "dashed", col = c("orange","red"), lwd = 2, bty = "n")View code for plot
cum_var_plot = np.cumsum(pca_cv.explained_variance_ratio_)
pc_labels = [f"PC{i+1}" for i in range(4)]
colors = ["forestgreen" if v >= 0.9 else "steelblue" for v in cum_var_plot]
plt.figure(figsize=(6, 4))
plt.bar(pc_labels, cum_var_plot, color=colors)
plt.axhline(0.80, color="orange", linestyle="--", lw=2, label="80% threshold")
plt.axhline(0.90, color="red", linestyle="--", lw=2, label="90% threshold")
plt.ylim(0, 1.05); plt.ylabel("Cumulative proportion of variance")
plt.title("Cumulative variance explained (folliculogenesis PCA)")
plt.legend(loc="lower right"); plt.tight_layout(); plt.show()Section 5: Scree Plot
A scree plot shows eigenvalues in descending order. Look for the “elbow” where the slope changes sharply. PCs before the elbow are retained.
Exercise: Draw a scree plot
NoteHint
Pass eigenvalues as the first argument to plot(). The Kaiser line sits at h = 1.
TipSolution
eigenvalues <- pca_result$sdev ^ 2
plot(eigenvalues, type = "b",
xlab = "Principal Component", ylab = "Eigenvalue",
main = "Scree Plot: Folliculogenesis PCA",
pch = 19, col = "steelblue", lwd = 2)
abline(h = 1, lty = "dashed", col = "red")
legend("topright", legend = "Kaiser threshold (eigenvalue = 1)",
lty = "dashed", col = "red", bty = "n")
eigenvalues <- pca_result$sdev ^ 2
plot(eigenvalues, type = "b",
xlab = "Principal Component", ylab = "Eigenvalue",
main = "Scree Plot: Folliculogenesis PCA",
pch = 19, col = "steelblue", lwd = 2)
abline(h = 1, lty = "dashed", col = "red")
legend("topright", legend = "Kaiser threshold (eigenvalue = 1)",
lty = "dashed", col = "red", bty = "n")
NoteHint
Use pca.explained_variance_ for eigenvalues. The x-axis values are pc_numbers and the Kaiser line is at y = 1.
TipSolution
eigenvalues_scree = pca.explained_variance_
pc_numbers = list(range(1, len(eigenvalues_scree) + 1))
plt.figure(figsize=(5, 4))
plt.plot(pc_numbers, eigenvalues_scree, "o-", color="steelblue", lw=2, markersize=8)
plt.axhline(y=1, color="red", linestyle="--", label="Kaiser threshold (eigenvalue = 1)")
plt.xlabel("Principal Component"); plt.ylabel("Eigenvalue")
plt.title("Scree Plot: Folliculogenesis PCA")
plt.xticks(pc_numbers); plt.legend(); plt.tight_layout(); plt.show()Section 6: What Do the Components Mean? Loadings
We have decided to retain 2 PCs. But what do PC1 and PC2 actually mean biologically? The answer lies in the loadings.
Loadings are the weights assigned to each gene in the construction of each PC. A large absolute loading means that gene strongly drives that PC. Genes with loadings near zero have little influence.
TipInterpreting loadings biologically
Look for genes with large absolute loadings. If a group of functionally related genes all load heavily on the same PC, that PC likely represents an underlying biological process, such as the maturation trajectory from primordial to preovulatory follicle.
6.1 Extract and view loadings
NoteHint
Loadings live in pca_result$rotation. Pass that to round() to print neatly.
TipSolution
loadings <- pca_result$rotation
print(round(loadings, 3))
loadings <- pca_result$rotation
print(round(loadings, 3))
NoteHint
Loadings are in pca.components_. Transpose with .T so rows are genes.
TipSolution
loadings = pca.components_.T
loadings_df = pd.DataFrame(
np.round(loadings, 3),
index=feature_names,
columns=[f"PC{i+1}" for i in range(4)]
)
print(loadings_df)6.2 Visualize loadings as a bar chart
View code for plot
loadings <- pca_result$rotation
var_names <- rownames(loadings)
par(mfrow = c(1, 2), mar = c(6, 4, 3, 1))
barplot(loadings[,1], names.arg=var_names, main="PC1 Loadings", ylab="Loading",
col=ifelse(loadings[,1]>0,"steelblue","tomato"), las=2, ylim=c(-1,1))
abline(h=0)
barplot(loadings[,2], names.arg=var_names, main="PC2 Loadings", ylab="Loading",
col=ifelse(loadings[,2]>0,"steelblue","tomato"), las=2, ylim=c(-1,1))
abline(h=0)
par(mfrow=c(1,1))View code for plot
loadings = pca_plot.components_.T
fig, axes = plt.subplots(1, 2, figsize=(9, 4), sharey=True)
for ax, pc_idx in zip(axes, [0, 1]):
vals = loadings[:, pc_idx]
cols = ["steelblue" if v > 0 else "tomato" for v in vals]
ax.bar(feature_names, vals, color=cols)
ax.axhline(0, color="black", lw=0.8)
ax.set_title(f"PC{pc_idx+1} Loadings"); ax.set_ylabel("Loading"); ax.set_ylim(-1, 1)
ax.set_xticks(range(len(feature_names)))
ax.set_xticklabels(feature_names, rotation=25, ha="right", fontsize=9)
plt.suptitle("Loadings for PC1 and PC2: Folliculogenesis PCA")
plt.tight_layout(); plt.show()Section 7: Biological Interpretation of the Loadings
Based on the loading bar charts in Section 6:
PC1 is dominated by NOBOX (large negative loading) and FSHR (large positive loading), with GDF9 also contributing. Since NOBOX is an early-stage oocyte factor and FSHR is a late-stage granulosa cell receptor, PC1 represents the follicular maturation gradient: samples progress from negative to positive PC1 scores as follicles develop from primordial to preovulatory.
PC2 is driven primarily by LHCGR and partly by GDF9. LHCGR spikes specifically at the preovulatory stage in response to the LH surge. PC2 therefore captures a preovulatory transition signal that is orthogonal to the general maturation gradient on PC1.
Note
The sign of a PC is arbitrary. PCA can flip any axis and the result is mathematically equivalent. What matters is the magnitude of loadings and their relative signs across genes.
Section 8: Score Plot
The score plot places each sample in the new PC coordinate system, coloured by follicle stage. This is where the biological interpretation pays off: we can see whether samples from the same stage cluster together, and whether the stages separate along axes that reflect the biology encoded in the loadings.
This is analogous to Figure 1 in Zhang et al. (2018), where PCA of the full transcriptome showed clear separation of oocyte samples by developmental stage.
View code for plot
library(ggplot2)
scores <- as.data.frame(pca_result$x)
scores$Stage <- follicle_data$Stage
pct <- round(summary(pca_result)$importance[2, 1:2] * 100, 1)
stage_cols <- c("Primordial"="#4e79a7","Primary"="#f28e2b",
"Secondary"="#59a14f","Antral"="#e15759","Preovulatory"="#b07aa1")
ggplot(scores, aes(x=PC1, y=PC2, colour=Stage)) +
geom_point(size=2.2, alpha=0.85) +
scale_colour_manual(values=stage_cols) +
labs(title = "PCA Score Plot: Folliculogenesis (GSE107746-inspired)",
x = paste0("PC1 (",pct[1],"% variance): maturation gradient"),
y = paste0("PC2 (",pct[2],"% variance): preovulatory transition")) +
theme_bw(base_size=12)View code for plot
df = pd.DataFrame(scores_arr, columns=["PC1","PC2"])
df["Stage"] = pd.Categorical(stage_labels, categories=stage_order)
stage_cols = {"Primordial":"#4e79a7","Primary":"#f28e2b",
"Secondary":"#59a14f","Antral":"#e15759","Preovulatory":"#b07aa1"}
var_pct = pca2.explained_variance_ratio_ * 100
fig, ax = plt.subplots(figsize=(7,5))
for sp, grp in df.groupby("Stage", observed=True):
ax.scatter(grp["PC1"], grp["PC2"], label=sp, color=stage_cols[sp], alpha=0.85, s=35)
ax.set_xlabel(f"PC1 ({var_pct[0]:.1f}% variance): maturation gradient")
ax.set_ylabel(f"PC2 ({var_pct[1]:.1f}% variance): preovulatory transition")
ax.set_title("PCA Score Plot: Folliculogenesis (GSE107746-inspired)")
ax.legend(title="Stage", bbox_to_anchor=(1.02,1), loc="upper left", fontsize=8)
plt.tight_layout(); plt.show()Notice how the five follicular stages form a roughly ordered sequence along PC1, consistent with the maturation gradient interpretation from the loadings. The Preovulatory samples separate upward on PC2, capturing the LHCGR-driven preovulatory transition signal. This mirrors the kind of stage-level separation that Zhang et al. observed in the full transcriptome data (GSE107746).
Summary
In this module you have learned:
What PCA is and why it is essential for high-dimensional biological data like RNA-seq.
How to build and explore a gene expression dataset based on a published folliculogenesis study (GSE107746).
How to run PCA in R (prcomp) and Python (sklearn.decomposition.PCA) with scaling.
How to apply the Kaiser criterion (eigenvalue > 1) to select components.
How to use cumulative variance explained (>=80-90%) to select components.
How to draw and interpret a scree plot to identify the elbow.
How to extract and interpret loadings to give PCs a biological meaning.
How to produce and interpret a score plot coloured by biological group.
Key takeaway
For this folliculogenesis dataset, all three criteria agree: retain 2 principal components. Together they explain approximately 90% of the total variance in four marker genes. The loadings show that PC1 represents the follicular maturation gradient (NOBOX declining, FSHR rising) and PC2 captures the preovulatory LHCGR spike. This biologically interpretable structure in just two dimensions mirrors what Zhang et al. observed in thousands of genes, demonstrating why PCA is a first-line tool in transcriptomic analysis.
In practice, the three criteria sometimes disagree. Always consider them together and use your biological judgement.