From 44ded1b5075594807c25e35239e8aa338aa3e4ef Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Wed, 22 Jul 2026 10:28:40 -0400 Subject: [PATCH 01/11] =?UTF-8?q?Add=20reusable=20Pok=C3=A9mon=20data=20pr?= =?UTF-8?q?eparation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../prepare_data.R | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 pokemon-dimensionality-reduction/prepare_data.R diff --git a/pokemon-dimensionality-reduction/prepare_data.R b/pokemon-dimensionality-reduction/prepare_data.R new file mode 100644 index 0000000..0d0b27c --- /dev/null +++ b/pokemon-dimensionality-reduction/prepare_data.R @@ -0,0 +1,158 @@ +# Pokémon data ------------------------------------------------------------- + +pokemon_type_colors <- c( + normal = "#A8A77A", fire = "#EE8130", water = "#6390F0", + electric = "#F7D02C", grass = "#7AC74C", ice = "#96D9D6", + fighting = "#C22E28", poison = "#A33EA1", ground = "#E2BF65", + flying = "#A98FF3", psychic = "#F95587", bug = "#A6B91A", + rock = "#B6A136", ghost = "#735797", dragon = "#6F35FC", + dark = "#705746", steel = "#B7B7CE", fairy = "#D685AD" +) + +pokeapi_csv <- function(file, ref = "master") { + url <- paste0( + "https://raw.githubusercontent.com/PokeAPI/pokeapi/", + ref, + "/data/v2/csv/", + file + ) + + readr::read_csv(url, show_col_types = FALSE) +} + +pokemon_feature_matrix <- function(data) { + numeric_features <- data |> + dplyr::select( + height, weight, base_experience, + hp, attack, defense, special_attack, special_defense, speed + ) |> + dplyr::mutate( + dplyr::across( + dplyr::everything(), + ~ tidyr::replace_na(as.numeric(.x), stats::median(.x, na.rm = TRUE)) + ) + ) |> + scale() + + categorical_data <- data |> + dplyr::transmute( + type_1 = factor(type_1), + type_2 = factor(type_2), + egg_group_1 = factor(egg_group_1), + egg_group_2 = factor(egg_group_2) + ) + + categorical_features <- stats::model.matrix( + ~ type_1 + type_2 + egg_group_1 + egg_group_2 - 1, + data = categorical_data + ) + + features <- cbind(numeric_features, categorical_features) + storage.mode(features) <- "double" + features +} + +prepare_pokemon_data <- function(cache_file = NULL, refresh = FALSE) { + if ( + !is.null(cache_file) && + file.exists(cache_file) && + !isTRUE(refresh) + ) { + return(readRDS(cache_file)) + } + + pokemon <- pokeapi_csv("pokemon.csv") |> + dplyr::filter(is_default == 1) |> + dplyr::transmute( + id, + species_id, + pokemon = identifier, + height, + weight, + base_experience + ) + + stats <- pokeapi_csv("stats.csv") |> + dplyr::transmute( + stat_id = id, + stat = stringr::str_replace_all(identifier, "-", "_") + ) |> + dplyr::right_join( + pokeapi_csv("pokemon_stats.csv") |> + dplyr::select(pokemon_id, stat_id, base_stat), + by = "stat_id" + ) |> + dplyr::select(pokemon_id, stat, base_stat) |> + tidyr::pivot_wider(names_from = stat, values_from = base_stat) |> + dplyr::rename(id = pokemon_id) + + types <- pokeapi_csv("types.csv") |> + dplyr::transmute(type_id = id, type = identifier) |> + dplyr::right_join( + pokeapi_csv("pokemon_types.csv") |> + dplyr::select(pokemon_id, type_id, slot), + by = "type_id" + ) |> + dplyr::mutate(slot = paste0("type_", slot)) |> + dplyr::select(pokemon_id, slot, type) |> + tidyr::pivot_wider(names_from = slot, values_from = type) |> + dplyr::rename(id = pokemon_id) + + egg_groups <- pokeapi_csv("egg_groups.csv") |> + dplyr::transmute(egg_group_id = id, egg_group = identifier) |> + dplyr::right_join( + pokeapi_csv("pokemon_egg_groups.csv") |> + dplyr::select(species_id, egg_group_id), + by = "egg_group_id" + ) |> + dplyr::group_by(species_id) |> + dplyr::mutate(slot = paste0("egg_group_", dplyr::row_number())) |> + dplyr::ungroup() |> + dplyr::select(species_id, slot, egg_group) |> + tidyr::pivot_wider(names_from = slot, values_from = egg_group) + + generations <- pokeapi_csv("generations.csv") |> + dplyr::transmute(generation_id = id, generation = identifier) + + species <- pokeapi_csv("pokemon_species.csv") |> + dplyr::select(id, generation_id) |> + dplyr::rename(species_id = id) |> + dplyr::left_join(generations, by = "generation_id") + + data <- pokemon |> + dplyr::left_join(types, by = "id") |> + dplyr::left_join(stats, by = "id") |> + dplyr::left_join(egg_groups, by = "species_id") |> + dplyr::left_join(species, by = "species_id") |> + dplyr::mutate( + type_2 = tidyr::replace_na(type_2, "none"), + egg_group_1 = tidyr::replace_na(egg_group_1, "none"), + egg_group_2 = tidyr::replace_na(egg_group_2, "none"), + generation = stringr::str_to_upper(generation), + type_color = unname(pokemon_type_colors[type_1]), + sprite_url = paste0( + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/", + id, + ".png" + ), + artwork_url = paste0( + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/", + id, + ".png" + ) + ) |> + dplyr::arrange(id) + + result <- list( + data = data, + x = pokemon_feature_matrix(data), + source = "PokeAPI/pokeapi + PokeAPI/sprites" + ) + + if (!is.null(cache_file)) { + dir.create(dirname(cache_file), recursive = TRUE, showWarnings = FALSE) + try(saveRDS(result, cache_file), silent = TRUE) + } + + result +} From 1ab14c36e5f58c0874eb4529fdf9a00e9bc36ffe Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Wed, 22 Jul 2026 10:29:44 -0400 Subject: [PATCH 02/11] =?UTF-8?q?Add=20Pok=C3=A9mon=20dimensionality=20red?= =?UTF-8?q?uction=20Shiny=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pokemon-dimensionality-reduction/app.R | 363 +++++++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 pokemon-dimensionality-reduction/app.R diff --git a/pokemon-dimensionality-reduction/app.R b/pokemon-dimensionality-reduction/app.R new file mode 100644 index 0000000..e3402f5 --- /dev/null +++ b/pokemon-dimensionality-reduction/app.R @@ -0,0 +1,363 @@ +# packages ---------------------------------------------------------------- +library(shiny) +library(bslib) +library(dplyr) +library(purrr) +library(highcharter) +library(Rtsne) +library(uwot) + +# data -------------------------------------------------------------------- +source("prepare_data.R", local = TRUE) + +pokemon_bundle <- prepare_pokemon_data( + cache_file = file.path(tempdir(), "visual-data-lab-pokemon.rds") +) + +pokemon <- pokemon_bundle$data +pokemon_x <- pokemon_bundle$x + +# theme ------------------------------------------------------------------- +apptheme <- bs_theme( + version = 5, + bg = "#f5f7fb", + fg = "#172033", + primary = "#2a75bb", + secondary = "#ffcb05" +) + +sidebar <- purrr::partial(bslib::sidebar, width = 305) +card <- purrr::partial(bslib::card, full_screen = TRUE) + +# helpers ----------------------------------------------------------------- +method_label <- function(method) { + switch( + method, + pca = "PCA", + tsne = "t-SNE", + umap = "UMAP", + method + ) +} + +run_projection <- function( + method, + x, + perplexity, + iterations, + n_neighbors, + min_dist, + seed +) { + set.seed(as.integer(seed)) + + if (identical(method, "pca")) { + fit <- stats::prcomp(x, center = TRUE, scale. = FALSE) + return(fit$x[, 1:2, drop = FALSE]) + } + + if (identical(method, "tsne")) { + max_perplexity <- max(2, floor((nrow(x) - 1) / 3) - 1) + perplexity <- min(as.numeric(perplexity), max_perplexity) + + fit <- Rtsne::Rtsne( + x, + dims = 2, + perplexity = perplexity, + max_iter = as.integer(iterations), + check_duplicates = FALSE, + pca = TRUE, + verbose = FALSE + ) + + return(fit$Y) + } + + uwot::umap( + x, + n_components = 2, + n_neighbors = min(as.integer(n_neighbors), nrow(x) - 1L), + min_dist = as.numeric(min_dist), + metric = "euclidean", + n_threads = 1, + verbose = FALSE + ) +} + +make_points <- function(data, xy) { + data |> + mutate( + x = xy[, 1], + y = xy[, 2], + pokemon_label = stringr::str_to_title(stringr::str_replace_all(pokemon, "-", " ")), + type_1_label = stringr::str_to_title(type_1), + type_2_label = if_else(type_2 == "none", "—", stringr::str_to_title(type_2)), + generation_label = stringr::str_replace(generation, "GENERATION-", "Gen "), + height_m = round(height / 10, 1), + weight_kg = round(weight / 10, 1) + ) |> + select( + x, y, pokemon_label, type_1_label, type_2_label, + generation_label, type_color, height_m, weight_kg, + hp, attack, defense, special_attack, special_defense, speed, + sprite_url, artwork_url + ) |> + purrr::pmap(function( + x, y, pokemon_label, type_1_label, type_2_label, + generation_label, type_color, height_m, weight_kg, + hp, attack, defense, special_attack, special_defense, speed, + sprite_url, artwork_url + ) { + list( + x = x, + y = y, + name = pokemon_label, + pokemon = pokemon_label, + type_1 = type_1_label, + type_2 = type_2_label, + generation = generation_label, + type_color = type_color, + height_m = height_m, + weight_kg = weight_kg, + hp = hp, + attack = attack, + defense = defense, + special_attack = special_attack, + special_defense = special_defense, + speed = speed, + artwork_url = artwork_url, + color = type_color, + marker = list( + symbol = sprintf("url(%s)", sprite_url), + width = 26, + height = 26 + ) + ) + }) +} + +tooltip_html <- paste0( + '
', + '
', + '', + '
', + '
{point.pokemon}
', + '
{point.generation}
', + '
', + '{point.type_1}', + '{point.type_2}', + '
', + '
{point.height_m} m · {point.weight_kg} kg
', + '
', + '', + '', + '', + '', + '
HP{point.hp}Attack{point.attack}
Defense{point.defense}Speed{point.speed}
Sp. Atk{point.special_attack}Sp. Def{point.special_defense}
', + '
' +) + +# ui ---------------------------------------------------------------------- +ui <- page_fillable( + title = "Pokémon Dimensionality Reduction", + theme = apptheme, + padding = 0, + tags$head(htmltools::includeCSS("styles.css")), + div( + class = "pokemon-topbar", + div( + class = "pokemon-brand", + span(class = "pokeball-mark"), + div( + div(class = "pokemon-brand-title", "Pokémon Lab"), + div(class = "pokemon-brand-subtitle", "Dimensionality Reduction") + ) + ), + div(class = "pokemon-topbar-caption", "Gotta project 'em all!") + ), + layout_sidebar( + fill = TRUE, + sidebar = sidebar( + title = "Projection", + selectInput( + "method", + "Method", + choices = c("PCA" = "pca", "t-SNE" = "tsne", "UMAP" = "umap"), + selected = "tsne" + ), + conditionalPanel( + "input.method === 'tsne'", + sliderInput( + "perplexity", + "Perplexity", + min = 5, + max = 100, + value = 40, + step = 5 + ), + sliderInput( + "iterations", + "Iterations", + min = 250, + max = 2000, + value = 750, + step = 250 + ) + ), + conditionalPanel( + "input.method === 'umap'", + sliderInput( + "n_neighbors", + "Neighbors", + min = 2, + max = 100, + value = 30, + step = 1 + ), + sliderInput( + "min_dist", + "Minimum distance", + min = 0, + max = 0.95, + value = 0.15, + step = 0.05 + ) + ), + numericInput("seed", "Seed", value = 13242, min = 1, step = 1), + actionButton( + "run", + "Run projection", + class = "btn btn-primary pokemon-run-button" + ), + div( + class = "pokemon-sidebar-note", + strong(format(nrow(pokemon), big.mark = ",")), + " Pokémon · stats + types + egg groups" + ), + accordion( + open = FALSE, + accordion_panel( + "How it works", + tags$small(htmltools::includeMarkdown("readme.md")) + ) + ), + tags$small(htmltools::includeMarkdown("credits.md")) + ), + card( + class = "pokemon-card", + card_header( + div( + class = "pokemon-chart-header", + div( + div(class = "pokemon-chart-kicker", "Projection map"), + div(class = "pokemon-chart-title", textOutput("chart_title", inline = TRUE)) + ), + div(class = "pokemon-chart-meta", textOutput("chart_meta", inline = TRUE)) + ) + ), + card_body( + class = "pokemon-chart-body", + highchartOutput("embedding", height = "calc(100vh - 154px)") + ) + ) + ) +) + +# server ------------------------------------------------------------------ +server <- function(input, output, session) { + projection <- eventReactive( + input$run, + { + withProgress(message = paste("Running", method_label(input$method)), value = 0.2, { + xy <- run_projection( + method = input$method, + x = pokemon_x, + perplexity = input$perplexity, + iterations = input$iterations, + n_neighbors = input$n_neighbors, + min_dist = input$min_dist, + seed = input$seed + ) + incProgress(0.8) + xy + }) + }, + ignoreNULL = FALSE + ) + + output$chart_title <- renderText({ + paste(method_label(input$method), "Pokémon map") + }) + + output$chart_meta <- renderText({ + if (identical(input$method, "pca")) { + return("Linear baseline") + } + + if (identical(input$method, "tsne")) { + return(paste0("Perplexity ", input$perplexity, " · ", input$iterations, " iterations")) + } + + paste0("", input$n_neighbors, " neighbors · min dist ", input$min_dist) + }) + + output$embedding <- renderHighchart({ + xy <- projection() + points <- make_points(pokemon, xy) + + highchart() |> + hc_chart( + type = "scatter", + zooming = list(type = "xy"), + backgroundColor = "transparent", + animation = FALSE, + spacing = list(18, 18, 18, 18) + ) |> + hc_title(text = NULL) |> + hc_xAxis( + title = list(text = NULL), + labels = list(enabled = FALSE), + tickLength = 0, + gridLineWidth = 0, + lineWidth = 0 + ) |> + hc_yAxis( + title = list(text = NULL), + labels = list(enabled = FALSE), + tickLength = 0, + gridLineWidth = 0, + lineWidth = 0 + ) |> + hc_add_series( + data = points, + name = "Pokémon", + turboThreshold = 0, + showInLegend = FALSE + ) |> + hc_tooltip( + useHTML = TRUE, + outside = TRUE, + borderWidth = 0, + borderRadius = 14, + shadow = TRUE, + padding = 0, + headerFormat = "", + pointFormat = tooltip_html + ) |> + hc_plotOptions( + series = list( + animation = FALSE, + cursor = "pointer", + states = list( + hover = list( + halo = list(size = 34, opacity = 0.24), + brightness = 0.08 + ) + ) + ) + ) |> + hc_credits(enabled = FALSE) + }) +} + +shinyApp(ui, server) From 905e396b519c1be273ea5109ebb17f3f08a67176 Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Wed, 22 Jul 2026 10:30:14 -0400 Subject: [PATCH 03/11] =?UTF-8?q?Style=20Pok=C3=A9mon=20app=20like=20the?= =?UTF-8?q?=20original=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pokemon-dimensionality-reduction/styles.css | 293 ++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 pokemon-dimensionality-reduction/styles.css diff --git a/pokemon-dimensionality-reduction/styles.css b/pokemon-dimensionality-reduction/styles.css new file mode 100644 index 0000000..acf07a6 --- /dev/null +++ b/pokemon-dimensionality-reduction/styles.css @@ -0,0 +1,293 @@ +:root { + --pokemon-blue: #2a75bb; + --pokemon-blue-dark: #1d4f91; + --pokemon-yellow: #ffcb05; + --pokemon-red: #ef5350; + --pokemon-ink: #172033; + --pokemon-muted: #6b7280; + --pokemon-card: #ffffff; +} + +html, +body { + background: #eef2f8; +} + +.pokemon-topbar { + min-height: 66px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 10px 22px; + color: #ffffff; + background: linear-gradient(115deg, var(--pokemon-blue-dark), var(--pokemon-blue)); + border-bottom: 5px solid var(--pokemon-yellow); + box-shadow: 0 2px 12px rgba(23, 32, 51, 0.18); + z-index: 2; +} + +.pokemon-brand { + display: flex; + align-items: center; + gap: 12px; +} + +.pokeball-mark { + position: relative; + display: inline-block; + width: 38px; + height: 38px; + flex: 0 0 38px; + border: 3px solid #172033; + border-radius: 50%; + background: linear-gradient( + to bottom, + var(--pokemon-red) 0%, + var(--pokemon-red) 44%, + #172033 44%, + #172033 56%, + #ffffff 56%, + #ffffff 100% + ); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.22); +} + +.pokeball-mark::after { + content: ""; + position: absolute; + width: 11px; + height: 11px; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + border: 3px solid #172033; + border-radius: 50%; + background: #ffffff; +} + +.pokemon-brand-title { + font-size: 1.1rem; + font-weight: 800; + letter-spacing: 0.02em; +} + +.pokemon-brand-subtitle { + margin-top: -1px; + color: rgba(255, 255, 255, 0.75); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.09em; +} + +.pokemon-topbar-caption { + color: var(--pokemon-yellow); + font-size: 0.9rem; + font-weight: 700; +} + +.bslib-sidebar-layout > .sidebar { + background: #ffffff; + border-right: 1px solid #dce3ee; +} + +.bslib-sidebar-layout > .main { + padding: 14px !important; + background: #eef2f8; +} + +.pokemon-run-button { + width: 100%; + margin: 4px 0 12px; + border: 0; + color: #ffffff; + font-weight: 700; + box-shadow: 0 4px 10px rgba(42, 117, 187, 0.22); +} + +.pokemon-run-button:hover, +.pokemon-run-button:focus { + background: var(--pokemon-blue-dark); +} + +.pokemon-sidebar-note { + margin: 3px 0 14px; + padding: 9px 10px; + border-left: 4px solid var(--pokemon-yellow); + border-radius: 5px; + background: #fff9d9; + color: #4a4a3f; + font-size: 0.78rem; + line-height: 1.35; +} + +.pokemon-card { + height: 100%; + border: 0 !important; + border-radius: 14px !important; + overflow: hidden; + background: var(--pokemon-card); + box-shadow: 0 8px 28px rgba(25, 44, 78, 0.12); +} + +.pokemon-card .card-header { + padding: 12px 17px; + background: #ffffff; + border-bottom: 1px solid #edf0f5; +} + +.pokemon-chart-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; +} + +.pokemon-chart-kicker { + color: var(--pokemon-blue); + font-size: 0.7rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.pokemon-chart-title { + margin-top: 1px; + color: var(--pokemon-ink); + font-size: 1.13rem; + font-weight: 800; +} + +.pokemon-chart-meta { + padding: 5px 9px; + color: #7a6510; + background: #fff5bd; + border: 1px solid #f9e680; + border-radius: 999px; + font-size: 0.76rem; + white-space: nowrap; +} + +.pokemon-chart-body { + padding: 0 !important; + background: + radial-gradient(circle at 20% 15%, rgba(42, 117, 187, 0.045), transparent 28%), + radial-gradient(circle at 80% 80%, rgba(255, 203, 5, 0.08), transparent 30%), + #ffffff; +} + +.highcharts-container, +.highcharts-root { + overflow: visible !important; +} + +.pokemon-tooltip { + width: 285px; + padding: 13px; + color: var(--pokemon-ink); + background: #ffffff; + border-top: 5px solid var(--pokemon-yellow); + border-radius: 13px; + box-shadow: 0 14px 35px rgba(23, 32, 51, 0.22); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +.pokemon-tooltip-top { + display: flex; + align-items: center; + gap: 12px; +} + +.pokemon-artwork { + width: 104px; + height: 104px; + flex: 0 0 104px; + object-fit: contain; +} + +.pokemon-tooltip-title { + min-width: 0; +} + +.pokemon-name { + color: var(--pokemon-ink); + font-size: 1.08rem; + font-weight: 800; + line-height: 1.15; +} + +.pokemon-generation { + margin: 2px 0 7px; + color: var(--pokemon-muted); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.pokemon-types { + display: flex; + align-items: center; + gap: 5px; + margin-bottom: 7px; +} + +.pokemon-type, +.pokemon-type-secondary { + display: inline-block; + padding: 2px 7px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; +} + +.pokemon-type { + color: #ffffff; +} + +.pokemon-type-secondary { + color: #4b5563; + background: #edf0f5; +} + +.pokemon-size { + color: #4b5563; + font-size: 0.76rem; +} + +.pokemon-stats { + width: 100%; + margin-top: 10px; + border-collapse: collapse; + font-size: 0.72rem; +} + +.pokemon-stats td { + padding: 4px 5px; + border-top: 1px solid #edf0f5; +} + +.pokemon-stats td:nth-child(odd) { + color: #6b7280; +} + +.pokemon-stats td:nth-child(even) { + text-align: right; + color: #172033; +} + +@media (max-width: 760px) { + .pokemon-topbar-caption { + display: none; + } + + .pokemon-topbar { + padding-left: 14px; + padding-right: 14px; + } + + .pokemon-chart-meta { + display: none; + } +} From b8f685d8761683d2c8d5293264b0419860398f12 Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Wed, 22 Jul 2026 10:30:22 -0400 Subject: [PATCH 04/11] =?UTF-8?q?Add=20Pok=C3=A9mon=20app=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pokemon-dimensionality-reduction/DESCRIPTION | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 pokemon-dimensionality-reduction/DESCRIPTION diff --git a/pokemon-dimensionality-reduction/DESCRIPTION b/pokemon-dimensionality-reduction/DESCRIPTION new file mode 100644 index 0000000..e82bc0c --- /dev/null +++ b/pokemon-dimensionality-reduction/DESCRIPTION @@ -0,0 +1,4 @@ +Title: Pokémon Dimensionality Reduction +Description: Explore how PCA, t-SNE, and UMAP arrange Pokémon using stats, types, and egg groups. +Categories: machine learning, visualization, dimensionality reduction +Runtime: server From c1bb8013af72953e10d3629cbf333d9a90ed229f Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Wed, 22 Jul 2026 10:30:33 -0400 Subject: [PATCH 05/11] =?UTF-8?q?Document=20Pok=C3=A9mon=20dimensionality?= =?UTF-8?q?=20reduction=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pokemon-dimensionality-reduction/readme.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 pokemon-dimensionality-reduction/readme.md diff --git a/pokemon-dimensionality-reduction/readme.md b/pokemon-dimensionality-reduction/readme.md new file mode 100644 index 0000000..17f16bc --- /dev/null +++ b/pokemon-dimensionality-reduction/readme.md @@ -0,0 +1,18 @@ +## How it works + +The app builds a feature matrix from the maintained PokeAPI tables. Numeric features include height, weight, base experience, and the six battle stats. Pokémon types and egg groups are added as indicator variables. + +Choose a projection method and change its main parameters: + +- **PCA** gives a fast linear baseline. +- **t-SNE** exposes perplexity and iteration count. +- **UMAP** exposes neighborhood size and minimum distance. + +Click **Run projection** to recompute the two-dimensional coordinates. The chart uses the regular PokeAPI sprite as the point marker. Hover a Pokémon to see its official artwork, types, generation, size, and battle stats. + +The coordinates are exploratory rather than a distance metric to interpret literally. They are useful for spotting groups, evolution families, unusual type combinations, and differences between projection methods. + +### Data + +Data: [PokeAPI/pokeapi](https://github.com/PokeAPI/pokeapi) +Sprites and artwork: [PokeAPI/sprites](https://github.com/PokeAPI/sprites) From d4f01705f668946397bc7937144dd69d6ac112c4 Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Wed, 22 Jul 2026 10:30:40 -0400 Subject: [PATCH 06/11] =?UTF-8?q?Add=20Pok=C3=A9mon=20app=20credits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pokemon-dimensionality-reduction/credits.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 pokemon-dimensionality-reduction/credits.md diff --git a/pokemon-dimensionality-reduction/credits.md b/pokemon-dimensionality-reduction/credits.md new file mode 100644 index 0000000..d2e18a3 --- /dev/null +++ b/pokemon-dimensionality-reduction/credits.md @@ -0,0 +1 @@ +App made by [Joshua Kunst](https://jkunst.com) with ❤️ and ☕ using Shiny for R ✨. Code [here](https://github.com/jbkunst/visual-data-lab). From 2028a80d36a68b1e295c0df26c188f1e57ea2bdd Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Wed, 22 Jul 2026 10:44:42 -0400 Subject: [PATCH 07/11] =?UTF-8?q?Enrich=20Pok=C3=A9mon=20species=20feature?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../prepare_data.R | 76 ++++++++++++++++--- 1 file changed, 64 insertions(+), 12 deletions(-) diff --git a/pokemon-dimensionality-reduction/prepare_data.R b/pokemon-dimensionality-reduction/prepare_data.R index 0d0b27c..d22f15c 100644 --- a/pokemon-dimensionality-reduction/prepare_data.R +++ b/pokemon-dimensionality-reduction/prepare_data.R @@ -20,30 +20,56 @@ pokeapi_csv <- function(file, ref = "master") { readr::read_csv(url, show_col_types = FALSE) } +scale_numeric_features <- function(data) { + data <- as.data.frame(data) + + data[] <- lapply(data, function(x) { + x <- as.numeric(x) + replacement <- stats::median(x, na.rm = TRUE) + + if (!is.finite(replacement)) { + replacement <- 0 + } + + x[is.na(x)] <- replacement + x + }) + + x <- as.matrix(data) + center <- colMeans(x) + spread <- apply(x, 2, stats::sd) + spread[!is.finite(spread) | spread == 0] <- 1 + + x <- sweep(x, 2, center, FUN = "-") + sweep(x, 2, spread, FUN = "/") +} + pokemon_feature_matrix <- function(data) { numeric_features <- data |> dplyr::select( height, weight, base_experience, - hp, attack, defense, special_attack, special_defense, speed - ) |> - dplyr::mutate( - dplyr::across( - dplyr::everything(), - ~ tidyr::replace_na(as.numeric(.x), stats::median(.x, na.rm = TRUE)) - ) + hp, attack, defense, special_attack, special_defense, speed, + capture_rate, base_happiness, hatch_counter, gender_rate, + is_baby, is_legendary, is_mythical, + has_gender_differences, forms_switchable ) |> - scale() + scale_numeric_features() categorical_data <- data |> dplyr::transmute( type_1 = factor(type_1), type_2 = factor(type_2), egg_group_1 = factor(egg_group_1), - egg_group_2 = factor(egg_group_2) + egg_group_2 = factor(egg_group_2), + growth_rate = factor(growth_rate), + body_color = factor(body_color), + body_shape = factor(body_shape), + habitat = factor(habitat) ) categorical_features <- stats::model.matrix( - ~ type_1 + type_2 + egg_group_1 + egg_group_2 - 1, + ~ type_1 + type_2 + egg_group_1 + egg_group_2 + + growth_rate + body_color + body_shape + habitat - 1, data = categorical_data ) @@ -114,10 +140,31 @@ prepare_pokemon_data <- function(cache_file = NULL, refresh = FALSE) { generations <- pokeapi_csv("generations.csv") |> dplyr::transmute(generation_id = id, generation = identifier) + growth_rates <- pokeapi_csv("growth_rates.csv") |> + dplyr::transmute(growth_rate_id = id, growth_rate = identifier) + + colors <- pokeapi_csv("pokemon_colors.csv") |> + dplyr::transmute(color_id = id, body_color = identifier) + + shapes <- pokeapi_csv("pokemon_shapes.csv") |> + dplyr::transmute(shape_id = id, body_shape = identifier) + + habitats <- pokeapi_csv("pokemon_habitats.csv") |> + dplyr::transmute(habitat_id = id, habitat = identifier) + species <- pokeapi_csv("pokemon_species.csv") |> - dplyr::select(id, generation_id) |> + dplyr::select( + id, generation_id, evolves_from_species_id, evolution_chain_id, + color_id, shape_id, habitat_id, gender_rate, capture_rate, + base_happiness, is_baby, hatch_counter, has_gender_differences, + growth_rate_id, forms_switchable, is_legendary, is_mythical + ) |> dplyr::rename(species_id = id) |> - dplyr::left_join(generations, by = "generation_id") + dplyr::left_join(generations, by = "generation_id") |> + dplyr::left_join(growth_rates, by = "growth_rate_id") |> + dplyr::left_join(colors, by = "color_id") |> + dplyr::left_join(shapes, by = "shape_id") |> + dplyr::left_join(habitats, by = "habitat_id") data <- pokemon |> dplyr::left_join(types, by = "id") |> @@ -128,6 +175,10 @@ prepare_pokemon_data <- function(cache_file = NULL, refresh = FALSE) { type_2 = tidyr::replace_na(type_2, "none"), egg_group_1 = tidyr::replace_na(egg_group_1, "none"), egg_group_2 = tidyr::replace_na(egg_group_2, "none"), + growth_rate = tidyr::replace_na(growth_rate, "unknown"), + body_color = tidyr::replace_na(body_color, "unknown"), + body_shape = tidyr::replace_na(body_shape, "unknown"), + habitat = tidyr::replace_na(habitat, "unknown"), generation = stringr::str_to_upper(generation), type_color = unname(pokemon_type_colors[type_1]), sprite_url = paste0( @@ -146,6 +197,7 @@ prepare_pokemon_data <- function(cache_file = NULL, refresh = FALSE) { result <- list( data = data, x = pokemon_feature_matrix(data), + feature_names = colnames(pokemon_feature_matrix(data)), source = "PokeAPI/pokeapi + PokeAPI/sprites" ) From bc8b8f95f6cafbf6dfa6cf3a500b05d4b4a4acb7 Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Wed, 22 Jul 2026 10:45:07 -0400 Subject: [PATCH 08/11] =?UTF-8?q?Document=20richer=20Pok=C3=A9mon=20specie?= =?UTF-8?q?s=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pokemon-dimensionality-reduction/readme.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pokemon-dimensionality-reduction/readme.md b/pokemon-dimensionality-reduction/readme.md index 17f16bc..8be0beb 100644 --- a/pokemon-dimensionality-reduction/readme.md +++ b/pokemon-dimensionality-reduction/readme.md @@ -1,6 +1,6 @@ ## How it works -The app builds a feature matrix from the maintained PokeAPI tables. Numeric features include height, weight, base experience, and the six battle stats. Pokémon types and egg groups are added as indicator variables. +The app builds a feature matrix from the maintained PokeAPI tables. Numeric features include height, weight, base experience, the six battle stats, capture rate, base happiness, hatch counter, gender rate, and species flags such as baby, legendary, and mythical. Pokémon types, egg groups, growth rate, body color, body shape, and habitat are added as indicator variables. Choose a projection method and change its main parameters: @@ -10,9 +10,11 @@ Choose a projection method and change its main parameters: Click **Run projection** to recompute the two-dimensional coordinates. The chart uses the regular PokeAPI sprite as the point marker. Hover a Pokémon to see its official artwork, types, generation, size, and battle stats. -The coordinates are exploratory rather than a distance metric to interpret literally. They are useful for spotting groups, evolution families, unusual type combinations, and differences between projection methods. +The coordinates are exploratory rather than a distance metric to interpret literally. They are useful for spotting groups, evolution families, unusual type combinations, differences in capture difficulty and species traits, and differences between projection methods. ### Data +There is no invented rarity score. The source data provides **capture rate** directly, plus explicit **legendary** and **mythical** flags that can be used to explore rarity-like structure without imposing an arbitrary classification. + Data: [PokeAPI/pokeapi](https://github.com/PokeAPI/pokeapi) Sprites and artwork: [PokeAPI/sprites](https://github.com/PokeAPI/sprites) From bf89b0c64c999b3c03873f2427584d192e72d329 Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Wed, 22 Jul 2026 10:59:34 -0400 Subject: [PATCH 09/11] Use mixed-data feature preprocessing --- .../prepare_data.R | 133 +++++++++++++++--- 1 file changed, 115 insertions(+), 18 deletions(-) diff --git a/pokemon-dimensionality-reduction/prepare_data.R b/pokemon-dimensionality-reduction/prepare_data.R index d22f15c..85653bc 100644 --- a/pokemon-dimensionality-reduction/prepare_data.R +++ b/pokemon-dimensionality-reduction/prepare_data.R @@ -20,7 +20,11 @@ pokeapi_csv <- function(file, ref = "master") { readr::read_csv(url, show_col_types = FALSE) } -scale_numeric_features <- function(data) { +# Continuous variables live on very different scales (kg, stats, capture rate, +# etc.). Median-impute missing values, then standardize only these variables. +# Binary indicators are deliberately NOT standardized: a rare 1 should not +# acquire a huge z-score just because its prevalence is low. +scale_continuous_features <- function(data) { data <- as.data.frame(data) data[] <- lapply(data, function(x) { @@ -44,37 +48,123 @@ scale_numeric_features <- function(data) { sweep(x, 2, spread, FUN = "/") } -pokemon_feature_matrix <- function(data) { - numeric_features <- data |> +# Equalizing by sqrt(number of columns) stops a block from dominating the +# Euclidean distance merely because it expands into many dummy columns. +# The named weights are intentionally explicit so they are easy to review or +# tune later. A weight of 1 is the neutral starting point for every block. +weight_feature_block <- function(x, weight = 1) { + x <- as.matrix(x) + + if (!ncol(x)) { + return(x) + } + + x * (as.numeric(weight) / sqrt(ncol(x))) +} + +pokemon_feature_matrix <- function( + data, + block_weights = c( + continuous = 1, + binary = 1, + types = 1, + egg_groups = 1, + species_traits = 1 + ) +) { + required_weights <- c( + "continuous", "binary", "types", "egg_groups", "species_traits" + ) + + if (!all(required_weights %in% names(block_weights))) { + stop("block_weights must define all feature blocks.", call. = FALSE) + } + + # gender_rate uses -1 for genderless Pokémon and otherwise stores the female + # proportion in eighths. Split that into a bounded continuous proportion plus + # a separate binary genderless flag rather than treating -1 as a real ratio. + feature_data <- data |> + dplyr::mutate( + female_ratio = dplyr::if_else( + gender_rate < 0, + NA_real_, + as.numeric(gender_rate) / 8 + ), + genderless = as.numeric(gender_rate < 0) + ) + + continuous_features <- feature_data |> dplyr::select( height, weight, base_experience, hp, attack, defense, special_attack, special_defense, speed, - capture_rate, base_happiness, hatch_counter, gender_rate, - is_baby, is_legendary, is_mythical, - has_gender_differences, forms_switchable + capture_rate, base_happiness, hatch_counter, female_ratio ) |> - scale_numeric_features() + scale_continuous_features() |> + weight_feature_block(block_weights[["continuous"]]) - categorical_data <- data |> + # Keep binary variables as 0/1. Standardizing a very rare flag such as + # is_mythical would over-amplify it in t-SNE/UMAP distance calculations. + binary_features <- feature_data |> + dplyr::transmute( + is_baby = as.numeric(tidyr::replace_na(is_baby, 0)), + is_legendary = as.numeric(tidyr::replace_na(is_legendary, 0)), + is_mythical = as.numeric(tidyr::replace_na(is_mythical, 0)), + has_gender_differences = as.numeric( + tidyr::replace_na(has_gender_differences, 0) + ), + forms_switchable = as.numeric(tidyr::replace_na(forms_switchable, 0)), + genderless = as.numeric(tidyr::replace_na(genderless, 0)) + ) |> + as.matrix() |> + weight_feature_block(block_weights[["binary"]]) + + # One-hot encode nominal variables. Keep the semantic groups separate so a + # high-cardinality group does not automatically receive more total weight. + type_features <- feature_data |> dplyr::transmute( type_1 = factor(type_1), - type_2 = factor(type_2), + type_2 = factor(type_2) + ) |> + stats::model.matrix(~ type_1 + type_2 - 1, data = _) |> + weight_feature_block(block_weights[["types"]]) + + egg_group_features <- feature_data |> + dplyr::transmute( egg_group_1 = factor(egg_group_1), - egg_group_2 = factor(egg_group_2), + egg_group_2 = factor(egg_group_2) + ) |> + stats::model.matrix(~ egg_group_1 + egg_group_2 - 1, data = _) |> + weight_feature_block(block_weights[["egg_groups"]]) + + species_trait_features <- feature_data |> + dplyr::transmute( growth_rate = factor(growth_rate), body_color = factor(body_color), body_shape = factor(body_shape), habitat = factor(habitat) - ) + ) |> + stats::model.matrix( + ~ growth_rate + body_color + body_shape + habitat - 1, + data = _ + ) |> + weight_feature_block(block_weights[["species_traits"]]) - categorical_features <- stats::model.matrix( - ~ type_1 + type_2 + egg_group_1 + egg_group_2 + - growth_rate + body_color + body_shape + habitat - 1, - data = categorical_data + features <- cbind( + continuous_features, + binary_features, + type_features, + egg_group_features, + species_trait_features ) - features <- cbind(numeric_features, categorical_features) storage.mode(features) <- "double" + + attr(features, "block_weights") <- block_weights[required_weights] + attr(features, "preprocessing") <- paste( + "continuous=z-score; binary=0/1; categorical=one-hot;", + "each semantic block weighted by 1/sqrt(p)" + ) + features } @@ -194,10 +284,17 @@ prepare_pokemon_data <- function(cache_file = NULL, refresh = FALSE) { ) |> dplyr::arrange(id) + # Build once so the exact same matrix is used by PCA, t-SNE and UMAP. + # Centering inside PCA does not change the binary/categorical pairwise + # differences used by the nonlinear methods. + feature_matrix <- pokemon_feature_matrix(data) + result <- list( data = data, - x = pokemon_feature_matrix(data), - feature_names = colnames(pokemon_feature_matrix(data)), + x = feature_matrix, + feature_names = colnames(feature_matrix), + block_weights = attr(feature_matrix, "block_weights"), + preprocessing = attr(feature_matrix, "preprocessing"), source = "PokeAPI/pokeapi + PokeAPI/sprites" ) From d757d52720ae79b3096ac2b2cf70cf0a52c06a1b Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Wed, 22 Jul 2026 11:00:40 -0400 Subject: [PATCH 10/11] Expose Pokemon species traits in explorer --- pokemon-dimensionality-reduction/app.R | 39 ++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/pokemon-dimensionality-reduction/app.R b/pokemon-dimensionality-reduction/app.R index e3402f5..fabab12 100644 --- a/pokemon-dimensionality-reduction/app.R +++ b/pokemon-dimensionality-reduction/app.R @@ -17,6 +17,12 @@ pokemon_bundle <- prepare_pokemon_data( pokemon <- pokemon_bundle$data pokemon_x <- pokemon_bundle$x +# pokemon_x is already prepared for mixed data: +# - continuous variables are standardized; +# - binary variables stay 0/1; +# - nominal variables are one-hot encoded; +# - semantic blocks are normalized so dummy-heavy groups do not dominate. + # theme ------------------------------------------------------------------- apptheme <- bs_theme( version = 5, @@ -52,6 +58,8 @@ run_projection <- function( set.seed(as.integer(seed)) if (identical(method, "pca")) { + # PCA still centers the final mixed feature matrix. We do not scale again: + # doing so would undo the deliberate 0/1 treatment and block weighting. fit <- stats::prcomp(x, center = TRUE, scale. = FALSE) return(fit$x[, 1:2, drop = FALSE]) } @@ -73,6 +81,8 @@ run_projection <- function( return(fit$Y) } + # UMAP and t-SNE both consume the same deliberately prepared Euclidean + # feature space, which makes their projections comparable at the input level. uwot::umap( x, n_components = 2, @@ -94,18 +104,34 @@ make_points <- function(data, xy) { type_2_label = if_else(type_2 == "none", "—", stringr::str_to_title(type_2)), generation_label = stringr::str_replace(generation, "GENERATION-", "Gen "), height_m = round(height / 10, 1), - weight_kg = round(weight / 10, 1) + weight_kg = round(weight / 10, 1), + growth_rate_label = stringr::str_to_title(stringr::str_replace_all(growth_rate, "-", " ")), + habitat_label = if_else( + habitat == "unknown", + "Unknown", + stringr::str_to_title(stringr::str_replace_all(habitat, "-", " ")) + ), + special_status = case_when( + is_mythical == 1 ~ "Mythical", + is_legendary == 1 ~ "Legendary", + is_baby == 1 ~ "Baby", + TRUE ~ "—" + ) ) |> select( x, y, pokemon_label, type_1_label, type_2_label, generation_label, type_color, height_m, weight_kg, hp, attack, defense, special_attack, special_defense, speed, + capture_rate, base_happiness, hatch_counter, + growth_rate_label, habitat_label, special_status, sprite_url, artwork_url ) |> purrr::pmap(function( x, y, pokemon_label, type_1_label, type_2_label, generation_label, type_color, height_m, weight_kg, hp, attack, defense, special_attack, special_defense, speed, + capture_rate, base_happiness, hatch_counter, + growth_rate_label, habitat_label, special_status, sprite_url, artwork_url ) { list( @@ -125,6 +151,12 @@ make_points <- function(data, xy) { special_attack = special_attack, special_defense = special_defense, speed = speed, + capture_rate = capture_rate, + base_happiness = base_happiness, + hatch_counter = hatch_counter, + growth_rate = growth_rate_label, + habitat = habitat_label, + special_status = special_status, artwork_url = artwork_url, color = type_color, marker = list( @@ -153,6 +185,9 @@ tooltip_html <- paste0( 'HP{point.hp}Attack{point.attack}', 'Defense{point.defense}Speed{point.speed}', 'Sp. Atk{point.special_attack}Sp. Def{point.special_defense}', + 'Capture{point.capture_rate}Happiness{point.base_happiness}', + 'Growth{point.growth_rate}Habitat{point.habitat}', + 'Status{point.special_status}Hatch{point.hatch_counter}', '', '' ) @@ -232,7 +267,7 @@ ui <- page_fillable( div( class = "pokemon-sidebar-note", strong(format(nrow(pokemon), big.mark = ",")), - " Pokémon · stats + types + egg groups" + " Pokémon · stats + capture + species traits" ), accordion( open = FALSE, From 8e347271d2bebf8f1410616aab9e80bb25a59dc0 Mon Sep 17 00:00:00 2001 From: Joshua Kunst Date: Wed, 22 Jul 2026 11:00:57 -0400 Subject: [PATCH 11/11] Document mixed-data preprocessing --- pokemon-dimensionality-reduction/readme.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pokemon-dimensionality-reduction/readme.md b/pokemon-dimensionality-reduction/readme.md index 8be0beb..8371dd4 100644 --- a/pokemon-dimensionality-reduction/readme.md +++ b/pokemon-dimensionality-reduction/readme.md @@ -1,6 +1,17 @@ ## How it works -The app builds a feature matrix from the maintained PokeAPI tables. Numeric features include height, weight, base experience, the six battle stats, capture rate, base happiness, hatch counter, gender rate, and species flags such as baby, legendary, and mythical. Pokémon types, egg groups, growth rate, body color, body shape, and habitat are added as indicator variables. +The app builds a mixed-data feature matrix from the maintained PokeAPI tables. It combines morphology, battle stats, capture and breeding traits, binary species flags, Pokémon types, egg groups, growth rate, body color, body shape, and habitat. + +### Preprocessing + +The feature space is prepared deliberately for PCA, t-SNE, and UMAP: + +- **Continuous variables** such as height, weight, battle stats, capture rate, happiness, hatch counter, and female ratio are median-imputed and standardized. +- **Binary variables** such as legendary, mythical, baby, genderless, and form/gender flags stay as **0/1**. Rare flags are not standardized, because doing so could give a rare `1` an artificially huge z-score. +- **Nominal variables** such as type, egg group, growth rate, color, shape, and habitat are one-hot encoded as **0/1** indicators. +- Feature blocks are divided by `sqrt(number of columns in the block)`. This prevents a categorical block from dominating Euclidean distance simply because it expands into many dummy columns. + +The block weights are explicit in `prepare_data.R` and all default to `1`, so the preprocessing is easy to inspect or tune later. Choose a projection method and change its main parameters: @@ -8,7 +19,7 @@ Choose a projection method and change its main parameters: - **t-SNE** exposes perplexity and iteration count. - **UMAP** exposes neighborhood size and minimum distance. -Click **Run projection** to recompute the two-dimensional coordinates. The chart uses the regular PokeAPI sprite as the point marker. Hover a Pokémon to see its official artwork, types, generation, size, and battle stats. +Click **Run projection** to recompute the two-dimensional coordinates. The chart uses the regular PokeAPI sprite as the point marker. Hover a Pokémon to see its official artwork, types, generation, size, battle stats, capture rate, happiness, growth rate, habitat, hatch counter, and legendary/mythical status. The coordinates are exploratory rather than a distance metric to interpret literally. They are useful for spotting groups, evolution families, unusual type combinations, differences in capture difficulty and species traits, and differences between projection methods.