NSBA Draft Analyticsembargoed · 2026-06-06

DATA_DICTIONARY_PROCESSED.md

Data Dictionary — processed

Processed Data Dictionary

Tidy tables written by scripts/parse_games.py to data/processed/. Source: every game .xlsx under data/raw/games/{nsba1_2022,nsba2_2023,nsba3_2025}/. NSBA4 has no games yet (we are drafting it), so no game data here.


Processed Games

Scoring is STANDARD Science Bowl (NOT the pyramidal Combine scoring): - Toss-up correct = +4 - Toss-up neg (wrong interrupt or wrong toss-up answer) = -4 - A wrong-but-non-penalty buzz is recorded as 0 - Bonus (only after a correct toss-up) = +10

A team's final score = (sum of its toss-up points) + (sum of its bonus points). We reconstruct this from the parsed events and check it against the score the sheet records (see "Reconciliation" below).

Two physical scoresheet layouts

The parser auto-detects which layout a sheet uses.

One row per BUZZ (important)

A single toss-up can carry multiple buzzes: a neg by one team opens the question to the other team, and the sheets sometimes record two cells on the same team in one row. Therefore tossups_long has one row per buzz event, not one per question. Toss-ups nobody buzzed get a single row with team/player_raw null and tossup_points = 0. This is what makes reconciliation exact: summing tossup_points over a team recovers its true toss-up total.

Subject codes

Canonical: b (Biology), ch (Chemistry), cs (Computer Science), ess (Earth/Space), m (Math), p (Physics). NSBA replaced the legacy "Energy" category with Computer Science. NSBA1 still uses an ene slot (legacy Energy) — kept as-is, NOT remapped to cs. A few junk subject strings exist in the raw sheets (jkj, jhg, ban, en, ...) and are passed through unchanged; downstream code should filter to the 6 canonical codes (+ene).


games_meta.csv — one row per game

column meaning
season folder/season: nsba1_2022, nsba2_2023, nsba3_2025
round source workbook stem (e.g. Round 1, RR7, Playoff 1, Final)
game_id season|round|sheetname — unique game key, joins all tables
team_a, team_b team names exactly as written (A = left/home, B = right/away)
score_a, score_b final score as recorded on the sheet
winner team name with higher recorded score, or Tie; null if scores missing
is_playoff bool; true when the workbook name contains playoff/play-in/final
calc_a, calc_b score reconstructed by the parser (toss-ups + bonuses)
reconciles bool; true iff calc_a==score_a and calc_b==score_b

tossups_long.csv — one row per BUZZ (or one per dead toss-up)

column meaning
season, round, game_id game keys
tossup_num question number within the game (1..23/24/25, or higher in long playoffs)
subject subject code (see above); may be blank for unused/dead slots
team team that buzzed; null if nobody buzzed
player_raw buzzer's name exactly as written (nickname/first name); null if no buzz
tossup_points +4 (correct), -4 (neg), or 0 (wrong, no penalty / dead)
is_neg bool; true iff tossup_points == -4

bonuses_long.csv — one row per bonus awarded

column meaning
season, round, game_id game keys
tossup_num the toss-up this bonus followed
subject subject code of that toss-up
team team that received the bonus
bonus_points normally 10; a few corrupted sheets carry other values

game_player_box.csv — one row per (game, player) that buzzed or was on the floor

column meaning
season, round, game_id game keys
team the player's team
player_raw player name exactly as written
toss_points this player's total toss-up points in the game
n_correct number of +4 toss-ups
n_negs number of -4 negs
tossups_present tossups-heard (lineup proxy) if the sheet recorded it, else null

Reconciliation result

181 / 193 games (93.8%) reconcile exactly (calc == recorded score for both teams). All 12 non-reconciling games are due to corrupted/incomplete source sheets, not parser error:

Filter with reconciles == True before any quantitative modeling.

Lineup recoverability (gates a later plus-minus model)

What the sheets let us recover differs sharply by season:

season who BUZZED who was ON THE FLOOR (lineup)
NSBA1 (2022) yes yesPlayer TUH row populated for 75/75 games
NSBA2 (2023) yes partial — TUH present in only 13/64 games
NSBA3 (2025) yes no — paired layout has no per-player TUH; 0/49 games

So a per-tossup, substitution-aware plus-minus model is only fully feasible for NSBA1. For NSBA2 it is possible only on the ~13 games with TUH. For NSBA3 we know who buzzed each toss-up but not who was seated (subs are allowed), so on-floor plus-minus is not recoverable from these sheets — only buzz-attributed stats. tossups_present is the column that encodes this: non-null = lineup known; null = only buzzers known.

Regeneration

/home/david/code/nsba/.venv/bin/python scripts/parse_games.py

Prints counts, the reconciliation rate, and the list of non-reconciling games.


Processed Combine

Tidy tables written by scripts/parse_combine.py to data/processed/. Source: the four combine workbooks under data/raw/combine/ (nsba1_2022, nsba2_2023, nsba3_2025, nsba4_2026 — the last is IN PROGRESS).

The combine is a tryout used to seed the snake draft. Scoring is PYRAMIDAL (NOT the standard Science-Bowl scoring used in games): - 6 categories × 4 questions = 24 questions per player. - Each question's points reflect buzz-earliness: +4 (superpower) / +3 (power) / +2 (correct). A skipped/unanswered question is blank (null) or 0. - A wrong WXYZ multiple-choice answer is a −1 penalty, stored in penalty. - The question number (1–4) roughly encodes difficulty within a category, so it is preserved per-question for a later IRT model. - The combine is GAMEABLE — strong players sometimes "tank" — so treat scores as a seed signal, not ground truth.

Per-season layout quirks (handled by the parser)

season source layout per-question data? categories notes
nsba1_2022 one tab per player; names are nicknames/handles; total cell labelled Cum total yes 5 — NO Computer Science penalty stored as a positive count in the source; parser negates it so points + penalty reconstructs. No Overall sheet, no paid concept.
nsba2_2023 category sheets (Ovr,Bio,Chem,ESS,Math,Physics,CS) no — aggregate only 6 Only per-category totals exist; Ovr sheet is authoritative (its (name,tag) set equals the union of the category sheets). Produces no combine_question_long rows.
nsba3_2025 Overall sheet + one tab per player; total cell labelled Combined yes 6 Overall sheet's 💯/🥇 emoji columns are decorative, not a payment flag, so eligibility is not payment-gated.
nsba4_2026 Overall + PUBLIC Overall + one tab per player; IN PROGRESS yes 6 Overall sheet has a real Paid? boolean. Many players unpaid / no combine yet. eligible = Paid? AND has combine score.

player_raw is kept exactly as written in each source (tab Name cell, falling back to the tab title). Player names are not unique within a season (e.g. nsba2 has two distinct "Owen"/"Aditya"/"Rohan"/"Rishabh", separable by discord_tag). Identity reconciliation across these handles is a separate job.

Category codes

This file uses bio, chem, ess, math, phys, cs (note: longer codes than the games tables, which use b/ch/m/p). NSBA1 has no cs.

combine_question_long.csv — one row per (player, category, question 1–4)

column meaning
season nsba1_2022, nsba3_2025, nsba4_2026 (NOT nsba2 — it has no per-question data)
player_raw player name exactly as written in the player tab
discord_tag discord handle from the tab's Discord cell, if present (else blank)
category bio/chem/ess/math/phys/cs
question_num 1–4; roughly increasing difficulty within the category
points buzz-earliness points (+2/+3/+4), or blank if skipped/unattempted
penalty signed-negative WXYZ penalty (−1), or blank if none. nsba1's positive source counts are negated here so points + penalty = the question's net contribution

combine_player_category.csv — one row per (player, category)

column meaning
season all four seasons
player_raw player name exactly as written
category the 6 (or 5 for nsba1) category codes
category_total that category's total as recorded on the source (already net of penalties)

combine_player_overall.csv — one row per (player)

column meaning
season all four seasons
player_raw player name exactly as written
discord_tag discord handle if available, else blank
overall_score the player's overall combine score (the tab Combined/Cum total cell for tab-based seasons; the Overall column for nsba2)
rank combine rank from the Overall/Ovr sheet, if present (matched by name); else blank
paid True/False for nsba4 (has a Paid? column); blank/None for seasons with no payment concept (nsba1/2/3)
eligible draft eligibility. For nsba4: paid AND has a combine score. For completed seasons (no paid gate): has a combine score

Validation

For nsba3 and nsba4, each player's overall is reconstructed from the per-question points + penalty and checked against the workbook's Overall sheet (matched by name). 0 mismatches where the Overall cell is populated. Additional internal checks (all pass with 0 mismatches): - reconstructed sum == the tab's Combined/Cum total cell — all 91 nsba3, 52 nsba4, and 61 nsba1 tabs; - sum of question points + penalty per category == category_total (nsba1/3/4); - nsba2: each Ovr row's per-category totals sum to its recorded Overall (51/51).

The only apparent "overall != sum-of-categories" cases are nsba2's 4 shared-name pairs noted above — an artifact of aggregating on first-name only; the stored rows are individually correct and distinguished by discord_tag.

Row counts (current run)

file rows by season
combine_question_long.csv 4652 nsba1=1220, nsba3=2184, nsba4=1248 (nsba2: none)
combine_player_category.csv 1469 nsba1=305, nsba2=306, nsba3=546, nsba4=312
combine_player_overall.csv 255 nsba1=61, nsba2=51, nsba3=91, nsba4=52

nsba4 eligibility (in progress): 5 paid + eligible, 1 paid-but-no-combine matched player whose tab is unmatched to the Overall sheet is conservatively left non-eligible.

Regeneration

/home/david/code/nsba/.venv/bin/python scripts/parse_combine.py

Prints per-table and per-season row counts and the validation mismatch list.

Science Bowl baseline (SSB-2026) — natural-roster reference data

Written by scripts/extract_scibowl.py from the scibowl.live local dataset scibowl-org/stats/ssb-2026-combined (schema_version 1, generated 2026-05-15; combined from Johns Hopkins Invitational + Stanford College+ SB + Stanford SB). Raw grain is one row per buzz; we aggregate to stable identities.

Real Science Bowl uses 6 categories — Biology, Chemistry, Physics, Math, Earth/Space (ESS), and Energy. There is no Computer Science (CS is NSBA-only). 5 categories overlap NSBA; Energy is extra. This data is the natural (school-formed) baseline for A16 (coverage) and A18 (natural vs drafted).

Scoring is STANDARD Science Bowl, identical to NSBA games above (TU +4 / neg −4 / end-wrong 0 / bonus +10). Celerity = fraction of the question stem unread at a correct buzz = (word_count − word_index)/word_count, clamped [0,1]; higher = earlier/faster; only defined for correct tossups.

Identity caveat: raw player_id/team_id are PER-GAME identifiers (one person → many ids). We aggregate on (name, team, tournament) for players and (team, tournament) for teams. Validation: every team's reconstructed total_buzz_points exactly equals its recorded game points_for (100% reconcile).

scibowl_player_stats.csv (one row per stable player, 366 rows)

Tossups only. In Science Bowl, bonuses are answered by the team (their buzz rows carry a blank player_id), so bonuses are not individually attributable and appear only in the team table. The player table is therefore a pure tossup profile. - player_name, team_name, tournament — stable identity key. - tu_buzz, tu_correct, tu_neg — tossup buzzes / correct / negs. - tu_conv_pct = tu_correct / tu_buzz. - avg_celerity — mean celerity over the player's correct tossups (blank if none). - tu_net_points — 4·tu_correct − 4·tu_neg (tossup-only; no bonus points here). - nsba_name_guess — conservative exact full-name match to an NSBA combine player (case-insensitive, first+last required); blank if no match. 23 distinct matches. Treat as a hint, not a confirmed link — name collisions are possible. - <cat>_tu_correct, <cat>_points for each of the 6 categories (biology, chemistry, physics, math, earth_space, energy); tossup-only.

scibowl_team_stats.csv (one row per stable team, 84 rows)

scibowl_subject_coverage.csv (one row per team×subject, 504 rows)

Regeneration

/home/david/code/nsba/.venv/bin/python scripts/extract_scibowl.py

Does not modify the scibowl-org repo (read-only source).


Analytical spine: player_season_master.csv + team_season.csv

Written by scripts/build_master.py. This is the backbone downstream analyses read. Built only from GAME data (standard Science-Bowl scoring), with the combine attached as a link, never mixed into the value stats.

Clean game set (the filter everything inner-joins to)

games_meta filtered to reconciles == True AND not(score_a==0 & score_b==0) = 180 games (nsba1=66, nsba2=61, nsba3=53). Note: games_meta has 181 reconciling games; the not-both-zero guard intentionally drops one extra — nsba2 RR10|cryo v caleb, a genuine 0-0 result — leaving 180. This follows the stated guardrail filter exactly. The 12 non-reconciling games (5 inflated real games + corrupted/template/RR11 junk) are excluded. The 4 nsba3 Round-1 games are team-level-only (buzzes recorded but no player names): they contribute to team_season but add zero player rows (240 nsba3 toss-up points live only at team level).

Team-name canonicalization (applied before anything else)

Some teams appear under case/spacing variants that would split their win% and roster (Mount Ellis/mount ellis; no like blue/nolikeblue; nsba3 Chonk/chonk, Zootopia/zootopia). build_master.py folds team names within each season on an alphanumeric-only key and rewrites every team string (in games_meta, tossups_long, bonuses_long, game_player_box) to the most-frequent original spelling for that key. This merged 8 split team-seasons (1 in nsba1, 7 in nsba3), taking team_season from 49 to 41 rows. Conservative: only exact case/whitespace collisions merge; distinct names are never combined.

Identity & value-stat conventions

player_season_master.csv — one row per (canonical_id, season) with GAME data (207 rows)

nsba1=75, nsba2=60, nsba3=72. 181 distinct canonical players (some span seasons).

column meaning
canonical_id, canonical_name, season identity key (joins canonical_players)
raw_names_used ;-joined distinct game raw names this player used that season
discord_tag discord handle from the game-source canonical row, if any
team the player's primary team that season (team of most clean games)
games_played distinct clean games the player appears in
tossups_faced TUH denominator (see conventions above)
tuh_source sheet (all games had recorded TUH), estimated (none did), or mixed
toss_points total toss-up points (reconciles with tossups_long)
n_correct, n_negs count of +4 and −4 toss-ups
neg_rate n_negs / (n_correct + n_negs)
points_per_game toss_points / games_played
pptf toss_points / tossups_faced — the key rate
pts_b … pts_p, pts_cs, pts_ene toss-up points by subject (buzz-attributed)
pptf_b … pptf_p, pptf_cs, pptf_ene per-subject PPTF; denom = that subject's tossups across the player's games
team_bonus_points_season total bonus points the player's team earned that season (bonuses are team-attributed, not individual)
team_win_pct, team_playoff_games team result context (from clean games_meta)
has_combine_link bool; true iff a same-season combine row resolved to this player
combine_overall, combine_rank, combine_paid, combine_eligible same-season combine link (combine is a GAMED ceiling — a seed signal, not value)
combine_bio … combine_phys, combine_cs same-season combine per-category totals (crosswalked: bio=b chem=ch math=m phys=p cs=cs ess=ess; nsba1 has no cs)
likely_returning, grade_or_age, availability_flag, study_engagement, strong_subjects, weak_subjects, tanking_risk qualitative flags from discord_player_signals.csv, merged by canonical_id (handles are nsba4-era; they land on a player's every season via canonical_id)

Combine link: 114 / 207 player-seasons link to combine (nsba1=8/75, nsba2=46/60, nsba3=60/72). nsba1 is low by nature — nsba1 game sheets use nicknames/handles while the combine stores real names, so few resolve. The link is built (a) on unambiguous (season, player_raw) in combine-source canonical rows, then (b) by discord_tag for the 4 nsba2 shared-name pairs whose combine rows were only resolved as game-source rows. Qualitative signals: 17 player-seasons carry a flag (only the 12 nsba4 signal handles that also have game history, spread across their seasons).

team_season.csv — one row per (season, team) (41 rows)

nsba1=12, nsba2=13, nsba3=16.

column meaning
season, team key (team = canonicalized name)
games, wins, win_pct from clean games_meta (win_pct = wins/games)
points_for, points_against summed recorded game scores (placement proxy)
is_playoff_games count of the team's clean games that are playoff games
subject_hhi Herfindahl index of the team's positive toss-up-point shares across subjects (~0.17 = balanced/broad, 1.0 = one-subject). Lower = broader
n_subjects_scored # subjects with >0 net toss-up points (breadth)
share_b … share_p, share_cs, share_ene each subject's share of the team's positive toss-up points
roster_canonical_ids ;-joined canonical_ids that appeared for the team in clean games
roster_size distinct canonical players on the roster

Validation: each season's games sums to exactly 2× clean games (132/122/106); every player's (season, team) exists in team_season; mean subject_hhi = 0.216.

Regeneration

/home/david/code/nsba/.venv/bin/python scripts/build_master.py

Prints clean-game counts, row counts, combine-link counts, and join-loss accounting.


Draft tables (draft_picks.csv, draft_rosters.csv, draft_eligibility.csv)

Source: data/raw/draft/{nsba1_2022_draft, nsba2_2023_draft, nsba3_2025_tournament_hub}.xlsx. Built by scripts/parse_draft.py. Picks are joined to canonical_players.csv (prefer exact discord-tag match; else name+season fuzzy; cross-season name fallback last). Matches are never forced — unmatched picks keep a blank canonical_id and match_confidence = 0.

draft_picks.csv — one row per draft pick

Column Description
season nsba1_2022 / nsba2_2023 / nsba3_2025
overall_pick int. Linear pick number in draft order. 0 = captain/keeper (nsba3 round-1 captains have no linear pick #).
round int, 1-based. See round derivation below.
team drafting team/captain label (see per-season notes)
player_raw drafted player's name/nickname as recorded in the source
discord_tag discord tag/handle if the source provided one (nsba2/nsba3 only; blank for nsba1)
canonical_id matched canonical_players.canonical_id, or blank if unmatched
match_confidence 0.0–1.0. 1.0=exact tag, 0.95=tag stem, 0.9=exact name-in-season, 0.84–0.99=fuzzy name-in-season, 0.7=cross-season name fallback, 0.0=unmatched

Round derivation / inferred team counts (documented): - nsba1_2022 — 12 teams, 6 rounds, 72 picks. Clean arithmetic snake: round = ceil(overall_pick / 12). Round 1 = forward captain order, round 2 reverses, etc. - nsba2_2023 — 12 teams, picks 1–61 (#31 vacant → 60 real picks). round = ceil(overall_pick / 12). Team count inferred from distinct teams on the Draft sheet. - nsba3_2025 — 14 teams. Round taken from the authoritative side-grid in the Draft sheet (captains = round 1; numbered picks 1–73 mapped to rounds 2–7 by the grid), NOT pure arithmetic, because trades make the linear numbering non-uniform.

draft_rosters.csv — one row per (season, team)

Column Description
season season id
team team/captain label
n_picks number of drafted players on the team
canonical_ids ;-joined matched canonical_ids (omits unmatched picks)
player_names ;-joined player names (canonical name where matched, else player_raw)

draft_eligibility.csv — one row per eligible/paid player

Column Description
season nsba2_2023 (from Raw sheet) or nsba3_2025 (from Payment sheet)
player player name (nsba2: resolved via Raw tag→name lookup; may be blank if unresolved)
discord_tag discord tag (nsba2 only; blank for nsba3)
paid bool. nsba2: Paid column x; nsba3: Paid? == Yes
combined blank — combine-participation not tracked in these sheets
draft_eligible bool. nsba2: Draft Eligible column x; nsba3: equals paid (Timeline rules: paid players are draft-eligible)

Per-season data oddities (recorded as-is, not silently corrected)

Regeneration

/home/david/code/nsba/.venv/bin/python scripts/parse_draft.py

Prints per-season pick counts, inferred team counts, match rates + methods, per-team pick-count sanity (4–6 expected), the unmatched list, and eligibility totals.


NSBA Draft Analytics · embargoed until after the SSB draft · ← hub