{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "37e8cfe9",
   "metadata": {},
   "source": [
    "# Tension Board 2 / Tension Board 1: Data Overview and Climbing Statistics\n",
    "\n",
    "## Purpose\n",
    "\n",
    "This notebook establishes the basic statistical landscape of the dataset before we move into hold-level analysis and predictive modelling. The main goals are:\n",
    "\n",
    "1. to understand the size and scope of the data,\n",
    "2. to compare layouts, boards, and angles at a high level,\n",
    "3. to identify broad trends in grade, popularity, and quality,\n",
    "4. to create a clean descriptive baseline for the later modelling notebooks.\n",
    "\n",
    "Throughout, I treat each climb-angle entry as a separate observation unless explicitly noted otherwise. That matters because some climbs appear at multiple angles, so a unique climb count and a climb-angle count are not always the same thing.\n",
    "\n",
    "## Outputs\n",
    "\n",
    "This notebook produces summary tables and exploratory plots that motivate the later notebooks on:\n",
    "- hold usage,\n",
    "- hold difficulty,\n",
    "- feature engineering,\n",
    "- predictive modelling,\n",
    "- and deep learning.\n",
    "\n",
    "## Notebook Structure\n",
    "1. [Setup and Imports](#setup-and-imports)\n",
    "2. [Popularity and Temporal Trends](#popularity-and-temporal-trends)\n",
    "3. [Climbing Statistics](#climbing-statistics-grades-angles-quality-and-matching)\n",
    "4. [Prolific Statistics](#prolific-statistics)\n",
    "5. [Conclusion](#conclusion)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "898cad20",
   "metadata": {},
   "source": [
    "## Setup and Imports"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "e48e2d25",
   "metadata": {},
   "outputs": [],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Setup and imports\n",
    "==================================\n",
    "\"\"\"\n",
    "# Imports\n",
    "import pandas as pd\n",
    "import sqlite3\n",
    "import matplotlib.pyplot as plt\n",
    "import seaborn as sns\n",
    "import numpy as np\n",
    "\n",
    "import matplotlib.patches as mpatches\n",
    "import sqlite3\n",
    "\n",
    "\n",
    "# Set some display options\n",
    "pd.set_option('display.max_columns', None)\n",
    "pd.set_option('display.max_rows', 100)\n",
    "\n",
    "# Set style\n",
    "palette=['steelblue', 'coral', 'seagreen']  #(for multi-bar graphs)\n",
    "\n",
    "# Connect to the database\n",
    "DB_PATH=\"../data/kilter.db\"\n",
    "conn = sqlite3.connect(DB_PATH)\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "2e7b5862",
   "metadata": {},
   "outputs": [],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "### Query our data from the DB\n",
    "==================================\n",
    "\n",
    "We restrict ourselves to the Kilter Original, i.e., layoud_id=1.\n",
    "For some reason there is also a first ascent logged in 2006 (\"'Preliminary first draft of Val David Sans Nom Practice. Not yet tested\").\n",
    "Since the first logged ascents start in 2018, we'll just insist that the date is larger than January 1, 2016.\n",
    "\"\"\"\n",
    "\n",
    "# Query climb data\n",
    "climbs_query = \"\"\"\n",
    "SELECT\n",
    "    c.uuid,\n",
    "    c.name AS climb_name,\n",
    "    c.setter_username,\n",
    "    c.layout_id AS layout_id,\n",
    "    c.description,\n",
    "    c.is_nomatch,\n",
    "    c.is_listed,\n",
    "    l.name AS layout_name,\n",
    "    p.name AS board_name,\n",
    "    c.frames,\n",
    "    cs.angle,\n",
    "    cs.display_difficulty,\n",
    "    dg.boulder_name AS boulder_grade,\n",
    "    cs.ascensionist_count,\n",
    "    cs.quality_average,\n",
    "    cs.fa_at\n",
    "FROM climbs c\n",
    "JOIN layouts l ON c.layout_id = l.id\n",
    "JOIN products p ON l.product_id = p.id\n",
    "JOIN climb_stats cs ON c.uuid = cs.climb_uuid\n",
    "JOIN difficulty_grades dg ON ROUND(cs.display_difficulty) = dg.difficulty\n",
    "WHERE cs.display_difficulty IS NOT NULL AND c.layout_id=1 AND cs.fa_at > '2016-01-01';\n",
    "\"\"\"\n",
    "\n",
    "# Load it into a DataFrame\n",
    "df = pd.read_sql_query(climbs_query, conn)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "70a88be4",
   "metadata": {},
   "source": [
    "The above query will allow us to gather basically anything we need to in order to analyze climbing statistics. We leave out information about climging holds and things like this, because they will be analyzed in a different notebook. Let's see what our DataFrame looks like."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "8b0057f5",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>uuid</th>\n",
       "      <th>climb_name</th>\n",
       "      <th>setter_username</th>\n",
       "      <th>layout_id</th>\n",
       "      <th>description</th>\n",
       "      <th>is_nomatch</th>\n",
       "      <th>is_listed</th>\n",
       "      <th>layout_name</th>\n",
       "      <th>board_name</th>\n",
       "      <th>frames</th>\n",
       "      <th>angle</th>\n",
       "      <th>display_difficulty</th>\n",
       "      <th>boulder_grade</th>\n",
       "      <th>ascensionist_count</th>\n",
       "      <th>quality_average</th>\n",
       "      <th>fa_at</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>002047402B6941CEA5ED7BB09FBFE14D</td>\n",
       "      <td>4/26 Harder Than It Should Be</td>\n",
       "      <td>kilterjackie</td>\n",
       "      <td>1</td>\n",
       "      <td></td>\n",
       "      <td>0</td>\n",
       "      <td>0</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>p1145r12p1146r12p1149r13p1186r13p1201r13p1256r...</td>\n",
       "      <td>40</td>\n",
       "      <td>18.0</td>\n",
       "      <td>6b/V4</td>\n",
       "      <td>1</td>\n",
       "      <td>1.0</td>\n",
       "      <td>2018-04-27 20:24:44</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>002ED50792A94E5EB2127D59E167B2EE</td>\n",
       "      <td>what kind of triangle</td>\n",
       "      <td>bctyner</td>\n",
       "      <td>1</td>\n",
       "      <td>Make sure you angle your body just right</td>\n",
       "      <td>0</td>\n",
       "      <td>0</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>p1123r12p1139r13p1155r12p1171r13p1187r13p1203r...</td>\n",
       "      <td>45</td>\n",
       "      <td>25.0</td>\n",
       "      <td>7b+/V8</td>\n",
       "      <td>2</td>\n",
       "      <td>2.5</td>\n",
       "      <td>2020-01-05 17:57:46</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>004FB7A0C0754DA98634C5EE4D985D9A</td>\n",
       "      <td>SpEeD cLiMbInG</td>\n",
       "      <td>jefferoni</td>\n",
       "      <td>1</td>\n",
       "      <td></td>\n",
       "      <td>0</td>\n",
       "      <td>0</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>p1081r15p1090r13p1091r13p1094r12p1097r13p1098r...</td>\n",
       "      <td>40</td>\n",
       "      <td>29.0</td>\n",
       "      <td>8a+/V12</td>\n",
       "      <td>1</td>\n",
       "      <td>1.0</td>\n",
       "      <td>2021-03-18 02:49:18</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>004FB7A0C0754DA98634C5EE4D985D9A</td>\n",
       "      <td>SpEeD cLiMbInG</td>\n",
       "      <td>jefferoni</td>\n",
       "      <td>1</td>\n",
       "      <td></td>\n",
       "      <td>0</td>\n",
       "      <td>0</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>p1081r15p1090r13p1091r13p1094r12p1097r13p1098r...</td>\n",
       "      <td>45</td>\n",
       "      <td>22.0</td>\n",
       "      <td>7a/V6</td>\n",
       "      <td>1</td>\n",
       "      <td>3.0</td>\n",
       "      <td>2021-03-18 02:48:26</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>00683d10a8e246b3a106531c8573f13c</td>\n",
       "      <td>marks 1 try</td>\n",
       "      <td>mark.nalder</td>\n",
       "      <td>1</td>\n",
       "      <td></td>\n",
       "      <td>0</td>\n",
       "      <td>0</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>p1141r15p1142r12p1144r15p1179r12p1192r13p1246r...</td>\n",
       "      <td>30</td>\n",
       "      <td>14.0</td>\n",
       "      <td>5b/V1</td>\n",
       "      <td>2</td>\n",
       "      <td>1.5</td>\n",
       "      <td>2021-01-20 14:27:43</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>...</th>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>302504</th>\n",
       "      <td>F32C9281901C44ADB2CF0E645416EAD4</td>\n",
       "      <td>bobbyjoy001</td>\n",
       "      <td>Sirmatt279</td>\n",
       "      <td>1</td>\n",
       "      <td>Very possible to keep tension whole way throug...</td>\n",
       "      <td>1</td>\n",
       "      <td>1</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>p1075r15p1171r12p1208r12p1257r13p1292r13p1305r...</td>\n",
       "      <td>40</td>\n",
       "      <td>17.0</td>\n",
       "      <td>6a+/V3</td>\n",
       "      <td>1</td>\n",
       "      <td>3.0</td>\n",
       "      <td>2026-01-31 00:51:43</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>302505</th>\n",
       "      <td>2E9884E9AF2442D9988C90AC21B00BEA</td>\n",
       "      <td>Deuce Patrol</td>\n",
       "      <td>aaf63</td>\n",
       "      <td>1</td>\n",
       "      <td></td>\n",
       "      <td>0</td>\n",
       "      <td>1</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>p1082r15p1149r12p1215r13p1268r13p1353r14p1453r...</td>\n",
       "      <td>45</td>\n",
       "      <td>18.0</td>\n",
       "      <td>6b/V4</td>\n",
       "      <td>1</td>\n",
       "      <td>2.0</td>\n",
       "      <td>2026-01-31 00:57:01</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>302506</th>\n",
       "      <td>6B8AFFD9C84C42EB98F26654A3EC78C0</td>\n",
       "      <td>bobbyjoy002</td>\n",
       "      <td>Sirmatt279</td>\n",
       "      <td>1</td>\n",
       "      <td>Easing you in</td>\n",
       "      <td>0</td>\n",
       "      <td>1</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>p1076r15p1106r15p1154r15p1157r15p1166r15p1190r...</td>\n",
       "      <td>40</td>\n",
       "      <td>16.0</td>\n",
       "      <td>6a/V3</td>\n",
       "      <td>1</td>\n",
       "      <td>3.0</td>\n",
       "      <td>2026-01-31 00:58:15</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>302507</th>\n",
       "      <td>A9ED28ABDBD6449FAE4B023664904A9F</td>\n",
       "      <td>bobbyjoy003</td>\n",
       "      <td>Sirmatt279</td>\n",
       "      <td>1</td>\n",
       "      <td>Tester. No matching.</td>\n",
       "      <td>1</td>\n",
       "      <td>1</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>p1159r15p1204r15p1212r12p1213r12p1247r13p1249r...</td>\n",
       "      <td>40</td>\n",
       "      <td>19.0</td>\n",
       "      <td>6b+/V4</td>\n",
       "      <td>1</td>\n",
       "      <td>3.0</td>\n",
       "      <td>2026-01-31 01:05:58</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>302508</th>\n",
       "      <td>D3849EE4641A401EA6D7B2FF2B44C152</td>\n",
       "      <td>last minute jumps</td>\n",
       "      <td>ducky777</td>\n",
       "      <td>1</td>\n",
       "      <td>No matching.</td>\n",
       "      <td>1</td>\n",
       "      <td>1</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>Kilter Board Original</td>\n",
       "      <td>p1142r15p1163r12p1176r12p1199r13p1269r13p1301r...</td>\n",
       "      <td>45</td>\n",
       "      <td>21.0</td>\n",
       "      <td>6c+/V5</td>\n",
       "      <td>1</td>\n",
       "      <td>3.0</td>\n",
       "      <td>2026-01-31 01:10:15</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "<p>302509 rows × 16 columns</p>\n",
       "</div>"
      ],
      "text/plain": [
       "                                    uuid                     climb_name  \\\n",
       "0       002047402B6941CEA5ED7BB09FBFE14D  4/26 Harder Than It Should Be   \n",
       "1       002ED50792A94E5EB2127D59E167B2EE          what kind of triangle   \n",
       "2       004FB7A0C0754DA98634C5EE4D985D9A                 SpEeD cLiMbInG   \n",
       "3       004FB7A0C0754DA98634C5EE4D985D9A                 SpEeD cLiMbInG   \n",
       "4       00683d10a8e246b3a106531c8573f13c                    marks 1 try   \n",
       "...                                  ...                            ...   \n",
       "302504  F32C9281901C44ADB2CF0E645416EAD4                    bobbyjoy001   \n",
       "302505  2E9884E9AF2442D9988C90AC21B00BEA                   Deuce Patrol   \n",
       "302506  6B8AFFD9C84C42EB98F26654A3EC78C0                    bobbyjoy002   \n",
       "302507  A9ED28ABDBD6449FAE4B023664904A9F                    bobbyjoy003   \n",
       "302508  D3849EE4641A401EA6D7B2FF2B44C152              last minute jumps   \n",
       "\n",
       "       setter_username  layout_id  \\\n",
       "0         kilterjackie          1   \n",
       "1              bctyner          1   \n",
       "2            jefferoni          1   \n",
       "3            jefferoni          1   \n",
       "4          mark.nalder          1   \n",
       "...                ...        ...   \n",
       "302504      Sirmatt279          1   \n",
       "302505           aaf63          1   \n",
       "302506      Sirmatt279          1   \n",
       "302507      Sirmatt279          1   \n",
       "302508        ducky777          1   \n",
       "\n",
       "                                              description  is_nomatch  \\\n",
       "0                                                                   0   \n",
       "1                Make sure you angle your body just right           0   \n",
       "2                                                                   0   \n",
       "3                                                                   0   \n",
       "4                                                                   0   \n",
       "...                                                   ...         ...   \n",
       "302504  Very possible to keep tension whole way throug...           1   \n",
       "302505                                                              0   \n",
       "302506                                      Easing you in           0   \n",
       "302507                               Tester. No matching.           1   \n",
       "302508                                       No matching.           1   \n",
       "\n",
       "        is_listed            layout_name             board_name  \\\n",
       "0               0  Kilter Board Original  Kilter Board Original   \n",
       "1               0  Kilter Board Original  Kilter Board Original   \n",
       "2               0  Kilter Board Original  Kilter Board Original   \n",
       "3               0  Kilter Board Original  Kilter Board Original   \n",
       "4               0  Kilter Board Original  Kilter Board Original   \n",
       "...           ...                    ...                    ...   \n",
       "302504          1  Kilter Board Original  Kilter Board Original   \n",
       "302505          1  Kilter Board Original  Kilter Board Original   \n",
       "302506          1  Kilter Board Original  Kilter Board Original   \n",
       "302507          1  Kilter Board Original  Kilter Board Original   \n",
       "302508          1  Kilter Board Original  Kilter Board Original   \n",
       "\n",
       "                                                   frames  angle  \\\n",
       "0       p1145r12p1146r12p1149r13p1186r13p1201r13p1256r...     40   \n",
       "1       p1123r12p1139r13p1155r12p1171r13p1187r13p1203r...     45   \n",
       "2       p1081r15p1090r13p1091r13p1094r12p1097r13p1098r...     40   \n",
       "3       p1081r15p1090r13p1091r13p1094r12p1097r13p1098r...     45   \n",
       "4       p1141r15p1142r12p1144r15p1179r12p1192r13p1246r...     30   \n",
       "...                                                   ...    ...   \n",
       "302504  p1075r15p1171r12p1208r12p1257r13p1292r13p1305r...     40   \n",
       "302505  p1082r15p1149r12p1215r13p1268r13p1353r14p1453r...     45   \n",
       "302506  p1076r15p1106r15p1154r15p1157r15p1166r15p1190r...     40   \n",
       "302507  p1159r15p1204r15p1212r12p1213r12p1247r13p1249r...     40   \n",
       "302508  p1142r15p1163r12p1176r12p1199r13p1269r13p1301r...     45   \n",
       "\n",
       "        display_difficulty boulder_grade  ascensionist_count  quality_average  \\\n",
       "0                     18.0         6b/V4                   1              1.0   \n",
       "1                     25.0        7b+/V8                   2              2.5   \n",
       "2                     29.0       8a+/V12                   1              1.0   \n",
       "3                     22.0         7a/V6                   1              3.0   \n",
       "4                     14.0         5b/V1                   2              1.5   \n",
       "...                    ...           ...                 ...              ...   \n",
       "302504                17.0        6a+/V3                   1              3.0   \n",
       "302505                18.0         6b/V4                   1              2.0   \n",
       "302506                16.0         6a/V3                   1              3.0   \n",
       "302507                19.0        6b+/V4                   1              3.0   \n",
       "302508                21.0        6c+/V5                   1              3.0   \n",
       "\n",
       "                      fa_at  \n",
       "0       2018-04-27 20:24:44  \n",
       "1       2020-01-05 17:57:46  \n",
       "2       2021-03-18 02:49:18  \n",
       "3       2021-03-18 02:48:26  \n",
       "4       2021-01-20 14:27:43  \n",
       "...                     ...  \n",
       "302504  2026-01-31 00:51:43  \n",
       "302505  2026-01-31 00:57:01  \n",
       "302506  2026-01-31 00:58:15  \n",
       "302507  2026-01-31 01:05:58  \n",
       "302508  2026-01-31 01:10:15  \n",
       "\n",
       "[302509 rows x 16 columns]"
      ]
     },
     "execution_count": 3,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "df"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e7eb46cc",
   "metadata": {},
   "source": [
    "---"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ba4fc956",
   "metadata": {},
   "source": [
    "# Popularity and Temporal Trends\n",
    "\n",
    "## Popularity of Tension Board\n",
    "\n",
    "Since we do not have access to user data, we will examine the popular of the Tension Boards by counting first ascents and unique setters by year. Often it's the case that the first ascensionist is the also the setter of the climb, but not always. None the less, we group up first ascensionists by year, with an extra tidbit about how many unique setters there were. "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "3dff1bd7",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 66948 characters]",
      "text/plain": [
       "<Figure size 1200x600 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Popularity of Kilter board by year. \n",
    "First ascents by year + unique setters by year\n",
    "==================================\n",
    "\"\"\"\n",
    "\n",
    "# Convert df['fa_at'] to datetime format. (For some reason, it does not register as such)\n",
    "df['fa_at'] = pd.to_datetime(df['fa_at'])\n",
    "\n",
    "# Add a new column for the year\n",
    "df['fa_year'] = df['fa_at'].dt.year\n",
    "\n",
    "# Make a new DataFrame with year, first_ascents, and unique_setters\n",
    "df_growth = df.groupby('fa_year').agg(\n",
    "    first_ascents=('uuid', 'count'),\n",
    "    unique_setters=('setter_username', 'nunique')\n",
    ").reset_index()\n",
    "\n",
    "# Disregard the year 2026 since the data only goes one month in. \n",
    "df_growth = df_growth[df_growth['fa_year'] < 2026]\n",
    "\n",
    "# Convert year to string so that matplotlib doesn't think the year is continuous\n",
    "df_growth['fa_year'] = df_growth['fa_year'].astype(str)\n",
    "\n",
    "## Plot\n",
    "# Dual index plotting\n",
    "\n",
    "fig, ax1 = plt.subplots(figsize=(12,6))\n",
    "\n",
    "# Bar chart for first ascents\n",
    "ax1.bar(df_growth['fa_year'], df_growth['first_ascents'], label='First Ascents', color='coral')\n",
    "ax1.set_xlabel('Year')\n",
    "ax1.set_ylabel('First Ascents')\n",
    "ax1.set_title('TB First Ascents & Unique Setters over Time')\n",
    "#ax1.tick_params(axis='y')\n",
    "\n",
    "# Line chart for unique setters (secondary axis)\n",
    "ax2 = ax1.twinx()\n",
    "ax2.plot(df_growth['fa_year'], df_growth['unique_setters'], color='steelblue', marker='o', label='Unique Setters')\n",
    "ax2.set_ylabel('Unique Setters', color='steelblue')\n",
    "ax2.tick_params(axis='y', labelcolor='steelblue')\n",
    "\n",
    "# Other stuff\n",
    "fig.legend(loc='upper left', bbox_to_anchor=(0.15,0.85))\n",
    "\n",
    "plt.xticks()\n",
    "plt.savefig('../images/01_climb_stats/first_ascents_by_year.png')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e2bca15a",
   "metadata": {},
   "source": [
    "## Seasonal analysis\n",
    "\n",
    "Next, we examine when the Tension board is most popular. Again, we will work with what we have and use first ascent data. We will plot first ascents by month, combing all years. We exclude the year 2026 because this can skew the analysis as some of the month of January has data (and clearly, 2026 is when the TB2 is the most popular, so this can actually add quite a bit bias). "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "8912306b",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 34644 characters]",
      "text/plain": [
       "<Figure size 1200x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Season analysis: first ascents by month\n",
    "==================================\n",
    "\"\"\"\n",
    "\n",
    "# First let us add a column for the month to our data\n",
    "df['fa_month'] = df['fa_at'].dt.month\n",
    "\n",
    "# Filter to years < 2026 since the data only goes one month in\n",
    "df_filter = df[df['fa_year'] < 2026]\n",
    "\n",
    "# Make a new DataFrame with month and first ascents\n",
    "df_season = df_filter.groupby('fa_month').agg(\n",
    "    first_ascents=('uuid', 'count'),\n",
    ").reset_index()\n",
    "\n",
    "# We also add a column for the month name. \n",
    "month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', \n",
    "               'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']\n",
    "df_season['fa_month_name'] = df_season['fa_month'].apply(lambda x: month_names[x-1])\n",
    "\n",
    "# Plot the data\n",
    "fig,ax = plt.subplots(figsize=(12,6))\n",
    "ax.bar(df_season['fa_month_name'], df_season['first_ascents'], color='coral')\n",
    "ax.set_title('First Ascents by Month (All Years Combined)')\n",
    "ax.set_xlabel('Month')\n",
    "ax.set_ylabel('Total First Ascents')\n",
    "\n",
    "# Save the file\n",
    "plt.savefig('../images/01_climb_stats/first_ascents_by_month.png')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bb802d74",
   "metadata": {},
   "source": [
    "This should be what we expect: that the winter months (Dec) see the most traffic. This is probably when the outdoor climbers are hitting the boards because they're stuck inside. The warmer months see the least number of first ascents since the strong climbers are probably outdoors."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "def962ba",
   "metadata": {},
   "source": [
    "## Day of Week Analysis\n",
    "\n",
    "We can plot the number of first ascents by day of week. Removing the 2026 data shouldn't make a difference here, so we opt to keep it."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "071cfd5f",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 31080 characters]",
      "text/plain": [
       "<Figure size 1200x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Day of Week analysis\n",
    "==================================\n",
    "\"\"\"\n",
    "\n",
    "# Let us add a column in our DataFrame for the day of the week.\n",
    "# Note that df.dt.day_of_week will have Monday be 0 and Sunday be 6. \n",
    "\n",
    "df['fa_day_of_week'] = df['fa_at'].dt.day_of_week\n",
    "\n",
    "\n",
    "# Make a new DataFrame with month and first ascents\n",
    "df_days = df.groupby('fa_day_of_week').agg(\n",
    "    first_ascents=('uuid', 'count'),\n",
    ").reset_index()\n",
    "\n",
    "# We also add a column for the month name. \n",
    "day_names = ['Mon', 'Tues', 'Wed', 'Thurs', 'Fri', 'Sat', 'Sun']\n",
    "df_days['fa_day_name'] = df_days['fa_day_of_week'].apply(lambda x: day_names[x])\n",
    "\n",
    "# Plot the data\n",
    "fig,ax = plt.subplots(figsize=(12,6))\n",
    "ax.bar(df_days['fa_day_name'], df_days['first_ascents'], color='coral')\n",
    "ax.set_title('First Ascents by Day of Week (All Years Combined)')\n",
    "ax.set_xlabel('Day')\n",
    "ax.set_ylabel('Total First Ascents')\n",
    "\n",
    "# Save the file\n",
    "plt.savefig('../images/01_climb_stats/first_ascents_by_day_of_week.png')\n",
    "plt.show()\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9961e92e",
   "metadata": {},
   "source": [
    "Interesting, Tuesday and Wednesday have the most traffic, while Monday is the least popular."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "440a0b28",
   "metadata": {},
   "source": [
    "## Time of Day Analysis\n",
    "\n",
    "We can even do a time of day analysis. Again, we will keep the 2026 data since it shouldn't affect much. It is not entirely clear that makes sense to look at this, as we don't know if the time of first ascent is recorded in local time of the climber or local time of the server. These boards are all over the world, so this may add quite a bit of variance."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "1b8e3c4a",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 25564 characters]",
      "text/plain": [
       "<Figure size 1200x600 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Time of Day analysis\n",
    "==================================\n",
    "\"\"\"\n",
    "\n",
    "# Let us add a column in our DataFrame for the day of the week.\n",
    "# Note that df.dt.day_of_week will have Monday be 0 and Sunday be 6. \n",
    "\n",
    "df['fa_hour'] = df['fa_at'].dt.hour\n",
    "\n",
    "\n",
    "# Make a new DataFrame with month and first ascents\n",
    "df_hour = df.groupby('fa_hour').agg(\n",
    "    first_ascents=('uuid', 'count'),\n",
    ").reset_index()\n",
    "\n",
    "\n",
    "# Plot the data\n",
    "fig,ax = plt.subplots(figsize=(12,6))\n",
    "ax.bar(df_hour['fa_hour'], df_hour['first_ascents'], color='coral')\n",
    "ax.set_title('First Ascents by Hour (All Years Combined)')\n",
    "ax.set_xlabel('Hour')\n",
    "ax.set_ylabel('Total First Ascents')\n",
    "\n",
    "# Save the file\n",
    "plt.savefig('../images/01_climb_stats/first_ascents_by_hour.png')\n",
    "plt.show()\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5ddc49b3",
   "metadata": {},
   "source": [
    "---"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "533dc4a2",
   "metadata": {},
   "source": [
    "# Climbing Statistics: Grades, Angles, Quality, and Matching\n",
    "\n",
    "We will visualize the climbing grade distribution. Recall that we have the following table of grades (with some other unlisted grades).\n",
    "\n",
    "|difficulty|boulder_name|route_name|\n",
    "|----------|------------|----------|\n",
    "|        10|4a/V0       |5b/5.9    |\n",
    "|        11|4b/V0       |5c/5.10a  |\n",
    "|        12|4c/V0       |6a/5.10b  |\n",
    "|        13|5a/V1       |6a+/5.10c |\n",
    "|        14|5b/V1       |6b/5.10d  |\n",
    "|        15|5c/V2       |6b+/5.11a |\n",
    "|        16|6a/V3       |6c/5.11b  |\n",
    "|        17|6a+/V3      |6c+/5.11c |\n",
    "|        18|6b/V4       |7a/5.11d  |\n",
    "|        19|6b+/V4      |7a+/5.12a |\n",
    "|        20|6c/V5       |7b/5.12b  |\n",
    "|        21|6c+/V5      |7b+/5.12c |\n",
    "|        22|7a/V6       |7c/5.12d  |\n",
    "|        23|7a+/V7      |7c+/5.13a |\n",
    "|        24|7b/V8       |8a/5.13b  |\n",
    "|        25|7b+/V8      |8a+/5.13c |\n",
    "|        26|7c/V9       |8b/5.13d  |\n",
    "|        27|7c+/V10     |8b+/5.14a |\n",
    "|        28|8a/V11      |8c/5.14b  |\n",
    "|        29|8a+/V12     |8c+/5.14c |\n",
    "|        30|8b/V13      |9a/5.14d  |\n",
    "|        31|8b+/V14     |9a+/5.15a |\n",
    "|        32|8c/V15      |9b/5.15b  |\n",
    "|        33|8c+/V16     |9b+/5.15c |\n",
    "\n",
    "We will use the actual difficulty in our work, and then unpack translations into boulder_name as we see fit."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4f986836",
   "metadata": {},
   "source": [
    "## Grade distribution"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "5fa594ba",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 71964 characters]",
      "text/plain": [
       "<Figure size 1600x800 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Difficulty distribution\n",
    "==================================\n",
    "\"\"\"\n",
    "\n",
    "grade_counts = df['boulder_grade'].value_counts()\n",
    "grade_order = df.groupby('boulder_grade')['display_difficulty'].mean().sort_values().index.tolist()\n",
    "grade_counts = grade_counts.reindex(grade_order)\n",
    "\n",
    "\n",
    "df_grades = df.groupby('boulder_grade').size().reset_index(name='count')\n",
    "\n",
    "\n",
    "# Plot\n",
    "fig, ax = plt.subplots(figsize=(16, 8))\n",
    "\n",
    "sns.barplot(\n",
    "    data=df_grades,\n",
    "    x='boulder_grade',\n",
    "    y='count',\n",
    "    color='steelblue',\n",
    "    ax=ax,\n",
    "    width=0.6,\n",
    "    order=grade_order\n",
    ")\n",
    "\n",
    "\n",
    "\n",
    "ax.set_xlabel('Grade', fontsize=11)\n",
    "ax.set_ylabel('Number of Climbs', fontsize=11)\n",
    "ax.set_title('Difficulty Distribution by Board Layout', fontsize=14)\n",
    "ax.tick_params(axis='x', rotation=45)\n",
    "ax.grid(axis='y', alpha=0.3)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('../images/01_climb_stats/difficulty_distribution.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "871bb45d",
   "metadata": {},
   "source": [
    "As a climber in North America, I tend to just use the V-grade and not look at the French grade. So let us group the V-grades together and show the distribution like that. We'll usually just stick the boulder_grade (e.g., 5c/V2) instead of grouping the V-grades though. "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "d7f0b911",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 41372 characters]",
      "text/plain": [
       "<Figure size 1600x800 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "V-Grade distribution\n",
    "==================================\n",
    "\"\"\"\n",
    "\n",
    "grade_to_v = {\n",
    "10: 0, 11: 0, 12: 0,\n",
    "13: 1, 14: 1,\n",
    "15: 2,\n",
    "16: 3, 17: 3,\n",
    "18: 4, 19: 4,\n",
    "20: 5, 21: 5,\n",
    "22: 6,\n",
    "23: 7,\n",
    "24: 8, 25: 8,\n",
    "26: 9,\n",
    "27: 10,\n",
    "28: 11,\n",
    "29: 12,\n",
    "30: 13,\n",
    "31: 14,\n",
    "32: 15,\n",
    "33: 16,\n",
    "}\n",
    "\n",
    "# Let's add a v_grade column and v_grade_counts\n",
    "df['v_grade'] = df['display_difficulty'].round().map(grade_to_v)\n",
    "df_v_grades = df.groupby('v_grade').size().reset_index(name='count')\n",
    "df_v_grades['v_label'] = 'V' + df_v_grades['v_grade'].astype(str)\n",
    "\n",
    "\n",
    "# Plot\n",
    "fig, ax = plt.subplots(figsize=(16, 8))\n",
    "\n",
    "sns.barplot(\n",
    "    data=df_v_grades,\n",
    "    x='v_label',\n",
    "    y='count',\n",
    "    color='steelblue',\n",
    "    ax=ax,\n",
    "    width=0.6,\n",
    ")\n",
    "\n",
    "\n",
    "ax.set_xlabel('V-Grade', fontsize=11)\n",
    "ax.set_ylabel('Number of Climbs', fontsize=11)\n",
    "ax.set_title('V-Grade Distribution by Board Layout', fontsize=14)\n",
    "ax.tick_params(axis='x')\n",
    "ax.grid(axis='y', alpha=0.3)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('../images/01_climb_stats/v_grade_distribution.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e27f4277",
   "metadata": {},
   "source": [
    "Some key differences in grades are the angle at which the climb is. Note that climbs can be done at different angles."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e6a34766",
   "metadata": {},
   "source": [
    "## Angle Distribution\n",
    "\n",
    "What about the angle distribution? Since the TB1 goes from 0 to 50 and the TB2 goes from 0 to 65 (although my local board only goes to 60?), let's do an analysis on each."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "7d65b6cd",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 39180 characters]",
      "text/plain": [
       "<Figure size 1600x800 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Angle distribution\n",
    "==================================\n",
    "\n",
    "The Kilter Board original goes to 70 degrees.\n",
    "\"\"\"\n",
    "\n",
    "\n",
    "df_angle = df.groupby('angle').size().reset_index(name='count')\n",
    "\n",
    "# Reindex to correct order\n",
    "angle_order = sorted(df['angle'].unique())\n",
    "\n",
    "# Plot\n",
    "fix, ax = plt.subplots(figsize=(16,8))\n",
    "\n",
    "# Plot All Layouts\n",
    "sns.barplot(\n",
    "    data=df_angle,\n",
    "    x='angle',\n",
    "    y='count',\n",
    "    color='seagreen',\n",
    "    ax=ax,\n",
    "    width=0.6,\n",
    "    order=angle_order\n",
    ")\n",
    "\n",
    "\n",
    "ax.set_xlabel('Angle')\n",
    "ax.set_ylabel('Number of Climbs')\n",
    "ax.set_title('Angle Distribution by Board Layout')\n",
    "ax.grid(axis='y', alpha=0.3)\n",
    "\n",
    "\n",
    "plt.suptitle('Angle Distribution by Board Layout')\n",
    "plt.savefig('../images/01_climb_stats/angle_distribution.png')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7a00ee20",
   "metadata": {},
   "source": [
    "Just like with Tension Boards, 40 is the most common angle. "
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bc164cee",
   "metadata": {},
   "source": [
    "## Angle vs grade\n",
    "\n",
    "How is the distribution between angles and grades? Let's do this with a heatmap."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "2023cb49",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 78968 characters]",
      "text/plain": [
       "<Figure size 1600x800 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Angle vs grade\n",
    "==================================\n",
    "\"\"\"\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(16, 8))\n",
    "\n",
    "# Create mapping from difficulty to boulder_grade\n",
    "grade_mapping = df.groupby('display_difficulty')['boulder_grade'].first().to_dict()\n",
    "\n",
    "# Plot \"All Layouts\" as faint background boxes\n",
    "sns.boxplot(\n",
    "    data=df,\n",
    "    x='angle',\n",
    "    y='display_difficulty',\n",
    "    color='seagreen',\n",
    "    order=angle_order,\n",
    "    showfliers=False,\n",
    "    width=0.6,\n",
    "    ax=ax,\n",
    ")\n",
    "\n",
    "# Relabel y-axis with boulder_grades\n",
    "yticks_rounded = sorted(set(int(round(t)) for t in df['display_difficulty'].unique() if not pd.isna(t)))\n",
    "ylabels = [grade_mapping.get(t, '') for t in yticks_rounded]\n",
    "ax.set_yticks(yticks_rounded)\n",
    "ax.set_yticklabels(ylabels)\n",
    "\n",
    "\n",
    "ax.set_xlabel('Angle (degrees)', fontsize=11)\n",
    "ax.set_ylabel('Boulder Grade', fontsize=11)\n",
    "ax.set_title('Difficulty Distribution by Angle', fontsize=14)\n",
    "ax.grid(axis='y', alpha=0.3)\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('../images/01_climb_stats/difficulty_by_angle_boxplot.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a5477862",
   "metadata": {},
   "source": [
    "We see that angle is directly correlated with how difficult climbs are on average, right up untill 55 degrees. Then it tapers off."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c7a5973a",
   "metadata": {},
   "source": [
    "## The Quality of a climb\n",
    "\n",
    "Next we examine the quality of a climb. First we look at how quality relates to the number of ascents."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "id": "bf5bd013",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 43996 characters]",
      "text/plain": [
       "<Figure size 500x500 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Climb quality vs popularity\n",
    "==================================\n",
    "\"\"\"\n",
    "\n",
    "# Filter to climbs with quality ratings\n",
    "df_quality = df[(df['quality_average'].notna()) & (df['quality_average'] > 0)]\n",
    "\n",
    "# Sample for performance\n",
    "#df_sample = df_quality.sample(min(2000, len(df_quality)))\n",
    "\n",
    "g = sns.jointplot(\n",
    "    data=df_quality,\n",
    "    x='quality_average',\n",
    "    y='ascensionist_count',\n",
    "    kind='scatter',\n",
    "    color='teal',\n",
    "    height=5\n",
    ")\n",
    "\n",
    "g.ax_joint.set_xlabel('Quality Rating')\n",
    "g.ax_joint.set_ylabel('Ascensionist Count')\n",
    "g.fig.suptitle('Quality vs Popularity')\n",
    "\n",
    "plt.savefig('../images/01_climb_stats/quality_popularity.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bf0ca290",
   "metadata": {},
   "source": [
    "Next we visualize the average quality vs the angle and grade, by means of a heatmap. Keep in mind that the harder the climb and steeper the angle, the less people will be doing it. So harder climbs are skewed towards people who can actually do it. The point is that, on boards, the climb quality isn't always the best metric. As such, we won't spend too much time on the quality and will only do a heatmap which takes into account all layouts."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "id": "af7282b9",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 82548 characters]",
      "text/plain": [
       "<Figure size 1600x800 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "### Average quality by angle and grade\n",
    "\n",
    "# Filter to climbs with quality ratings\n",
    "df_quality = df[(df['quality_average'].notna()) & (df['quality_average'] > 0)]\n",
    "\n",
    "\n",
    "# Create pivot table\n",
    "quality_pivot = df_quality.pivot_table(\n",
    "    index='boulder_grade',\n",
    "    columns='angle',\n",
    "    values='quality_average',\n",
    "    aggfunc='mean'\n",
    ")\n",
    "quality_pivot = quality_pivot.reindex(grade_order)\n",
    "quality_pivot = quality_pivot.reindex(columns=[a for a in angle_order if a in quality_pivot.columns])\n",
    "\n",
    "# Plot\n",
    "fig, ax = plt.subplots(figsize=(16, 8))\n",
    "\n",
    "sns.heatmap(\n",
    "    quality_pivot,\n",
    "    cmap='RdYlGn',\n",
    "    cbar_kws={'label': 'Avg Quality Rating'},\n",
    "    ax=ax\n",
    ")\n",
    "\n",
    "ax.set_xlabel('Angle (°)')\n",
    "ax.set_ylabel('Grade')\n",
    "ax.invert_yaxis()\n",
    "ax.set_title('Average Quality Rating by Grade and Angle (All Layouts)')\n",
    "\n",
    "plt.tight_layout()\n",
    "plt.savefig('../images/01_climb_stats/quality_heatmap.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3630b077",
   "metadata": {},
   "source": [
    "## \"Match\" vs. \"No Match\"\n",
    "\n",
    "Some setters opt to put the \"no match\" tag onto their climbs. This means that the climber is not allowed to match their hands on any hold. Let's do an analysis of the differences with regular climbs."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "id": "ed1f0ec2",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 67616 characters]",
      "text/plain": [
       "<Figure size 1500x500 with 3 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Match vs No Match analysis\n",
    "==================================\n",
    "\"\"\"\n",
    "\n",
    "# Create status column\n",
    "df['status'] = df.apply(\n",
    "    lambda x: 'No Match' if (\n",
    "        pd.notna(x['description']) and 'No matching' in str(x['description'])\n",
    "    ) or x.get('is_nomatch', 0) == 1 else 'Matched',\n",
    "    axis=1\n",
    ")\n",
    "\n",
    "# Aggregate by status only\n",
    "df_agg = df.groupby('status').agg(\n",
    "    count=('uuid', 'count'),\n",
    "    avg_ascensionists=('ascensionist_count', 'mean'),\n",
    "    avg_difficulty=('display_difficulty', 'mean')\n",
    ").reset_index()\n",
    "\n",
    "status_order = ['Matched', 'No Match']\n",
    "status_colors = {'Matched': 'teal', 'No Match': 'coral'}\n",
    "\n",
    "# Plot\n",
    "fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n",
    "\n",
    "for ax, metric, title in zip(axes, ['count', 'avg_difficulty', 'avg_ascensionists'], \n",
    "                               ['Total Climbs', 'Average Difficulty', 'Avg Ascensionists']):\n",
    "    \n",
    "    sns.barplot(\n",
    "        data=df_agg,\n",
    "        x='status',\n",
    "        y=metric,\n",
    "        hue='status',\n",
    "        legend=False,\n",
    "        order=status_order,\n",
    "        palette=status_colors,\n",
    "        ax=ax,\n",
    "        width=0.5\n",
    "    )\n",
    "    \n",
    "    ax.set_title(title, fontsize=12)\n",
    "    ax.set_xlabel('')\n",
    "    ax.grid(axis='y', alpha=0.3)\n",
    "\n",
    "# Y-axis labels for difficulty plot\n",
    "yticks = [11, 13, 15, 17, 19, 21, 23]\n",
    "ylabels = [grade_mapping.get(t, f\"V{t-10}\") for t in yticks]\n",
    "axes[1].set_yticks(yticks)\n",
    "axes[1].set_yticklabels(ylabels)\n",
    "axes[1].set_ylim(bottom=10)\n",
    "\n",
    "# Add value labels on bars\n",
    "for ax in axes:\n",
    "    for p in ax.patches:\n",
    "        if ax == axes[1]:  # Difficulty plot - show boulder_grade\n",
    "            height = p.get_height()\n",
    "            rounded_diff = round(height)\n",
    "            boulder_grade = grade_mapping.get(rounded_diff, f\"V{rounded_diff - 10}\")\n",
    "            ax.annotate(\n",
    "                boulder_grade,\n",
    "                (p.get_x() + p.get_width() / 2, height),\n",
    "                ha='center',\n",
    "                va='bottom',\n",
    "                fontsize=10,\n",
    "                fontweight='bold'\n",
    "            )\n",
    "        else:  # Other plots - show numeric values\n",
    "            fmt = f'{p.get_height():,.0f}' if ax == axes[0] else f'{p.get_height():.1f}'\n",
    "            ax.annotate(\n",
    "                fmt,\n",
    "                (p.get_x() + p.get_width() / 2, p.get_height()),\n",
    "                ha='center',\n",
    "                va='bottom',\n",
    "                fontsize=10\n",
    "            )\n",
    "\n",
    "plt.suptitle('Match vs No Match Climbs (All Layouts)', fontsize=14, y=1.02)\n",
    "plt.tight_layout()\n",
    "plt.savefig('../images/01_climb_stats/match_vs_nomatch.png', dpi=150, bbox_inches='tight')\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6f51156a",
   "metadata": {},
   "source": [
    "So we gather the following about \"no match\" climbs:\n",
    "\n",
    "- they are far fewer than \"match\" climbs,\n",
    "- they are on average harder than \"match\" climbs,\n",
    "- and that they have quite a bit less ascensionists on average."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 15,
   "id": "21e12faa",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th>status</th>\n",
       "      <th>Matched</th>\n",
       "      <th>No Match</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>avg_ascensionists</th>\n",
       "      <td>56.16</td>\n",
       "      <td>13.84</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>avg_difficulty</th>\n",
       "      <td>19.02</td>\n",
       "      <td>21.75</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>count</th>\n",
       "      <td>236234.00</td>\n",
       "      <td>66275.00</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "status               Matched  No Match\n",
       "avg_ascensionists      56.16     13.84\n",
       "avg_difficulty         19.02     21.75\n",
       "count              236234.00  66275.00"
      ]
     },
     "execution_count": 15,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Match vs No Match Summary\n",
    "==================================\n",
    "\"\"\"\n",
    "\n",
    "\n",
    "summary = df_agg.pivot_table(\n",
    "    columns='status',\n",
    "    values=['count', 'avg_difficulty', 'avg_ascensionists']\n",
    ").round(2)\n",
    "\n",
    "summary"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "de8eb20e",
   "metadata": {},
   "source": [
    "---"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "754abf39",
   "metadata": {},
   "source": [
    "# Prolific statistics\n",
    "\n",
    "Here we will take note of some prolific statistics: what are the most popular climbs and who are the most popular setters?"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "935c0ea3",
   "metadata": {},
   "source": [
    "## Most popular climbs"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 16,
   "id": "42f93d59",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>climb_name</th>\n",
       "      <th>setter_username</th>\n",
       "      <th>angle</th>\n",
       "      <th>boulder_grade</th>\n",
       "      <th>ascensionist_count</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>You Don’t Know Me</td>\n",
       "      <td>JustinKo</td>\n",
       "      <td>40</td>\n",
       "      <td>6a/V3</td>\n",
       "      <td>77383</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>Floats Your Boat</td>\n",
       "      <td>will_avelar</td>\n",
       "      <td>40</td>\n",
       "      <td>6a/V3</td>\n",
       "      <td>76569</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>Bell of the Wall</td>\n",
       "      <td>KilterStudio</td>\n",
       "      <td>40</td>\n",
       "      <td>6b/V4</td>\n",
       "      <td>76388</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>Lack of Faith</td>\n",
       "      <td>KilterStudio</td>\n",
       "      <td>40</td>\n",
       "      <td>6c/V5</td>\n",
       "      <td>67584</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>5</th>\n",
       "      <td>Evan Williams</td>\n",
       "      <td>juligris</td>\n",
       "      <td>40</td>\n",
       "      <td>5a/V1</td>\n",
       "      <td>57212</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>6</th>\n",
       "      <td>Drawing the Priest</td>\n",
       "      <td>griffinwhiteside</td>\n",
       "      <td>40</td>\n",
       "      <td>7a/V6</td>\n",
       "      <td>55975</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>7</th>\n",
       "      <td>swooped</td>\n",
       "      <td>jwebxl</td>\n",
       "      <td>40</td>\n",
       "      <td>7a/V6</td>\n",
       "      <td>55638</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>8</th>\n",
       "      <td>Tap Tap Tap</td>\n",
       "      <td>will_avelar</td>\n",
       "      <td>40</td>\n",
       "      <td>6a+/V3</td>\n",
       "      <td>53566</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>9</th>\n",
       "      <td>Priest Drawn</td>\n",
       "      <td>griffinwhiteside</td>\n",
       "      <td>40</td>\n",
       "      <td>6c+/V5</td>\n",
       "      <td>49587</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>10</th>\n",
       "      <td>Pinball</td>\n",
       "      <td>Byrons</td>\n",
       "      <td>40</td>\n",
       "      <td>6b/V4</td>\n",
       "      <td>47250</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>11</th>\n",
       "      <td>Can You Ear Me Now?</td>\n",
       "      <td>KilterStudio</td>\n",
       "      <td>40</td>\n",
       "      <td>6b+/V4</td>\n",
       "      <td>46803</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>12</th>\n",
       "      <td>kevin noooo</td>\n",
       "      <td>jwebxl</td>\n",
       "      <td>40</td>\n",
       "      <td>6c+/V5</td>\n",
       "      <td>45189</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>13</th>\n",
       "      <td>Sour Gummi Worms</td>\n",
       "      <td>martiiiin</td>\n",
       "      <td>40</td>\n",
       "      <td>5c/V2</td>\n",
       "      <td>44771</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>14</th>\n",
       "      <td>Scared em Off</td>\n",
       "      <td>KilterStudio</td>\n",
       "      <td>40</td>\n",
       "      <td>6b+/V4</td>\n",
       "      <td>41769</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>15</th>\n",
       "      <td>The Little Squeeze</td>\n",
       "      <td>bkbskilterboard</td>\n",
       "      <td>40</td>\n",
       "      <td>7a/V6</td>\n",
       "      <td>41115</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "             climb_name   setter_username  angle boulder_grade  \\\n",
       "1     You Don’t Know Me          JustinKo     40         6a/V3   \n",
       "2      Floats Your Boat       will_avelar     40         6a/V3   \n",
       "3      Bell of the Wall      KilterStudio     40         6b/V4   \n",
       "4         Lack of Faith      KilterStudio     40         6c/V5   \n",
       "5         Evan Williams          juligris     40         5a/V1   \n",
       "6    Drawing the Priest  griffinwhiteside     40         7a/V6   \n",
       "7               swooped            jwebxl     40         7a/V6   \n",
       "8           Tap Tap Tap       will_avelar     40        6a+/V3   \n",
       "9          Priest Drawn  griffinwhiteside     40        6c+/V5   \n",
       "10              Pinball            Byrons     40         6b/V4   \n",
       "11  Can You Ear Me Now?      KilterStudio     40        6b+/V4   \n",
       "12          kevin noooo            jwebxl     40        6c+/V5   \n",
       "13     Sour Gummi Worms         martiiiin     40         5c/V2   \n",
       "14        Scared em Off      KilterStudio     40        6b+/V4   \n",
       "15   The Little Squeeze   bkbskilterboard     40         7a/V6   \n",
       "\n",
       "    ascensionist_count  \n",
       "1                77383  \n",
       "2                76569  \n",
       "3                76388  \n",
       "4                67584  \n",
       "5                57212  \n",
       "6                55975  \n",
       "7                55638  \n",
       "8                53566  \n",
       "9                49587  \n",
       "10               47250  \n",
       "11               46803  \n",
       "12               45189  \n",
       "13               44771  \n",
       "14               41769  \n",
       "15               41115  "
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Most popular climbs\n",
    "==================================\n",
    "\"\"\"\n",
    "\n",
    "# The ascensionist_count column will allow us to easily deduce the top 15 climbs. \n",
    "\n",
    "# Create a DataFrame with the top 15 climbs\n",
    "df_popular_climbs = df.sort_values(by='ascensionist_count', ascending=False).head(15).reset_index(drop=True)\n",
    "\n",
    "# Appropriate index\n",
    "df_popular_climbs.index = df_popular_climbs.index + 1\n",
    "\n",
    "\n",
    "display(df_popular_climbs[['climb_name', 'setter_username', 'angle', 'boulder_grade', 'ascensionist_count']])\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9d70aa42",
   "metadata": {},
   "source": [
    "It's unsuprising that every one of these climbs is at 40° given that 40° is the most popular angle, by a long shot.\n",
    "\n",
    "What about an angle-agnostic analysis? What are the top climbs amonst all angles?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 17,
   "id": "404c67aa",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>climb_name</th>\n",
       "      <th>total_ascensionists</th>\n",
       "      <th>avg_difficulty</th>\n",
       "      <th>avg_boulder_grade</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>Floats Your Boat</td>\n",
       "      <td>189978</td>\n",
       "      <td>13.616047</td>\n",
       "      <td>5b/V1</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>Lack of Faith</td>\n",
       "      <td>163229</td>\n",
       "      <td>18.174413</td>\n",
       "      <td>6b/V4</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>swooped</td>\n",
       "      <td>150554</td>\n",
       "      <td>18.679320</td>\n",
       "      <td>6b+/V4</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>Bell of the Wall</td>\n",
       "      <td>108296</td>\n",
       "      <td>14.111320</td>\n",
       "      <td>5b/V1</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>5</th>\n",
       "      <td>Scared em Off</td>\n",
       "      <td>108158</td>\n",
       "      <td>16.322120</td>\n",
       "      <td>6a/V3</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>6</th>\n",
       "      <td>Sour Gummi Worms</td>\n",
       "      <td>107980</td>\n",
       "      <td>14.852447</td>\n",
       "      <td>5c/V2</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>7</th>\n",
       "      <td>Evan Williams</td>\n",
       "      <td>106809</td>\n",
       "      <td>13.423213</td>\n",
       "      <td>5a/V1</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>8</th>\n",
       "      <td>Priest Drawn</td>\n",
       "      <td>105561</td>\n",
       "      <td>18.146887</td>\n",
       "      <td>6b/V4</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>9</th>\n",
       "      <td>You Don’t Know Me</td>\n",
       "      <td>98271</td>\n",
       "      <td>15.933520</td>\n",
       "      <td>6a/V3</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>10</th>\n",
       "      <td>Drawing the Priest</td>\n",
       "      <td>95097</td>\n",
       "      <td>19.594613</td>\n",
       "      <td>6c/V5</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>11</th>\n",
       "      <td>HilDawgyDawg</td>\n",
       "      <td>88297</td>\n",
       "      <td>16.365867</td>\n",
       "      <td>6a/V3</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>12</th>\n",
       "      <td>proj braj</td>\n",
       "      <td>87256</td>\n",
       "      <td>18.352307</td>\n",
       "      <td>6b/V4</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>13</th>\n",
       "      <td>Boot Spur</td>\n",
       "      <td>83453</td>\n",
       "      <td>16.738333</td>\n",
       "      <td>6a+/V3</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>14</th>\n",
       "      <td>Lament of the Steep</td>\n",
       "      <td>75232</td>\n",
       "      <td>16.449960</td>\n",
       "      <td>6a/V3</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>15</th>\n",
       "      <td>Jug Skin</td>\n",
       "      <td>74233</td>\n",
       "      <td>15.726993</td>\n",
       "      <td>6a/V3</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "             climb_name  total_ascensionists  avg_difficulty avg_boulder_grade\n",
       "1      Floats Your Boat               189978       13.616047             5b/V1\n",
       "2         Lack of Faith               163229       18.174413             6b/V4\n",
       "3               swooped               150554       18.679320            6b+/V4\n",
       "4      Bell of the Wall               108296       14.111320             5b/V1\n",
       "5         Scared em Off               108158       16.322120             6a/V3\n",
       "6      Sour Gummi Worms               107980       14.852447             5c/V2\n",
       "7         Evan Williams               106809       13.423213             5a/V1\n",
       "8          Priest Drawn               105561       18.146887             6b/V4\n",
       "9     You Don’t Know Me                98271       15.933520             6a/V3\n",
       "10   Drawing the Priest                95097       19.594613             6c/V5\n",
       "11         HilDawgyDawg                88297       16.365867             6a/V3\n",
       "12            proj braj                87256       18.352307             6b/V4\n",
       "13            Boot Spur                83453       16.738333            6a+/V3\n",
       "14  Lament of the Steep                75232       16.449960             6a/V3\n",
       "15             Jug Skin                74233       15.726993             6a/V3"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Top 15 most popular climbs (angle agnostic)\n",
    "==================================\n",
    "\"\"\"\n",
    "\n",
    "# Aggregate by climb_name (sum counts across all angles)\n",
    "df_agg = df.groupby(['climb_name']).agg(\n",
    "    total_ascensionists=('ascensionist_count', 'sum'),\n",
    "    avg_difficulty=('display_difficulty', 'mean')\n",
    ").reset_index()\n",
    "\n",
    "\n",
    "df_agg['avg_boulder_grade'] = df_agg['avg_difficulty'].round().astype(int).map(grade_mapping)\n",
    "\n",
    "# Sort and select top 15\n",
    "df_popular_climbs_aa = df_agg.sort_values(by='total_ascensionists', ascending=False).head(15).reset_index(drop=True)\n",
    "\n",
    "df_popular_climbs_aa.index = df_popular_climbs_aa.index + 1\n",
    "\n",
    "display(df_popular_climbs_aa)\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cab87caa",
   "metadata": {},
   "source": [
    "## Prolific setters\n",
    "\n",
    "Next, we will make a simple table of the most prolific setters by board."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 18,
   "id": "3053ddc3",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>setter_username</th>\n",
       "      <th>climb_count</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>BKBChicago</td>\n",
       "      <td>826</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>ZiYi1234</td>\n",
       "      <td>819</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>forestfox98</td>\n",
       "      <td>774</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>RyanKing</td>\n",
       "      <td>653</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>5</th>\n",
       "      <td>GirthQuake</td>\n",
       "      <td>646</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>6</th>\n",
       "      <td>RappRocks</td>\n",
       "      <td>510</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>7</th>\n",
       "      <td>Deathscorch17</td>\n",
       "      <td>501</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>8</th>\n",
       "      <td>KilterStudio</td>\n",
       "      <td>500</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>9</th>\n",
       "      <td>jacobosullivan</td>\n",
       "      <td>486</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>10</th>\n",
       "      <td>wjracer</td>\n",
       "      <td>452</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "   setter_username  climb_count\n",
       "1       BKBChicago          826\n",
       "2         ZiYi1234          819\n",
       "3      forestfox98          774\n",
       "4         RyanKing          653\n",
       "5       GirthQuake          646\n",
       "6        RappRocks          510\n",
       "7    Deathscorch17          501\n",
       "8     KilterStudio          500\n",
       "9   jacobosullivan          486\n",
       "10         wjracer          452"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "\"\"\"\n",
    "==================================\n",
    "Top 10 setters\n",
    "==================================\n",
    "\"\"\"\n",
    "\n",
    "# Make a DataFrame for the setters\n",
    "df_agg = df.groupby(['setter_username']).agg(\n",
    "    climb_count=('uuid', 'nunique')\n",
    ").reset_index()\n",
    "\n",
    "df_setters = df_agg.sort_values(by='climb_count', ascending=False).head(10).reset_index(drop=True)\n",
    "\n",
    "df_setters.index = df_setters.index + 1\n",
    "\n",
    "display(df_setters)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "19dd4fad",
   "metadata": {},
   "source": [
    "---"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "085c99ea",
   "metadata": {},
   "source": [
    "# Conclusion\n",
    "\n",
    "At this point we have a board-level and climb-level picture of the dataset. In particular, we now know:\n",
    "\n",
    "- how large the dataset is,\n",
    "- how the grade and angle distributions vary across layouts,\n",
    "- which climbs and setters appear most often,\n",
    "- and where simple descriptive trends begin to show up.\n",
    "\n",
    "That gives us enough context to move from *global statistics* to *hold-level structure*. The next notebook focuses on hold usage patterns and board heatmaps, where we stop asking only **how many climbs there are** and start asking **which physical parts of the board are driving those climbs**."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.14.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
