Activity 22 — PCA II: Loadings, Scores, and the Biplot
What PC2 means, labelling the interesting points, and reading arrows
Hands-on companion to the second PCA lecture, written for students with no previous statistics. Reading a loadings table, the mixed-signs rule, naming PC2 and then confirming it from the Pokemon at each end, plotting the scores, colouring by a variable that was deliberately kept out of the PCA, labelling only the extremes with ggrepel, building a biplot from the loadings, and writing up a PCA with no p-value. Homework is the full 1,025-Pokemon interpretation.
In-class Activity 22: Loadings, Scores, and the Biplot 🔴
Today’s Objectives
- Read a loadings table properly
- Learn the one rule that matters: mixed signs mean a trade-off
- Name PC2 from its loadings — then prove it from the data
- Plot the Pokémon in PC space: the picture, at last
- Label only the interesting points, with
ggrepel - Build a biplot by hand, so the arrows are not magic
- Write up a PCA when there is no p-value to lean on
One new package. Small and fast, but do it at home:
install.packages("ggrepel")- In-class skeleton:
22_pca_2_skeleton.R→ save it asscripts/22_pca_2.R. Every line of code is already written. - Homework skeleton:
22_pca_2_homework.R→ save it under that same name. This one is what you turn in, and its code is written too. - Data — both go in your
data/folder:pokemon_151.csv— today, in classpokemon_1025.csv— the homework
- Today is a run-it-and-read-it day. You do not turn in
scripts/22_pca_2.R. - The Q boxes are things to think about. Not graded.
- You turn in
scripts/22_pca_2_homework.R— all 1,025 Pokémon, and you name the axes yourself.
Today the whole job is interpretation — looking at numbers and deciding what they mean. There is no output that will tell you whether you got it right. That is not a flaw in the method; it is what the method is for.
Part 1 · Where we left off, and starting again
Last class: six correlated stats, impossible to plot. PCA found new axes by blending them. PC1 = 39.6%, PC2 = 21.5%, together 61.1%. PC1 had all-positive loadings, so we called it overall power, and checked it — Magikarp at the bottom, Mewtwo at the top.
Three things were missing: what PC2 means, a picture, and a way to see the original stats on that picture.
▶ Run this:
library(tidyverse)
library(ggrepel)
pokemon <- read_csv("data/pokemon_151.csv")
poke_stats <- pokemon %>%
select(hp, attack, defense, sp_attack, sp_defense, speed)
poke_pca <- prcomp(poke_stats, center = TRUE, scale. = TRUE)
summary(poke_pca)Nothing today depends on objects left over in last class’s session. Restart R, run from the top, get the same answer.
If a script only works because of something you ran yesterday, it is broken — you just cannot see it yet.
Part 2 · The loadings table
▶ Run this:
round(poke_pca$rotation[, 1:3], 2)One row per original stat, one column per PC. A loading is how much that stat contributes to that axis:
- big number → matters a lot to that axis
- near zero → barely involved
- the sign → which direction
PC1 — all the same sign
| Stat | PC1 loading |
|---|---|
sp_defense |
0.54 |
sp_attack |
0.45 |
attack |
0.42 |
hp |
0.38 |
speed |
0.32 |
defense |
0.28 |
Every one positive, none of them tiny. To score high on PC1 you must be high on everything.
An axis where every loading shares a sign is a “total” axis — overall size, overall strength, overall abundance.
You will see this constantly. PC1 is very often a total, because the single biggest way things differ is usually just how much of them there is.
Part 3 · PC2 has mixed signs
| Stat | PC2 loading | |
|---|---|---|
defense |
+0.59 | ⬆️ |
attack |
+0.43 | ⬆️ |
hp |
+0.19 | |
sp_defense |
−0.17 | |
sp_attack |
−0.33 | ⬇️ |
speed |
−0.54 | ⬇️ |
Two big positives and two big negatives on the same axis.
Mixed signs mean a trade-off, not a total.
PC2 is not measuring how much. It is measuring which kind — being strong in one way instead of another.
- High PC2 =
defense+attack - Low PC2 =
speed+sp_attack
Naming it
High PC2 means tough and hard-hitting but slow. Low PC2 means fast and special-attacking but fragile. Anyone who has played the game already has names for these:
- High PC2 → a tank
- Low PC2 → a glass cannon
PC2 is a “tank ↔︎ glass cannon” axis, and it carries 21.5% of all the variation in six columns.
prcomp() returned a column of numbers labelled PC2. It has no idea what a tank is.
Naming an axis is interpretation, and it is your responsibility. It is also the single most valuable thing you do in a PCA — and the easiest place to fool yourself.
✏️ Q1: hp loads +0.19 on PC2 — small but positive. Does that fit the tank story? Would you mention hp if you were describing this axis in one sentence?
Part 4 · Prove the name from the data
▶ Run this:
poke_scores <- pokemon %>%
mutate(PC1 = poke_pca$x[, 1],
PC2 = poke_pca$x[, 2])
poke_scores %>%
arrange(desc(PC2)) %>%
select(name, defense, speed, PC2) %>%
head(4)
poke_scores %>%
arrange(PC2) %>%
select(name, defense, speed, PC2) %>%
head(4)| End of PC2 | Who is there | Defence | Speed |
|---|---|---|---|
| Top | Rhydon, Golem, Cloyster, Rhyhorn | 95 – 180 | 25 – 70 |
| Bottom | Abra, Kadabra, Alakazam, Jynx | 15 – 45 | 90 – 120 |
Alakazam is the fastest, most fragile special attacker in the game. Golem is a literal boulder. They sit at opposite ends of an axis built from nothing but six columns of numbers.
- Read the loadings → form a guess about what the axis means
- Look at the extremes → check the guess against things you already know
Do them in that order. If step 2 disagrees with step 1, believe step 2.
Part 5 · Finally, the picture
▶ Run this:
ggplot(poke_scores, aes(x = PC1, y = PC2)) +
geom_point(alpha = 0.7)Every dot is one Pokémon. Neither axis is a thing anyone measured — both were invented by prcomp().
- Left ↔︎ right = weak ↔︎ strong (PC1, 39.6%)
- Bottom ↔︎ top = glass cannon ↔︎ tank (PC2, 21.5%)
Two dots close together = two Pokémon with similar stat profiles overall. That is the question we could not answer with 15 pairwise scatterplots. And this one flat picture holds 61% of six columns.
The scores were centred, so (0, 0) is the average Pokémon — not zero power.
A dot at the far left is not “no stats”. It is well below average. Scores are in standard deviations, which is why they run roughly −3.5 to +3.5.
✏️ Q2: Find roughly where you would expect a Pokémon with every stat exactly average to sit. Now where would one with every stat slightly above average sit?
Part 6 · Colour by something you know
▶ Run this:
ggplot(poke_scores, aes(x = PC1, y = PC2, color = is_legendary)) +
geom_point(alpha = 0.7, size = 2)The four legendaries sit in a cluster at the far right — high PC1 — and slightly low on PC2. So the game’s strongest Pokémon are not just powerful; they lean a little towards the fast, special-attacking side rather than the tank side.
is_legendary was never in the PCA
Look back at Part 1: poke_stats has six columns, and is_legendary is not one of them. prcomp() never saw it. We only used it to colour the finished result.
That is the right way round. Build the axes from the measurements only, then colour by the thing you care about and see whether it lines up.
Put the grouping variable into the PCA and you have built the answer you wanted — the same circular trap as the wētā’s head_length.
✏️ Q3: Suppose you were studying skull measurements from two species and put “species” into the PCA as a 0/1 column. What would go wrong, and how would you know?
Part 7 · Label only the interesting ones
Labelling all 151 gives you a solid black rectangle. So label only the extremes — the ones furthest from the centre, which are the ones a reader will ask about.
abs(PC1)is distance from the centre, ignoring directionquantile(abs(PC1), 0.97)is the value only 3% of Pokémon exceed- Together: “far from average on PC1, either way”
▶ Run this:
extremes <- poke_scores %>%
filter(abs(PC1) > quantile(abs(PC1), 0.97) |
abs(PC2) > quantile(abs(PC2), 0.97))
nrow(extremes)
extremes %>%
select(name, PC1, PC2)
ggplot(poke_scores, aes(x = PC1, y = PC2)) +
geom_point(color = "grey70") +
geom_point(data = extremes, color = "firebrick") +
geom_text_repel(data = extremes, aes(label = name), size = 3)The | in the filter means OR, so a Pokémon qualifies by being extreme on either axis, not both.
Three layers, and the order matters — later layers draw on top:
- all 151 points, grey, in the background
- the extremes, on top, in red
- names, only for the extremes
geom_text_repel() is ggrepel’s version of geom_text(): it nudges labels apart so they do not overlap, and draws a little line back to the dot.
data = inside a layer
Each geom_ can take its own data =. Give it one and that layer ignores the data in ggplot().
That is how you draw two different subsets on one plot — and it works for every geom, not just these.
✏️ Q4: Change 0.97 to 0.90 and re-run, then to 0.99. There is no correct value. How did you decide which looked best, and what does that tell you about the choice?
Part 8 · The biplot
Our plot shows where Pokémon sit. It does not show which direction each original stat points.
A biplot draws both at once:
- Points = the Pokémon (the scores)
- Arrows = the original stats (the loadings)
R has a built-in biplot(poke_pca), but it is ugly and hard to control. Building ours from the loadings means you know exactly what the arrows are.
▶ Run this:
arrows_df <- as_tibble(poke_pca$rotation[, 1:2], rownames = "stat") %>%
mutate(PC1 = PC1 * 4,
PC2 = PC2 * 4)
arrows_df
ggplot(poke_scores, aes(x = PC1, y = PC2)) +
geom_point(color = "grey75", alpha = 0.7) +
geom_segment(data = arrows_df,
aes(x = 0, y = 0, xend = PC1, yend = PC2),
arrow = arrow(length = unit(0.2, "cm"))) +
geom_text_repel(data = arrows_df, aes(label = stat),
fontface = "bold", size = 3.4)* 4
Loadings run about −0.6 to +0.6. The scores run about −3.5 to +3.5. Draw the arrows at their true length and they vanish into a dot at the centre.
Multiplying by 4 is purely cosmetic. It changes the arrows’ lengths on screen, never their directions — and the directions are the part you interpret. Try 2, or 6, and see.
Reading the arrows
- Every arrow points right — all six stats increase with PC1; the all-positive loadings, now visible
defensepoints up-right,speedpoints down-right — nearly opposite vertically, which is the trade-offattacksits withdefense;sp_attackandsp_defenselean withspeeddefensehas the longest arrow,hpthe shortest — sohpis the stat least involved in these two axes
| Arrows | Meaning |
|---|---|
| close together | correlated stats |
| at right angles | unrelated |
| opposite | trade-off |
Part 9 · A trap worth naming
Last class the correlation between speed and defense was −0.05 — essentially unrelated.
But on this biplot their arrows point in nearly opposite directions, which looks like a strong trade-off.
Both are true. The biplot is a shadow. Two arrows can look opposed in the shadow while being unrelated in the full six dimensions.
“PC1 and PC2, which together explain 61.1% of the variance”
Without that number, a reader cannot tell whether your biplot is the whole story or three-fifths of it.
Never show a biplot without its percentages.
✏️ Q5: Go back to the correlation matrix from last class. Find another pair whose biplot arrows and actual correlation disagree. Why is PC3 relevant here?
Part 10 · Writing up a PCA
There is no p-value, so the structure is different from everything else this term.
Six battle statistics for the 151 original Pokémon were standardised and reduced by principal component analysis. The first two components explained 61.1% of the total variance (PC1 39.6%, PC2 21.5%). PC1 loaded positively on all six statistics (0.28 to 0.54) and is interpreted as overall power: the four legendary Pokémon averaged PC1 = 3.00 against −0.08 for all others, and Magikarp held the lowest score. PC2 contrasted
defense(+0.59) andattack(+0.43) againstspeed(−0.54) andsp_attack(−0.33), separating durable, slow-moving species such as Golem and Cloyster from fast, fragile ones such as Alakazam.
What that paragraph does:
- Says what went in — six stats, 151 Pokémon
- Says it was standardised (
scale. = TRUE) - Gives variance explained, per axis and cumulative
- Names each axis, and gives the loadings that justify the name
- Gives evidence from the data — real Pokémon at each end
- Never says “significant”, because nothing was tested
Too little: “We ran a PCA and PC1 explained 39.6%.” Explained of what? Meaning what?
Too much: “PC1 proves power is one-dimensional.” PCA proves nothing. It describes.
Describe, name, justify. Stop there.
✏️ Q6: The paragraph names two Pokémon for PC2 and two for PC1. Why is naming actual data points more convincing than just quoting the loadings?
Extension (homework) — all 1,025 Pokémon, interpreted
This is what you turn in: scripts/22_pca_2_homework.R
Last homework you ran the PCA on all 1,025 Pokémon and read the numbers. Now you interpret it:
- Read the loadings and name PC2 yourself — no answer is printed anywhere
- Confirm it from the extremes, and if they disagree with your name, change it and say that you changed it
- Draw the scores plot, coloured by legendary status — 71 of them this time, not 4
- Draw the biplot
- Write a full PCA results paragraph
Open the file and press Ctrl/Cmd + Shift + Enter. Everything runs.
Nearly all of the marks are for naming the axes and for the write-up. The only code you write is one number, in the optional section at the bottom.
With 1,025 Pokémon your scores plot has 1,025 dots and your labelled plot is crowded.
One of the questions asks what you would change to make it readable — and “label fewer things” is a perfectly good answer, as long as you say what you lose by doing it.
Session > Restart R, then run the whole file top to bottom. It must run clean, and all four plots must draw.