Project 2: Pokémon Base Set Population Report

DATA 607 · Project 2 · Dataset 1 of 3

Author

Dillon Leeper

Published

October 11, 2026

Overview

This is the dataset from my own Discussion 5A post. A population report counts how many copies of each trading card have been graded at each grade. The questions from my post: what share of each card’s graded copies earned a 10 (its gem rate), does 1st Edition grade better than later printings, and which cards are hardest to get a 10 on?

Data source

The data comes from PriceCharting’s population report for the Pokémon Base Set (https://www.pricecharting.com/pop/set/pokemon-base-set). Grading companies like PSA and CGC score cards on a 1 to 10 scale, and a population report counts how many copies of each card have received each grade. PriceCharting combines PSA and CGC counts into one number and folds half grades (for example 8.5) into the nearest whole grade below.

I copied the table into pokemon_base_set_pop_wide.csv exactly as it appears on the page, sorted by popularity, on October 8, 2026. Nothing was cleaned by hand: counts keep their thousands commas, missing values keep their “-” character, and sealed products (booster boxes and packs) stay in the file. The CSV holds the first 141 rows of the report as the page loaded them, which are the most popular items, not all 466 items the site lists for the set.

Code
library(readr)
library(dplyr)
library(tidyr)
library(stringr)
library(ggplot2)
library(scales)
library(knitr)

# One shared look for every chart in this project
theme_p2 <- function() {
  theme_minimal(base_size = 12) +
    theme(
      plot.title       = element_text(face = "bold"),
      panel.grid.minor = element_blank(),
      legend.position  = "top"
    )
}

# The holo vs. non-holo chart jitters its points, so fix the random seed
set.seed(607)

variant_colors <- c(
  "1st Edition" = "#2c5f8a",
  "Shadowless"  = "#c9701a",
  "Unlimited"   = "#8a9bab"
)

Data structure before tidying

Every column is read as text on purpose, so R does not guess types or turn the “-” characters into anything before I decide how to handle them.

Code
csv_url <- paste0(
  "https://raw.githubusercontent.com/dillonleeper/",
  "DATA-607/main/assignments/week06/project2/pokemon/pokemon_base_set_pop_wide.csv"
)

# Try the GitHub copy first; fall back to the local copy if it can't be reached
read_raw <- function(path) {
  read_csv(
    path,
    col_types = cols(.default = col_character()),
    na        = character()   # keep "-" and blanks exactly as they are
  )
}
raw_pop <- tryCatch(read_raw(csv_url), error = function(e) read_raw("pokemon_base_set_pop_wide.csv"))

glimpse(raw_pop)
Rows: 141
Columns: 7
$ Card       <chr> "Charizard [1st Edition] #4", "Charizard #4", "Machamp [1st…
$ `Grade 6`  <chr> "953", "17,851", "1,801", "46", "53", "2,515", "642", "-", …
$ `Grade 7`  <chr> "708", "16,981", "1,410", "78", "40", "1,882", "1,001", "1"…
$ `Grade 8`  <chr> "836", "15,932", "1,332", "104", "44", "1,677", "1,982", "4…
$ `Grade 9`  <chr> "779", "8,457", "582", "74", "18", "929", "1,103", "3", "64…
$ `Grade 10` <chr> "161", "490", "50", "9", "3", "96", "455", "3", "12", "582"…
$ Total      <chr> "5,980", "99,255", "9,287", "393", "236", "14,861", "5,697"…

The raw file has 141 rows and 7 columns: Card, Grade 6, Grade 7, Grade 8, Grade 9, Grade 10, Total.

Code
raw_pop |>
  slice(c(1, 2, 8, 12)) |>
  kable(caption = "Representative raw rows (wide format)")
Representative raw rows (wide format)
Card Grade 6 Grade 7 Grade 8 Grade 9 Grade 10 Total
Charizard [1st Edition] #4 953 708 836 779 161 5,980
Charizard #4 17,851 16,981 15,932 8,457 490 99,255
Venusaur #15 - 1 4 3 3 12
Booster Box [1st Edition] - - - - - -

What makes this untidy:

  • Grade lives in the headers. “Grade 6” through “Grade 10” are values of one variable, so each row holds five separate observations.
  • One column holds three variables. “Charizard [1st Edition] #4” packs the card name, the printing, and the card number into one string.
  • Total does not match the visible columns. For unlimited Charizard, grades 6 to 10 add up to 59,711 but Total says 99,255, because grades 1 to 5 are counted in Total without having their own columns.
  • Numbers are stored as text. Commas (“17,851”) and the “-” placeholder make every count column character data.
  • Some rows are not cards at all. Booster boxes and packs have “-” in every column.

Transformation steps

Step 1: Rename columns to snake_case

Code
pop_named <- raw_pop |>
  rename_with(~ .x |> str_to_lower() |> str_replace_all("\\s+", "_"))

names(pop_named)
[1] "card"     "grade_6"  "grade_7"  "grade_8"  "grade_9"  "grade_10" "total"   

Step 2: Handle missing values and convert counts to numbers

Decision: a “-” in a count column means the report has no copies recorded at that grade. I first turn it into NA, then parse_number() strips the commas.

Code
dash_pattern <- "^[-\\u2212\\u2013]$"   # hyphen, minus sign, or en dash

pop_numeric <- pop_named |>
  mutate(across(
    grade_6:total,
    ~ parse_number(if_else(
      str_detect(str_trim(.x), dash_pattern), NA_character_, str_trim(.x)
    ))
  ))

pop_numeric |> filter(is.na(total)) |> select(card, total)
# A tibble: 8 × 2
  card                        total
  <chr>                       <dbl>
1 Booster Box [1st Edition]      NA
2 Pikachu [PokeTour 1999] #58    NA
3 Booster Pack                   NA
4 Booster Pack [1st Edition]     NA
5 Booster Box                    NA
6 Machamp [Shadowless] #8        NA
7 Machamp [1999-2000] #8         NA
8 Booster Pack [Shadowless]      NA

Decision: rows with a missing Total are sealed products or variants that have never been graded. They have no grade data to analyze, so I drop them. For the remaining cards, a missing grade count means zero graded copies at that grade, so it becomes 0.

Code
dropped_rows <- pop_numeric |> filter(is.na(total))

pop_cards <- pop_numeric |>
  filter(!is.na(total)) |>
  mutate(across(grade_6:grade_10, ~ replace_na(.x, 0)))

This removes 8 rows (Booster Box [1st Edition]; Pikachu [PokeTour 1999] #58; Booster Pack; Booster Pack [1st Edition]; Booster Box; Machamp [Shadowless] #8; Machamp [1999-2000] #8; Booster Pack [Shadowless]), leaving 133 graded cards.

Step 3: Split the card column into name, variant, and number

extract() uses a regular expression with three groups: the name, an optional bracketed variant, and the card number after “#”. A card with no bracket is the regular Unlimited printing.

Code
pop_split <- pop_cards |>
  extract(
    card,
    into   = c("card_name", "variant", "card_number"),
    regex  = "^(.*?)\\s*(?:\\[(.*)\\])?\\s*#(\\d+)$",
    remove = FALSE
  ) |>
  mutate(
    variant     = coalesce(na_if(variant, ""), "Unlimited"),
    card_number = as.integer(card_number),
    rarity      = if_else(card_number <= 16, "Holo rare", "Non-holo")
  )

# Every card must parse cleanly
stopifnot(!any(is.na(pop_split$card_name)), !any(is.na(pop_split$card_number)))

pop_split |> count(variant, sort = TRUE) |>
  kable(col.names = c("Variant", "Cards"), caption = "Printings after the split")
Printings after the split
Variant Cards
Unlimited 56
1st Edition 44
Shadowless 19
1999-2000 5
1st Edition Ghost Stamp 1
1st Edition Red Cheeks 1
Black Dot Error 1
Black Flame 1
E3 1
E3 Red Cheeks 1
Evolution Box Error 1
Shadowless Red Cheeks 1
Trainer Deck B 1

Cards numbered 1 to 16 are the Base Set holo rares, so I add a rarity column from the card number.

Step 4: Recover the hidden grades 1 to 5

Total includes grades 1 to 5, which have no columns. Instead of ignoring the mismatch, I compute the missing count so every graded copy is accounted for.

Code
pop_full <- pop_split |>
  mutate(grade_1_to_5 = total - (grade_6 + grade_7 + grade_8 + grade_9 + grade_10))

# A negative count would mean the source columns are inconsistent
stopifnot(all(pop_full$grade_1_to_5 >= 0))

pop_full |>
  filter(card == "Charizard #4") |>
  select(card, grade_6:grade_10, grade_1_to_5, total) |>
  kable(format.args = list(big.mark = ","),
        caption = "Unlimited Charizard: grades 1 to 5 explain the gap")
Unlimited Charizard: grades 1 to 5 explain the gap
card grade_6 grade_7 grade_8 grade_9 grade_10 grade_1_to_5 total
Charizard #4 17,851 16,981 15,932 8,457 490 39,544 99,255

Step 5: Pivot from wide to long

Each row becomes one card at one grade. Total is now redundant (it equals the sum of population for each card), so I check that and then leave it out.

Code
grade_levels <- c("1-5", "6", "7", "8", "9", "10")

pop_tidy <- pop_full |>
  pivot_longer(
    cols         = c(grade_1_to_5, grade_6:grade_10),
    names_to     = "grade",
    names_prefix = "grade_",
    values_to    = "population"
  ) |>
  mutate(
    grade = str_replace(grade, "_to_", "-"),
    grade = factor(grade, levels = grade_levels)
  ) |>
  select(card, card_name, variant, card_number, rarity, grade, population)

# The long data must add back up to the original totals
check <- pop_tidy |>
  group_by(card) |>
  summarise(population = sum(population)) |>
  left_join(select(pop_full, card, total), by = "card")
stopifnot(all(check$population == check$total))

head(pop_tidy, 12) |> kable(caption = "Tidy data: one row per card per grade")
Tidy data: one row per card per grade
card card_name variant card_number rarity grade population
Charizard [1st Edition] #4 Charizard 1st Edition 4 Holo rare 1-5 2543
Charizard [1st Edition] #4 Charizard 1st Edition 4 Holo rare 6 953
Charizard [1st Edition] #4 Charizard 1st Edition 4 Holo rare 7 708
Charizard [1st Edition] #4 Charizard 1st Edition 4 Holo rare 8 836
Charizard [1st Edition] #4 Charizard 1st Edition 4 Holo rare 9 779
Charizard [1st Edition] #4 Charizard 1st Edition 4 Holo rare 10 161
Charizard #4 Charizard Unlimited 4 Holo rare 1-5 39544
Charizard #4 Charizard Unlimited 4 Holo rare 6 17851
Charizard #4 Charizard Unlimited 4 Holo rare 7 16981
Charizard #4 Charizard Unlimited 4 Holo rare 8 15932
Charizard #4 Charizard Unlimited 4 Holo rare 9 8457
Charizard #4 Charizard Unlimited 4 Holo rare 10 490

The tidy table has 798 rows (133 cards × 6 grade groups) and 7 columns.

Analytical methods

All analysis below uses only pop_tidy.

  • Gem rate = grade 10 copies ÷ all graded copies of that card. It is the share of submissions that came back a perfect 10.
  • Hardest 10s ranks cards by lowest gem rate. I only rank cards with at least 500 graded copies so a card with 3 graded copies cannot top the list.
  • Printing comparison uses the cards that appear in all three main printings (1st Edition, Shadowless, Unlimited). I report both the pooled gem rate (all copies together) and the median card’s gem rate, because one huge card (unlimited Charizard) can dominate a pooled number.
  • Holo vs. non-holo compares pooled gem rates by rarity. Cards #1 to 16 are the holo rares.
  • The only randomness is the jitter in the holo chart, so set.seed(607) in the setup chunk keeps it the same on every render.
Code
gem <- pop_tidy |>
  group_by(card, card_name, variant, card_number, rarity) |>
  summarise(
    graded   = sum(population),
    grade_10 = sum(population[grade == "10"]),
    .groups  = "drop"
  ) |>
  mutate(gem_rate = grade_10 / graded)

overall_rate <- sum(gem$grade_10) / sum(gem$graded)

Across all 133 cards there are 527,473 graded copies, and 7.3% of them are 10s.

Analysis 1: Which cards are hardest to get a 10 on?

Code
hardest <- gem |>
  filter(graded >= 500) |>
  arrange(gem_rate) |>
  slice_head(n = 10)

hardest |>
  transmute(
    Card              = card,
    Rarity            = rarity,
    `Graded copies`   = comma(graded),
    `Grade 10 copies` = comma(grade_10),
    `Gem rate (%)`    = percent(gem_rate, accuracy = 0.01)
  ) |>
  kable(caption = "10 lowest gem rates (cards with 500+ graded copies)")
10 lowest gem rates (cards with 500+ graded copies)
Card Rarity Graded copies Grade 10 copies Gem rate (%)
Charizard #4 Holo rare 99,255 490 0.49%
Machamp [1st Edition] #8 Holo rare 9,287 50 0.54%
Chansey [Shadowless] #3 Holo rare 4,335 26 0.60%
Ninetales [Shadowless] #12 Holo rare 1,267 8 0.63%
Charizard [Shadowless] #4 Holo rare 14,861 96 0.65%
Blastoise [Shadowless] #2 Holo rare 8,406 59 0.70%
Alakazam [Shadowless] #1 Holo rare 5,174 37 0.72%
Raichu [Shadowless] #14 Holo rare 4,260 34 0.80%
Hitmonchan [Shadowless] #7 Holo rare 5,588 53 0.95%
Gyarados [Shadowless] #6 Holo rare 7,766 78 1.00%
Code
hardest |>
  mutate(card = reorder(card, -gem_rate)) |>
  ggplot(aes(x = gem_rate, y = card, fill = variant)) +
  geom_col() +
  scale_x_continuous(labels = percent_format(accuracy = 0.1)) +
  scale_fill_manual(values = variant_colors, drop = FALSE) +
  labs(
    title = "The hardest Base Set cards to grade a 10",
    x     = "Gem rate (share of graded copies that earned a 10)",
    y     = NULL,
    fill  = "Printing"
  ) +
  theme_p2()

Every card on this list is a holo rare. Unlimited Charizard has the lowest gem rate at 0.49%: only 490 10s out of 99,255 graded copies. Eight of the ten are Shadowless printings, a short early print run of the set. Holo foil scratches easily and the holo window shows every flaw, which fits what collectors say about these cards.

Analysis 2: Does 1st Edition grade better than Shadowless or Unlimited?

Code
main_printings <- c("1st Edition", "Shadowless", "Unlimited")

# Cards that exist in all three main printings
in_all_three <- gem |>
  filter(variant %in% main_printings) |>
  count(card_name) |>
  filter(n == 3) |>
  pull(card_name)

printing_summary <- gem |>
  filter(variant %in% main_printings, card_name %in% in_all_three) |>
  group_by(variant) |>
  summarise(
    cards           = n(),
    graded          = sum(graded),
    grade_10        = sum(grade_10),
    median_gem_rate = median(gem_rate),
    .groups         = "drop"
  ) |>
  mutate(pooled_gem_rate = grade_10 / graded) |>
  arrange(desc(pooled_gem_rate))

printing_summary |>
  transmute(
    Printing                  = variant,
    Cards                     = cards,
    `Graded copies`           = comma(graded),
    `Grade 10 copies`         = comma(grade_10),
    `Pooled gem rate (%)`     = percent(pooled_gem_rate, accuracy = 0.1),
    `Median card gem rate (%)`= percent(median_gem_rate, accuracy = 0.1)
  ) |>
  kable(caption = "Gem rate by printing (cards found in all three printings)")
Gem rate by printing (cards found in all three printings)
Printing Cards Graded copies Grade 10 copies Pooled gem rate (%) Median card gem rate (%)
1st Edition 19 72,189 4,903 6.8% 3.7%
Shadowless 19 126,007 5,976 4.7% 1.2%
Unlimited 19 163,495 4,855 3.0% 3.2%
Code
pop_tidy |>
  filter(variant %in% main_printings, card_name %in% in_all_three) |>
  group_by(variant, grade) |>
  summarise(population = sum(population), .groups = "drop") |>
  group_by(variant) |>
  mutate(share = population / sum(population)) |>
  ungroup() |>
  ggplot(aes(x = grade, y = share, fill = variant)) +
  geom_col(position = "dodge") +
  scale_y_continuous(labels = percent_format(accuracy = 1)) +
  scale_fill_manual(values = variant_colors) +
  labs(
    title = "Grade distribution by printing",
    subtitle = paste(length(in_all_three), "cards found in all three printings"),
    x     = "Grade",
    y     = "Share of graded copies",
    fill  = "Printing"
  ) +
  theme_p2()

Code
first_ed <- filter(printing_summary, variant == "1st Edition")
shadow   <- filter(printing_summary, variant == "Shadowless")
unlim    <- filter(printing_summary, variant == "Unlimited")

For the 19 cards that exist in all three printings, 1st Edition has the highest pooled gem rate at 6.8%, compared with 4.7% for Shadowless and 3.0% for Unlimited. The median card tells the same story for 1st Edition (3.7%), but Shadowless has the lowest median (1.2%). Unlimited’s pooled rate is pulled down mostly by unlimited Charizard, which alone has 99,255 graded copies.

One reason 1st Edition may grade better: owners of the most valuable printing tend to submit only their best-looking copies, so the submissions themselves are pre-screened. The data cannot separate print quality from that selection.

Analysis 3: Holo rares vs. non-holo cards

Code
rarity_summary <- gem |>
  group_by(rarity) |>
  summarise(
    cards    = n(),
    graded   = sum(graded),
    grade_10 = sum(grade_10),
    .groups  = "drop"
  ) |>
  mutate(gem_rate = grade_10 / graded)

rarity_summary |>
  transmute(
    Rarity            = rarity,
    Cards             = cards,
    `Graded copies`   = comma(graded),
    `Grade 10 copies` = comma(grade_10),
    `Gem rate (%)`    = percent(gem_rate, accuracy = 0.1)
  ) |>
  kable(caption = "Gem rate by rarity")
Gem rate by rarity
Rarity Cards Graded copies Grade 10 copies Gem rate (%)
Holo rare 51 290,479 4,233 1.5%
Non-holo 82 236,994 34,446 14.5%
Code
gem |>
  filter(graded >= 500) |>
  ggplot(aes(x = rarity, y = gem_rate, color = rarity)) +
  geom_boxplot(outlier.shape = NA, width = 0.5) +
  geom_jitter(width = 0.15, alpha = 0.6) +
  scale_y_continuous(labels = percent_format(accuracy = 1)) +
  scale_color_manual(values = c("Holo rare" = "#2c5f8a", "Non-holo" = "#8a9bab")) +
  labs(
    title = "Holo rares rarely come back a 10",
    x     = NULL,
    y     = "Gem rate per card",
    color = "Rarity"
  ) +
  theme_p2()

Code
holo     <- filter(rarity_summary, rarity == "Holo rare")
non_holo <- filter(rarity_summary, rarity == "Non-holo")

Holo rares gem at 1.5%, while non-holo cards gem at 14.5%, about 10 times higher. This is the largest difference in the project and explains why the hardest-10 list is all holos.

Conclusions

  • Only 7.3% of the 527,473 graded Base Set copies in this file are 10s.
  • Rarity matters more than printing: holo rares gem at 1.5% versus 14.5% for non-holos.
  • Unlimited Charizard is the hardest 10 in the set at 0.49%, which helps explain why a PSA 10 commands such a premium.
  • 1st Edition copies earn 10s more often than Shadowless or Unlimited copies of the same cards (6.8% pooled), though submission bias likely plays a role.

Limitations. PSA and CGC counts are combined, so the two companies’ standards cannot be compared. Half grades are folded into whole grades. Some Unlimited holo counts look very low next to their Shadowless counterparts (for example Venusaur), which suggests the source may file many graded copies under a different printing label. The CSV covers the first 141 rows of the report rather than all 466 items. Sale prices were not part of this table, so the question of how scarcity at grade 10 drives price is left for future work.

AI Disclosure and Citation

Claude (Opus 5.5, accessed through claude.ai) helped with this dataset, including copying the population report into the CSV, and rendering the document to check the results.

Anthropic. (2026). Claude (Opus 5.5) [Large language model]. https://claude.ai/. Accessed October 8 and 11, 2026.