MinneMUDAC 2025 Playbook

MinneMUDAC 2026 · this year's challenge NEW

This playbook was built on the 2025 case (BBBS Twin Cities). The 2026 competition is a brand-new problem with a new client — but the method you learn here is exactly what you'll run on the 2026 data. This page is your orientation and your personal to-do list on the road to Saturday, October 17, 2026.

Two time-sensitive things (as of mid-September 2026)

DO NOW Make sure your team is registered on Eventbrite — only registered teams get the email link to the kickoff.

THU SEP 17 The challenge & data are revealed at the first Zoom webinar on Thursday, Sep 17. It's recorded, but plan to attend live — your one-month clock starts here.

This year's problem

Client

The Food Group

MinneAnalytics is partnering with The Food Group to take on food insecurity. The specific challenge question and datasets are introduced at the kickoff webinar — they are not public yet.

Main event

Sat · Oct 17, 2026

8 AM–5 PM · St. Catherine University, 2004 Randolph Ave, Saint Paul, MN 55105. Round 1 runs 9 am–noon; finalists present after lunch.

Same three divisions as always — Novice Undergraduate Graduate — with teams of up to 5 students + 1 faculty/staff advisor. Any team with a graduate student is automatically in the Graduate division. Awards: Analytic Acumen, Serendipitous Discovery, and Overall Prediction (all divisions except Novice for the prediction award).

Key dates

WhenWhat happensYour move
Before Thu Sep 17 Registration open on Eventbrite. Only registered teams get the kickoff link. Register the team, pick a division.
Thu Sep 17 First Zoom webinar. Challenge + data introduced. Recorded for all teams. Attend live; get the data; read the challenge doc the same day.
~Sep 17 → Oct 16
(about one month)
Analysis window — teams build their model & story. Run the full workflow in this playbook on the 2026 data.
Sat · Oct 17 Main event, 8 AM–5 PM. Round 1 pitches 9 am–noon; finals after lunch. Pitch 4–6 times (5 min each, 7–12 min with Q&A). Bring photo ID.

What I need to do before Oct 17

Work top to bottom. The first two blocks are the only hard, time-boxed items — the rest is the month of actual project work, and this whole site is the how-to for it.

Now — before Sep 17 Lock in the basics

  • Register the team on Eventbrite — the advisor registers and enters the team name + advisor email. One advisor can cover up to 3 teams; max 3 teams per department.
  • Form the team: up to 5 students + 1 faculty/staff advisor. Blended skill-sets are encouraged. Share the TeamUp join card ↓ so classmates join one pool and get matched on skills, availability & commitment.
  • Choose a division (the advisor picks): Novice, Undergraduate, or Graduate. Any grad student on the team ⇒ Graduate.
  • Traveling 2+ hours? Request Friday-night accommodation during registration — rooms are limited.
  • Set up your tools once — Python & R plus a shared repo. Set up Python & R → · Team GitHub workflow →
  • Warm up on the method with this 2025 case so you're not learning EDA/ETL/modeling on the clock. How an analyst thinks →

Team formation Form your team with TeamUp

Share this card so classmates can join one shared pool — from a phone or a laptop, no app to install. Everyone who enters the same room code gets matched together on skills, availability, and commitment, so you assemble a balanced team instead of just whoever's nearby. The organizer forms the teams once everyone's in.

Room code
MAC

Scan the QR, or paste the link into any browser.

Letters, numbers, - and _ only. The QR, link, and code all update as you type — give everyone the same code so they land in one pool.

Thu Sep 17 Kickoff webinar

  • Attend the Zoom webinar live — the link arrives by email once you're registered. If you miss it, watch the recording ASAP; the clock is already running.
  • Get the 2026 data and read the official challenge question + rubric the same day.
  • Restate the challenge in one plain sentence, then turn it into testable questions. See how →

Sep 17 → Oct 16 Build (~one month)

  • Inspect the data first — grain, size, missingness, leakage — before any charts. Step 0 · Inspect any dataset →
  • Run the loop: EDA → ETL / feature engineering → model → communicate, mapping every deliverable to the rubric. The rubric = your grade →
  • If your division predicts a target, build + validate the model and prepare the required prediction/submission file.
  • Draft the data story — every chart answers a business question; every model implies an action they could take Monday morning. Pitfalls & judge tips →

Oct 17 · event day Pitch

  • Rehearse the 5-minute pitch — you'll give it 4–6 times to rotating judge teams (7–12 min each with Q&A). Balance it for business, technical, and academic judges.
  • Bring a driver's license or government photo ID — it's required for entry.
  • The event runs 8 AM–5 PM; arrive for the 9 am–noon first round, finalists present to all judges after lunch.
How to use this 2025 playbook for the 2026 challenge The client, data, and questions change every year — the workflow doesn't. Treat every "BBBS" example here as a template: swap in The Food Group's data and questions, and the same EDA → ETL → text/feature work → model → judge-ready story still applies. Start with How an analyst thinks and Step 0 · Inspect any dataset.

The MinneMUDAC 2025 Analytics Playbook

A step-by-step teaching case that takes you from a folder of raw files to a judge-ready data story — using the real 2025 MinneMUDAC datasets for Big Brothers Big Sisters Twin Cities (BBBS). Written for students who have not done a full analytics project before.

Competing in 2026? This site's worked examples use the 2025 case, but the workflow is what matters — and the 2026 challenge (The Food Group · food insecurity) lands at the Thu Sep 17 webinar for the Oct 17 event. See ★ 2026 Challenge & my to-do for this year's dates and your checklist.
What you'll be able to do by the end Load messy real-world Excel files, clean and reshape them (ETL), explore them (EDA), mine free-text notes, build a predictive model scored by RMSE, and package the whole thing to match exactly what the judges' rubric rewards — in both Python and R.

Three groups, one dataset, three depths

The competition splits students into three divisions by experience. This playbook is organized the same way, as three workshops that build on each other. Do them in order — each one assumes you finished the one before.

Workshop A · Novice

Explore & explain

Clean the data and describe what makes a match succeed. Pure EDA — no coding-heavy modeling. Response variables: Match Length & Closure Reason.

Open Workshop A →
Workshop B · Undergraduate

Add text & a prediction

Everything in A, plus mining the check-in notes, call cadence, and a predictive model for Match Length scored by RMSE on 300 hidden matches.

Open Workshop B →
Workshop C · Graduate

Deep NLP + external data

Everything in A & B, plus sentiment/emotion, modeling Closure Reason, joining U.S. Census data, and an early-intervention strategy.

Open Workshop C →

How to use this site

  1. Read the orientation pages first

    Understand who the client is, what the files contain, and how you're graded. Skipping this is the #1 mistake.
  2. Set up your tools

    Follow Set up Python & R once. Every code block has a Python / R toggle and a copy button.
  3. Learn the mental model

    How an analyst thinks is the reusable EDA → ETL → model → communicate loop you'll apply everywhere.
  4. Work your division's workshop

    Follow the numbered steps. Each ends with a rubric-mapped deliverable checklist so you know when you're done.
Important framing This is a consulting project, not a homework problem. BBBS is a real nonprofit that matches youth ("Littles") with adult mentors ("Bigs"). Every chart must answer a business question, and every model must lead to an action they could take on Monday morning.

The client & the question

You can't analyze what you don't understand. Before a single line of code, learn the client's world and restate the challenge in plain language.

Who is the client?

Big Brothers Big Sisters Twin Cities is the largest and oldest youth mentoring organization in the greater Twin Cities. They pair Littles (youth, roughly ages 8–13) with Bigs (adult volunteer mentors) through three kinds of programs:

Community-basedSite-based (e.g. at a school) Workplace / site-based facilitated

A "match" is one Big paired with one Little. BBBS wants matches that are healthy and long-lasting, because longer, stronger mentoring relationships produce better outcomes for kids.

The business problem, in one sentence

"Help us understand what makes a match succeed — and warn us early when a match is about to fall apart — so we can act before it closes."

The two things they measure ("response variables")

VariableTypeMeaningWhy BBBS cares
Match LengthNumber (months) How long the match lasted (or has lasted so far). Longer matches = more mentoring impact. This is the thing UG & Grad must predict.
Closure ReasonCategory Why a match ended — e.g. "Volunteer: Moved", "Child: Graduated", "Successful match closure". Tells them which problems to prevent. Grad models this and designs interventions.

What does "a successful match" even mean?

The challenge gives you four valid definitions of success. A strong team picks one (or reports several), states it explicitly, and analyzes against it. Don't leave this vague — judges reward a clear, defended definition.

  1. Still active

    The match has not closed (Stage = "Active").
  2. Lasts a long time

    A high Match Length (e.g. above the median of ~16.8 months, or above a policy target like 12 months).
  3. Closed because the Little grew up

    Closure Reason relates to the Little aging out / graduating — a natural, positive ending.
  4. Officially a "Successful match closure"

    That exact value in Closure Reason.
Analyst move Create a single tidy success flag from your chosen definition early, and reuse it in every chart and model. Consistency reads as competence.

Restate the challenge as questions you can test

Translate the prompt into concrete, answerable questions. This list is your project plan.

DivisionQuestion you must answerAnalytics task
NovHow do Match Length & Closure Reason vary by Program Type? Over time? By demographics?Descriptive EDA, group comparisons
NovDoes alignment (same gender, shared hobbies) relate to success?Feature engineering + comparison
UGDoes call cadence (days between check-ins) affect Match Length?Reshape to note level, feature build
UGWhich words/sentiments in check-in notes relate to Match Length? Early vs late?Text mining, sentiment
UGPredict Match Length for 300 hidden matches (min RMSE).Regression modeling
GradWhat text/emotion signals relate to Closure Reason? Design an early-intervention model.NLP, classification, strategy
GradWhat Census context (by Block Group) helps explain match success?External data join

Understand the data

The single most important — and most skipped — step. Know the files, their grain (what one row means), and their quirks before you touch a model.

The files in the folder

FileWho uses itRowsGrain (1 row = )Notes
Novice.xlsxNovice3,275 one match66 fields. No note text or completion dates.
Training-Restated.xlsxUG Grad39,345 one check-in note for a match68 fields. 3,275 matches × many notes each (long format).
Test-Truncated-Restated.xlsxUG Grad2,566 one check-in note for a match63 fields. 300 matches you must predict. Later notes deliberately removed.
DataDictionary.xlsxeveryone68one field definitionYour map to every column. Keep it open.
Use the "Restated" files (UG/Grad). An address accidentally appeared in some open-text fields, so the organizers republished cleaned data with -Restated in the name. Delete the old Training.xlsx/Test-Truncated.xlsx and use only the restated versions. (Novice data was unaffected.)

The most important idea: grain (long vs wide)

Novice.xlsx — wide / "one row per match"

3,275 rows = 3,275 matches. Each match's attributes and its final Match Length sit on one line. Easy to analyze directly.

Training / Test — long / "one row per note"

39,345 rows but only 3,275 matches — because a match gets many check-in calls over time (median ~7 notes, up to 32). The match's fixed attributes repeat on every note row. You must decide the grain you model at.

Why this matters If you feed the long file straight into a model of Match Length, matches with more calls get more "votes" and you leak the answer (more notes ≈ longer match). UG/Grad workshops show the fix: engineer note-level features, then collapse to one row per match before modeling.
IDs repeat — rematches are real, not duplicates. The same Little ID can appear with a new Big ID (a rematch after an earlier match closed), and experienced Big IDs take on multiple Littles. A repeated ID is history, not a row to delete. It also unlocks features like "experienced Big" and "Little is a rematch" — see From the client Q&A.

The response variables at a glance (real numbers)

Match Length (months)

StatValue
mean23.4
median16.8
std dev19.7
min / max0 / 97.2

Right-skewed: most matches are short, a long tail runs for years. That skew shapes your modeling choices (see UG workshop).

Match Stage (Novice)

StageCount
Closed2,486
Active774
Pending Closure15

Active matches have no Closure Reason (that's the 786 blanks) — that's expected, not missing data to "fix".

Field families (68 fields, grouped so they're not scary)

FamilyExample fieldsUse it for
IDs & keysMatch ID 18Char, Little ID, Big IDJoining & counting, never as predictors
ResponseMatch Length, Closure Reason, Stage, Closure DetailsWhat you explain/predict
Big demographicsBig Age, Big Gender, Big Level of Education, Big Occupation, Big Race/EthnicityWho the mentor is
Little demographicsLittle Gender, Little Participant: Race/Ethnicity, Little BirthdateWho the youth is
Interests (align!)Big Contact: Interest Finder - Hobbies, Little Contact: Interest Finder - SportsShared-interest features
Program & processProgram Type, Match Activation Date, Big Days Interview to MatchContext & timing features
Free textMatch Support Contact Notes, Rationale for Match, Closure DetailsUG/Grad text mining
Geography… Census Block Group (Little home, Big home, Big employer)Grad Census join

The truncation trick (why the Test set is "unfair" on purpose)

The difference between a match's activation date and its latest note date is basically its length — so if the test set kept all the notes, predicting Match Length would be trivial and meaningless. To stop that, the organizers truncated the test set: they deleted the most recent check-in notes for most of the 300 test matches.

Consequence for you: your model must predict length from early-relationship signals, not from "how many notes exist." Build features that would be knowable partway through a match. The rubric explicitly rewards going beyond just counting notes.

Load the data and prove you understand it

Your very first script should print the shape, the grain, the response distribution, and missingness. Here's that "hello data" script in both languages.

# hello_data.py — first look at Novice.xlsx
import pandas as pd

df = pd.read_excel("Data/Novice.xlsx")

print("shape:", df.shape)                 # (3275, 66)
print("1 row = 1 match? unique IDs =", df["Match ID 18Char"].nunique())

# response variable summary
print(df["Match Length"].describe())
print(df["Stage"].value_counts(dropna=False))

# which columns are the most empty? (top 12)
missing = (df.isna().mean() * 100).sort_values(ascending=False)
print(missing.head(12).round(1))
# hello_data.R — first look at Novice.xlsx
library(readxl); library(dplyr)

df <- read_excel("Data/Novice.xlsx")

dim(df)                                  # 3275   66
n_distinct(df$`Match ID 18Char`)          # 3275 -> 1 row per match

summary(df$`Match Length`)
count(df, Stage)

# missingness by column (top 12)
sort(colMeans(is.na(df)) * 100, decreasing = TRUE)[1:12]
Deliverable seed The output of this script is literally slide 2 of your presentation: "Here's the data, here's the grain, here's what we're explaining."

The rubric = your grade

Judges score you on a fixed rubric with weighted categories. The weights tell you exactly how much time and how many slides each topic deserves. Reverse-engineer it.

Golden rule The rubric is the specification. If a category is worth 25%, roughly a quarter of your presentation should serve it. Print your division's rubric and check off each row before you submit.

Novice

Completeness & breadth of outcomes25%
Impact of important factors20%
Appropriateness of analytical methods20%
Creativity & innovation20%
Communication / team synergy15%

Undergraduate

Completeness & breadth of outcomes25%
Accuracy of prediction (RMSE)15%
Impact of important factors15%
Appropriateness of analytical methods15%
Creativity & innovation15%
Communication / team synergy15%

Graduate

Completeness & breadth of outcomes25%
Accuracy of prediction (RMSE)15%
Impact of important factors15%
Appropriateness of analytical methods15%
Creativity, innovation & external data15%
Communication / team synergy15%

What each category actually asks for

CategoryJudges are really asking…How to win it
Completeness & breadth (25%)Did you answer every part of the prompt for your division?Make a checklist from the challenge questions; address each explicitly on a slide.
Impact of important factors (15–20%)Do you know which variables matter and by how much?Quantify effects (e.g. "Community matches last 6 months longer on average"), not just "X is related to Y".
Appropriateness of methods (15–20%)Did you use the right tool correctly, and can you defend it?Justify each method in one sentence; show you checked its assumptions.
Creativity / external data (15–20%)Did you go beyond the obvious?Novel features (interest alignment, note sentiment), Census join (Grad).
Accuracy of prediction (15%, UG/Grad)Is your RMSE competitive?Best RMSE in the division scores 4, worst scores 1. A missing/invalid file = automatic 1.
Communication / synergy (15%)Is the story clear, and does the whole team own it?One narrative, every member speaks, 5–7 min, no jargon dumps.
Reality check: the best model doesn't always win. BBBS judges confirmed that top-RMSE teams have lost to teams with a compelling story, and mediocre-model teams have reached the finals on clear business value. RMSE is only 15%. Get a competitive number, then pour your energy into insight, actions, and communication. More on the judging dynamics in From the client Q&A.

Logistics that quietly cost points

  • Presentation is 5–7 minutes, judged 4–6 times; expect interruptions with questions.
  • No school branding (logos, lanyards, clothing) — impartiality rule.
  • UG & Grad must submit exactly 300 predictions in Testset_Predictions_Submit.csv. A wrong/short file = prediction score of 1.
  • Judging panel = a business pro + a technical pro + an academic. Speak to all three: impact, method, and rigor.

Set up Python & R

Do this once. You can use either language for the whole project — this playbook shows both so you can pick what your team knows.

Option 1 — Python (pandas ecosystem)

# In a terminal. Python 3.10+ recommended.
pip install pandas numpy openpyxl matplotlib seaborn scikit-learn
# Text mining (UG/Grad):
pip install nltk scikit-learn wordcloud vaderSentiment
# Optional stronger models:
pip install xgboost lightgbm

Option 2 — R (tidyverse ecosystem)

# In the R console / RStudio.
install.packages(c("tidyverse", "readxl", "janitor", "lubridate",
                   "tidytext", "textdata", "tidymodels", "ranger", "vip"))

A project folder that keeps you sane

Folder / fileWhat goes there
Data/The original xlsx files (never edit these by hand).
01_clean.py / .RETL: load, clean, engineer features, save a tidy file.
02_eda.py / .RCharts & tables that answer the challenge questions.
03_text.py / .RUG/Grad: note mining & sentiment.
04_model.py / .RUG/Grad: train, validate, predict, write submission.
outputs/Saved figures & the final Testset_Predictions_Submit.csv.
Reproducibility = credibility. If a judge asks "can you re-run this?", a clean numbered pipeline answers yes. Set a random seed (random_state=42 / set.seed(42)) everywhere so results don't change between runs.
Starter scripts included. This project ships runnable starters in the starter_code/ folder (python_starter.py, r_starter.R) that load the real files and reproduce the numbers on the Data page. Point them at your Data/ folder and run.

The universal pipeline (all stages in one script)

Prefer to see the whole process wired together? starter_code/pipeline.py and starter_code/pipeline.R run every stage end-to-end on the real files:

load→ ETL / feature engineering → EDA→ text mining → model (RMSE)→ submission file

  • Config-driven — set DATA_DIR and TEAM_ID at the top, then run.
  • Fail-soft — each stage is wrapped; a missing file or library skips that stage with a message instead of crashing.
  • Reusable — the same feature functions work on the Novice file (one row per match) and the long Training/Test files (one row per note) after an automatic collapse.
  • Safe — read-only on your data; everything it writes lands in starter_code/outputs/ (EDA report + figure, and the 300-row Testset_Predictions_Submit.csv).
pip install pandas numpy openpyxl matplotlib scikit-learn
# optional: pip install seaborn vaderSentiment
python starter_code/pipeline.py
# install.packages(c("tidyverse","readxl","janitor","lubridate","tidytext","textdata","ranger"))
Rscript starter_code/pipeline.R
What runs depends on your files. With only Novice.xlsx you get EDA + a match-level model demo; add Training-Restated.xlsx to unlock the note, cadence, and text features and the real model; Test-Truncated-Restated.xlsx makes it write the 300-row submission. The Python pipeline is verified end-to-end on the real data; the R script mirrors it step for step.

How an analyst thinks

Every workshop below is the same reusable loop. Learn it once and you can attack any dataset, not just this one.

  1. Frame — turn the ask into questions

    Write the business question, the response variable, and a testable list of sub-questions (you did this on the client page). No framing → aimless charts.
  2. ETL — get to tidy data

    Extract (load the files), Transform (fix types, parse dates, handle missing values, engineer features, set the right grain), Load (save one clean table). Rule of tidy data: one row per unit, one column per variable.
  3. EDA — look before you model

    Univariate (each variable alone), then bivariate (variable vs the response), then multivariate. Distributions, group comparisons, trends over time, correlations. EDA is where the insights the rubric rewards come from.
  4. Model (UG/Grad) — only after EDA

    Split train/validation, pick a model matched to the question, tune it, and measure honestly (RMSE for Match Length). Never judge a model on the data it trained on.
  5. Interpret — from output to action

    Translate coefficients / feature importance into plain-language recommendations BBBS could act on. "So what?" is the whole point.
  6. Communicate — the data story

    5–7 slides: problem → data → what we found → what to do → how confident we are. Weighted by the rubric.

ETL vs EDA — two different jobs (people confuse these)

Beginners often blur these together. They're distinct crafts that happen in sequence and then loop. ETL builds the table; EDA interrogates it; ML learns from it.

Raw, scattered data3 xlsx files + Census ETL: clean & reshape Analysis matrixone tidy table EDA: explore Patterns & featurescharts, hypotheses ML Models & insightspredictions, actions
DimensionETL  engineering EDA  discovery
Core purpose Move, reshape, clean, and aggregate data into a usable schema. Inspect distributions, find patterns, diagnose anomalies, form hypotheses.
Primary actions Merge tables, parse dates, fix types, normalize text, call APIs. Summary stats, correlations, boxplots, histograms, scatter plots, tests.
Output A clean, reliable table (a processed .csv/.parquet). Charts, summary tables, narrative insights, feature-engineering ideas.
Mindset Deterministic / automated — it's a pipeline that reruns the same way. Human-in-the-loop — guided by curiosity and business context.

You don't pick one — they interleave

A real project bounces between building and exploring several times before any model is trained:

  1. Micro-ETL

    Load raw files, fix column types, format timestamps, resolve delimiters.
  2. First EDA

    Look at missing values, class balance, and raw distribution skew.
  3. Deep ETL (feature engineering)

    Aggregate the longitudinal call logs to match level, join external tables, compute rolling/cadence metrics.
  4. Focused EDA

    Inspect relationships between your engineered features and the target.
  5. Machine learning

    Train models on the features that survived EDA — then loop back if they underperform.

How that maps to this BBBS challenge

Training-Restated~39k call notes Test-Truncated~2.5k notes Census (Grad)block group ETL Match-level dataset1 row per match EDA ML / survival

1 · ETL stage

  • Extract rows from the two xlsx files + Census block-group tables.
  • Transform: parse ;-lists (hobbies, sports) into multi-hot / Jaccard overlap scores; aggregate call logs to match level (cadence, sentiment trajectory); replicate the test truncation on your CV folds to prevent temporal leakage.
  • Load: write train_features.parquet & test_features.parquet.

2 · EDA stage

  • Target profiling: Match Length distribution, skew, the ~12-month retention cliff.
  • Factor analysis: Match Length across Program Type (Site vs Community); how demographic concordance & interest overlap relate to duration.
  • Text signals: keywords / sentiment shifts in the notes tied to early closure.

3 · ML stage

  • Integrate demographic flags + cadence stats + NLP sentiment + Census into one matrix.
  • Train gradient-boosted trees (LightGBM / XGBoost / CatBoost) or a survival model, using GroupKFold on Match ID 18Char.
  • Tune directly for RMSE on predicted Match Length.
The leakage trap worth repeating: the test set was truncated, so your validation folds must be too. Truncate the notes in each training fold the same way (drop the most recent notes) before you compute features — otherwise your local RMSE will look great and collapse on the real test set. And group by Match ID 18Char in cross-validation so no match appears in both train and validation folds.

Handling missing data — a reusable playbook

Real datasets are full of holes (this one has columns 70–99% empty). Don't guess — match the amount and reason for missingness to a strategy. Decide per column, not globally.

SituationDo thisWhy
Column > ~70–80% missingUsually drop it (unless it's known to be critical)Too sparse to impute reliably
Numeric, a few % missingImpute median (inside the pipeline), optionally add a was_missing flagMedian resists skew; the flag keeps "missingness" as signal
Categorical missingAdd an explicit "Unknown" level"Not provided" is itself information
Multi-select / interest lists blankTreat as empty set → 0 shared / all-zero one-hotBlank means "none selected", not an error
The target is missingDrop those rows from training (never impute a target)You can't learn from an unknown answer
Missing because it's an outcome (e.g. Closure Reason on Active matches)Leave it — it's structural, not an errorActive matches simply haven't closed
Impute inside the pipeline, per fold — never before splitting. If you fill missing values using the whole dataset's median and then split, information leaks from validation into training. Put imputation in the model pipeline (as this site's code does with SimpleImputer / step_impute_*) so each fold imputes from its own data.
The "was-missing" flag trick: before imputing a column, add a companion 0/1 column marking which rows were missing. Sometimes the fact a field was blank (e.g. no education recorded) predicts the outcome better than the value itself.

Picking a method from the question's shape

The question sounds like…Response typeReach for
"What's the distribution of ___?"anyhistogram, boxplot, summary stats
"Does ___ differ across groups?"number by categorygrouped boxplots, means, ANOVA / t-test
"Has ___ changed over time?"number by dateline chart of yearly means, trend
"Are two categories related?"category × categorycrosstab, stacked bars, chi-square
"Predict a number (Match Length)"numberlinear / random forest / gradient boosting regression → RMSE
"Predict / explain a category (Closure Reason)"categorylogistic / random forest classification
"What's in this free text?"texttokenize, TF-IDF, sentiment, topic keywords

The Which method, when? reference expands this with code pointers.

Step 0 · Inspect any dataset ★

Before you clean, engineer, or model anything, you look. Initial inspection is a read-only triage that tells you what the data is — and what you find here decides every method you'll use next. This toolkit works on any dataset, not just this one.

Inspection ≠ Execution. Commands like .info(), .describe(), .isnull().sum(), and .nunique() are for human eyes — they check file health, names, missingness, and types. They do not transform the data. The ETL / feature-engineering pipeline (the workshops) is what turns what you discovered into a model-ready table.
Phase 1 · Inspectlook & triage (read-only) informs what code to write Phase 2 · ExecuteETL · NLP · modeling

Phase 1 — your notebook (inspect)

  • Check the schema with .info() / glimpse().
  • Spot missingness with .isnull() / is.na().
  • Discover repeated Match IDs with .nunique() / n_distinct().

Phase 2 — the pipeline (execute)

  • Cast text/object columns to datetime.
  • Aggregate 2,566 call rows → 300 match rows.
  • Extract sentiment & train the model.

The 5-command inspection toolkit

Run this on the raw file first, every time. Read the comment after each line — it names the question that line answers.

import pandas as pd
df = pd.read_excel("Test-Truncated-Restated.xlsx")

# 1) SCHEMA + MEMORY + DTYPES — are dates stored as text ('object')?
df.info()

# 2) MISSING-DATA AUDIT — % missing per column, worst first
missing = df.isnull().mean() * 100
print(missing[missing > 0].sort_values(ascending=False))

# 3) UNIT OF ANALYSIS — is 1 row a person? a match? a repeated call note?
print("Total rows:", len(df))
print("Unique matches:", df["Match ID 18Char"].nunique())

# 4) DISTRIBUTIONS — numeric spread vs categorical top values
print(df.describe(include="number"))   # mean, std, min, max
print(df.describe(include="object"))   # unique counts, top category

# 5) EYEBALL IT — spot formatting quirks the stats hide
print(df.head(5))     # first rows
print(df.sample(5))   # random rows
library(readxl); library(dplyr)
df <- read_excel("Test-Truncated-Restated.xlsx")

# 1) SCHEMA + DTYPES — glimpse shows type + first values per column
glimpse(df)                    # or skimr::skim(df) for a rich report

# 2) MISSING-DATA AUDIT — % missing per column, worst first
miss <- sort(colMeans(is.na(df)) * 100, decreasing = TRUE)
print(miss[miss > 0])

# 3) UNIT OF ANALYSIS
cat("Total rows:", nrow(df), "\n")
cat("Unique matches:", n_distinct(df$`Match ID 18Char`), "\n")

# 4) DISTRIBUTIONS
summary(select(df, where(is.numeric)))              # numeric spread
sapply(select(df, where(is.character)), n_distinct)  # categorical cardinality

# 5) EYEBALL IT
head(df, 5)
slice_sample(df, n = 5)
Reusable script: this project ships a one-command universal inspector at starter_code/inspect_data.py — run python inspect_data.py yourfile.xlsx "Match ID 18Char" on any tabular file (.xlsx/.csv/.parquet) to print all five checks at once. It even flags the long format, right-skewed columns, and high-cardinality categoricals for you.

The decision matrix — inspection → method

This is the part people miss: what you observe in Step 0 dictates what you do in Steps 1–6. Read each observation as an "if → then." This table is dataset-agnostic — keep it next to you.

What you observeWhat it meansWhat to do / which method
Dates show as object/text in .info()not usable as time yetparse to datetime; derive age, tenure, call gaps
A column is 30–60%+ missingweak/unreliable featuredrop it, or impute + add a "was-missing" flag
A few % missingnormalimpute inside the pipeline (median for numbers, mode for categories)
Rows ≫ unique ID countlong / panel data (repeats per unit)aggregate to the unit of analysis before modeling; GroupKFold on the ID
Numeric mean ≫ median, big std, high maxright-skew / outliersreport median; log-transform; prefer tree models & rank-based tests
Categorical with few levelslow cardinalityone-hot encode
Categorical with many levels (Occupation, Employer)high cardinalitytarget/frequency encoding, or CatBoost (native categoricals)
A long free-text columnunstructured signalNLP: clean → TF-IDF / sentiment / keywords
Class counts very unequalimbalancescore with macro-F1 (not accuracy); class weights / resampling
Semicolon-separated values in one cellmulti-select listsplit → multi-hot or Jaccard overlap

The target variable decides the model family

The single most important inspection question: what type is the thing you're predicting?

Number

e.g. Match Lengthregression. Score with RMSE. Linear → random forest → gradient boosting.

Category

e.g. Closure Reasonclassification. Logistic / random forest. Watch class imbalance (macro-F1).

Time-to-event + censoring

match duration where some are still Activesurvival analysis (Kaplan–Meier, Cox, survival forests).

Your goal also picks your features: predictive vs descriptive

Predictive

Predicting an outcome (Match Length for the test set) → use only features knowable at/near match time. Exclude anything that becomes known because the match ended — Closure Reason, Match Closure Date, the final Match Length itself. Using them is target leakage.

Descriptive

Explaining what makes matches succeed (Novice) → you want the outcome fields. Keep Match Length, Closure Reason, Stage — they're the very things you're describing.

Leakage is about timing, not column position. A feature leaks if its value is only known after the thing you're predicting — not because of where it sits in the spreadsheet. Ask of every predictor: "would we know this the day the match started?"

From inspection to action: the two findings that trigger Step 1

Inspection isn't busywork — specific findings force specific next steps. On this challenge, two diagnostics from Step 0 decide the entire shape of your ETL pipeline. (Both are verified against the actual files.)

Finding #1 · the targets are masked

Checking columns on Test-Truncated-Restated.xlsx shows the outcome fields are goneMatch Length, Closure Reason, Closure Details, Stage, and Match Closure Meeting Date are all absent (the test set is what you must predict).

→ Decision: you can't find drivers, correlations, or survival curves on the test set alone. Load the training set alongside it so you have ground-truth outcomes to learn from.

Finding #2 · granularity mismatch

len(df) vs nunique() shows 2,566 rows for only 300 matches — an average of 8.55 check-in records per match (median 7, up to 32). Rows are calls-over-time; the prediction target is per-match.

→ Decision: a correlation matrix or regression expects one row per subject. You must write aggregation logic (mean cadence, sentiment trend, call counts) to roll the calls up to one row per Match ID before modeling.

The decision-logic flow

Step 0inspect Targets missing in Test→ load Training for ground truth 2,566 rows / 300 matches→ write aggregation (rows → 1/match) Step 1ingest & ETL pipeline
The principle: Step 0 reveals what data you have and how it's structured; Step 1 builds the automated pipeline that reshapes and aligns it for analysis and modeling. The findings are the spec for the pipeline you write next.

What Step 0 reveals about this BBBS data

Inspection findingConsequence for your pipeline
Test-Truncated: 2,566 rows but only 300 unique Match ID 18CharLong format → aggregate to 300 match rows before predicting
Date columns load as object textParse to datetime → compute cadence, tenure, age
Match Length mean 23.4 ≫ median 16.8Right-skew → median, log-transform, tree models
786 blank Closure Reason = the Active matchesNot missing data — those are censored/ongoing
Big Occupation, Big Employer = many levelsHigh cardinality → target-encode or use CatBoost
Interest fields hold ;-separated listsSplit → multi-hot / Jaccard alignment features
Bottom line: spend the first hour looking, not coding. A 15-line inspection tells you the grain, the target type, the skew, the missingness, and the cardinality — which together decide your entire method stack before you write a single transform.

Workshop A Novice — Explore & explain

Goal: clean Novice.xlsx and produce a clear, quantified story about what makes a BBBS match succeed. No heavy modeling — this is a masterclass in EDA done right.

You will produce
  • Distributions of Match Length & Closure Reason
  • How they vary by Program Type
  • How they've changed over time
  • Which demographics move the needle
  • Whether alignment (same gender, shared hobbies) helps
  • A defended definition of a "successful match" + recommendations

Step 1 · ETL — load & clean

Load the file, parse the date columns, and build a success flag and an activation_year for the time analysis.

import pandas as pd, numpy as np

df = pd.read_excel("Data/Novice.xlsx")

# parse the dates we need
for c in ["Match Activation Date", "Match Closure Meeting Date"]:
    df[c] = pd.to_datetime(df[c], errors="coerce")

df["activation_year"] = df["Match Activation Date"].dt.year

# --- define "success" (definition #2: lasts beyond a policy target of 12 months) ---
# plus flag the official successful-closure category
df["success_long"]   = (df["Match Length"] >= 12).astype(int)
df["success_closure"] = (df["Closure Reason"] == "Successful match closure").astype(int)
df["is_active"]       = (df["Stage"] == "Active").astype(int)

print(df[["Match Length","activation_year","success_long"]].head())
library(tidyverse); library(readxl); library(lubridate); library(janitor)

df <- read_excel("Data/Novice.xlsx") |> clean_names()   # names -> snake_case

df <- df |>
  mutate(
    match_activation_date = as_date(match_activation_date),
    activation_year       = year(match_activation_date),
    success_long   = as.integer(match_length >= 12),
    success_closure= as.integer(closure_reason == "Successful match closure"),
    is_active      = as.integer(stage == "Active")
  )

glimpse(select(df, match_length, activation_year, success_long))
Note on clean_names(): the raw columns have spaces and colons (Big Contact: Interest Finder - Hobbies). In R we snake_case them once so code is readable; in Python we keep original names in quotes. Either is fine — just be consistent.

Step 2 · Distribution of the response variables

Start univariate. A right-skewed Match Length means you should report the median, not just the mean, and consider a log scale.

import matplotlib.pyplot as plt, seaborn as sns

fig, ax = plt.subplots(1, 2, figsize=(11,4))
sns.histplot(df["Match Length"], bins=30, ax=ax[0])
ax[0].set_title("Match Length (months) — right-skewed")

# top closure reasons (drop blanks = active matches)
top = df["Closure Reason"].value_counts().head(10)
top.sort_values().plot.barh(ax=ax[1])
ax[1].set_title("Top 10 closure reasons")
plt.tight_layout(); plt.savefig("outputs/response_dist.png", dpi=150)
library(scales)
# Match Length histogram
ggplot(df, aes(match_length)) +
  geom_histogram(bins = 30, fill = "#2a9d8f") +
  labs(title = "Match Length (months) — right-skewed", x = "months")

# top 10 closure reasons
df |> filter(!is.na(closure_reason)) |>
  count(closure_reason, sort = TRUE) |> slice_head(n = 10) |>
  ggplot(aes(n, reorder(closure_reason, n))) +
  geom_col(fill = "#1f5fa8") + labs(y = NULL, title = "Top 10 closure reasons")
Insight to state out loud: the leading closure reasons are volunteer-side, life-circumstance events ("Time constraint", "Moved") — a hint that screening for volunteer availability/stability could raise match length.

Step 3 · Does it vary by Program Type?

This is a "number by category" question → grouped boxplots + a table of group medians.

# group medians (report median for skewed data)
print(df.groupby("Program Type")["Match Length"]
        .agg(["count","median","mean"]).round(1))

sns.boxplot(data=df, x="Program Type", y="Match Length")
plt.xticks(rotation=20); plt.title("Match Length by Program Type")

# formal test: do groups differ? (Kruskal-Wallis handles skew)
from scipy.stats import kruskal
groups = [g["Match Length"].values for _, g in df.groupby("Program Type")]
print("Kruskal-Wallis:", kruskal(*groups))
df |> group_by(program_type) |>
  summarise(n = n(), median = median(match_length),
            mean = mean(match_length)) |> arrange(desc(median))

ggplot(df, aes(program_type, match_length)) +
  geom_boxplot(fill = "#2a9d8f") +
  labs(title = "Match Length by Program Type")

# formal test (non-parametric, robust to skew)
kruskal.test(match_length ~ factor(program_type), data = df)
Method note: Match Length is skewed, so we use the Kruskal–Wallis test (a rank-based ANOVA) instead of plain ANOVA. Naming why you chose it scores you "appropriateness of methods" points.
Domain nuance (from the client Q&A): Program Type isn't just a category — it changes which other variables matter. Community matches meet independently across the Twin Cities, so Big Car Access and distance are important; Site-based matches meet at a facility with group transport, so those barely matter. When you compare demographics, consider splitting by Program Type, or note that an effect is conditional on it.

Step 4 · Has it changed over time?

yearly = (df.dropna(subset=["activation_year"])
            .groupby("activation_year")["Match Length"].median())
yearly.plot(marker="o", title="Median Match Length by activation year")
plt.ylabel("median months")
df |> filter(!is.na(activation_year)) |>
  group_by(activation_year) |>
  summarise(med = median(match_length)) |>
  ggplot(aes(activation_year, med)) +
  geom_line() + geom_point() +
  labs(title = "Median Match Length by activation year", y = "median months")
Watch the censoring trap. Recent matches look "shorter" simply because they haven't had time to finish yet (they're still Active). Note this caveat, and consider analyzing only closed matches for the time trend.

Step 5 · Which demographics matter?

Loop the response against several Big/Little demographic variables. Bin Big Age into ranges so it's comparable to the categorical ones.

df["big_age_band"] = pd.cut(df["Big Age"],
        bins=[18,25,35,45,100], labels=["19-25","26-35","36-45","46+"])

for col in ["big_age_band", "Big Gender", "Big Level of Education", "Big Car Access"]:
    print(f"\n=== {col} ===")
    print(df.groupby(col)["Match Length"].median().round(1).sort_values(ascending=False))
df <- df |> mutate(big_age_band = cut(big_age,
        c(18,25,35,45,100), labels = c("19-25","26-35","36-45","46+")))

for (col in c("big_age_band","big_gender","big_level_of_education","big_car_access")) {
  cat("\n===", col, "===\n")
  print(df |> group_by(.data[[col]]) |>
          summarise(med = median(match_length)) |> arrange(desc(med)))
}

Step 6 · The creative one — alignment features

The prompt explicitly asks whether alignment (same gender, similar hobbies) relates to success. This is where you earn "creativity" points. Engineer alignment flags, then compare.

# 1) same gender flag
df["same_gender"] = (df["Big Gender"] == df["Little Gender"]).astype(int)

# 2) shared-hobbies flag: the interest fields are ';'-separated lists
def to_set(x):
    return set(str(x).lower().split(";")) if pd.notna(x) else set()

big_h    = df["Big Contact: Interest Finder - Hobbies"].map(to_set)
little_h = df["Little Contact: Interest Finder - Hobbies"].map(to_set)
df["shared_hobbies"] = [len(a & b) for a,b in zip(big_h, little_h)]
df["any_shared_hobby"] = (df["shared_hobbies"] > 0).astype(int)

# compare
print(df.groupby("same_gender")["Match Length"].median())
print(df.groupby("any_shared_hobby")["Match Length"].median())
library(stringr)
overlap <- function(a, b) {
  A <- str_split(str_to_lower(coalesce(a, "")), ";")
  B <- str_split(str_to_lower(coalesce(b, "")), ";")
  mapply(function(x, y) length(intersect(trimws(x), trimws(y))), A, B)
}
df <- df |> mutate(
  same_gender     = as.integer(big_gender == little_gender),
  shared_hobbies  = overlap(big_contact_interest_finder_hobbies,
                            little_contact_interest_finder_hobbies),
  any_shared_hobby= as.integer(shared_hobbies > 0))

df |> group_by(same_gender)      |> summarise(median(match_length))
df |> group_by(any_shared_hobby) |> summarise(median(match_length))
Repeat the pattern for Sports, Entertainment, Places-to-Go, and a combined "total shared interests" score. A small multiples chart of "median match length vs shared-interest count" is a memorable, rubric-friendly slide.

Level-up: Jaccard similarity & one-hot interests

A raw count of shared interests is biased — a Big who lists 20 hobbies overlaps with everyone. Jaccard similarity normalizes by the size of the combined interest sets, so it measures genuine compatibility:

Jaccard(Big, Little) = |shared interests| ÷ |all interests combined| — 0 means nothing in common, 1 means identical interest sets.
# Jaccard similarity for one interest category
def jaccard(a, b):
    a, b = to_set(a), to_set(b)            # to_set() from Step 6
    if not (a | b): return np.nan     # both empty -> undefined
    return len(a & b) / len(a | b)

df["hobby_jaccard"] = [jaccard(x, y) for x, y in
    zip(df["Big Contact: Interest Finder - Hobbies"],
        df["Little Contact: Interest Finder - Hobbies"])]

# bonus: explode a ';'-list into one-hot columns for modeling
sports = df["Big Contact: Interest Finder - Sports"].str.get_dummies(sep=";")
print(sports.shape, "one-hot sport columns")
jaccard <- function(a, b) {
  A <- to_set(a); B <- to_set(b)      # split ';' -> character vector
  u <- union(A, B); if (length(u) == 0) return(NA_real_)
  length(intersect(A, B)) / length(u)
}
df$hobby_jaccard <- mapply(jaccard,
  df$big_contact_interest_finder_hobbies,
  df$little_contact_interest_finder_hobbies)
Also try an age gap. Derive each person's age from their birthdate at match activation, then age_gap = big_age − little_age. Big–Little age gap and race/ethnicity concordance are classic compatibility features worth testing.

Step 7 · Rubric-mapped deliverable checklist

Rubric categoryYour Novice deliverable
Completeness (25%)Distributions ✓ · by Program Type ✓ · over time ✓ · demographics ✓ · alignment ✓ · success definition ✓
Impact of factors (20%)A ranked "what raises/lowers match length" table with sizes (e.g. "+X months")
Methods (20%)Median for skewed data, boxplots, Kruskal–Wallis — each justified in one line
Creativity (20%)Engineered alignment features (gender + shared interests)
Communication (15%)One story: "matches succeed when ___; BBBS should ___"

Workshop B Undergraduate — Text + Predict

Everything in Workshop A, on the bigger Training-Restated.xlsx, plus three new skills: reshaping note-level data, mining the check-in notes, and building a Match-Length model scored by RMSE.

Step 1 · The reshape you must get right

Training is long (one row per note). To do the Novice-style analysis and to model at the match level, collapse it to one row per match while turning the notes into features.

import pandas as pd, numpy as np
notes = pd.read_excel("Data/Training-Restated.xlsx")
notes["Completion Date"] = pd.to_datetime(notes["Completion Date"], errors="coerce")
notes = notes.sort_values(["Match ID 18Char", "Completion Date"])

# --- note-level features, aggregated per match ---
g = notes.groupby("Match ID 18Char")

feat = pd.DataFrame({
    "n_notes":       g.size(),
    "first_note":    g["Completion Date"].min(),
    "last_note":     g["Completion Date"].max(),
    "note_chars":    g["Match Support Contact Notes"].apply(
                        lambda s: s.astype(str).str.len().mean()),
})
# call cadence = average days between consecutive notes
def cadence(s):
    d = s.dropna().sort_values().diff().dt.days
    return d.mean()
feat["avg_days_between_calls"] = g["Completion Date"].apply(cadence)

# bring the match-level (constant) columns over — take the first row per match
const_cols = ["Program Type","Big Age","Big Gender","Little Gender",
              "Match Activation Date","Match Length"]
match = g[const_cols].first().join(feat)
print(match.shape)   # ~3275 rows, one per match
library(tidyverse); library(readxl); library(janitor); library(lubridate)
notes <- read_excel("Data/Training-Restated.xlsx") |> clean_names() |>
  mutate(completion_date = as_date(completion_date)) |>
  arrange(match_id_18char, completion_date)

match <- notes |>
  group_by(match_id_18char) |>
  summarise(
    n_notes    = n(),
    first_note = min(completion_date, na.rm = TRUE),
    last_note  = max(completion_date, na.rm = TRUE),
    note_chars = mean(nchar(coalesce(match_support_contact_notes, ""))),
    avg_days_between_calls = mean(diff(sort(completion_date)), na.rm = TRUE) |> as.numeric(),
    # match-level constants: take the first value
    program_type = first(program_type), big_age = first(big_age),
    big_gender = first(big_gender), little_gender = first(little_gender),
    match_length = first(match_length), .groups = "drop")
dim(match)   # ~3275 x ...
Leakage alert. n_notes, last_note, and the span first→last note are almost the answer for training data — but the test set was truncated so its last_note is artificially early. Features that depend on "how far the notes go" will not transfer. Prefer cadence, early-note text, and stable match attributes. Test every feature by asking: "would this be knowable 3 months into a match?"

Step 2 · Does call cadence affect Match Length?

Use the real schedule (from the client Q&A). Calls are scheduled monthly (~30 days) in Year 1, then quarterly (~90 days) after. So "on-cadence vs slipping" beats a raw average gap. A Year-1 gap past 30–45 days is a disengagement red flag. Engineer an on_schedule_ratio and an overdue flag alongside the average below.
import seaborn as sns, matplotlib.pyplot as plt
m = match.dropna(subset=["avg_days_between_calls","Match Length"])

sns.regplot(data=m, x="avg_days_between_calls", y="Match Length",
            scatter_kws={"alpha":.2}, lowess=True)
plt.title("Do longer gaps between calls shorten matches?")

print(m[["avg_days_between_calls","Match Length"]].corr(method="spearman"))
m <- filter(match, !is.na(avg_days_between_calls))

ggplot(m, aes(avg_days_between_calls, match_length)) +
  geom_point(alpha = .2) + geom_smooth() +
  labs(title = "Do longer gaps between calls shorten matches?")

cor(m$avg_days_between_calls, m$match_length, method = "spearman")

Step 3 · Mine the check-in notes (text)

The notes look like "Question: Activities: Answer: BB and LB have been…". Clean the prompt scaffolding, tokenize, and find words associated with long vs short matches.

import re
from sklearn.feature_extraction.text import TfidfVectorizer

# join all notes per match into one document
docs = (notes.groupby("Match ID 18Char")["Match Support Contact Notes"]
             .apply(lambda s: " ".join(s.dropna().astype(str))))

# redaction placeholders carry NO signal (see Client Q&A) -> remove them
REDACT = r"b_firstname|l_lastname|phone_number|email"
# role/process acronyms: drop the pure-noise ones, keep meaningful stems
NOISE  = r"\b(bb|lb|bp|msc|mec|mc|fc|pg|kj)\b"

def clean(t):
    t = t.lower()
    t = re.sub(r"question:|answer:", " ", t)   # drop scaffolding
    t = re.sub(REDACT, " ", t)               # drop redacted names/contacts
    t = re.sub(NOISE,  " ", t)               # drop anonymized role tags
    return re.sub(r"[^a-z ]", " ", t)

docs = docs.map(clean)

tfidf = TfidfVectorizer(stop_words="english", max_features=400,
                        ngram_range=(1,2), min_df=20)
X = tfidf.fit_transform(docs)

# correlate each term with Match Length
import numpy as np, pandas as pd
y = match.loc[docs.index, "Match Length"].values
corr = np.asarray([(np.corrcoef(X[:,j].toarray().ravel(), y)[0,1])
                   for j in range(X.shape[1])])
terms = pd.Series(corr, index=tfidf.get_feature_names_out()).dropna()
print("words linked to LONG matches:\n", terms.sort_values(ascending=False).head(15))
print("words linked to SHORT matches:\n", terms.sort_values().head(15))
library(tidytext); library(stringr)

clean_txt <- function(t) t |>
  str_to_lower() |>
  str_replace_all("question:|answer:", " ") |>                       # scaffolding
  str_replace_all("b_firstname|l_lastname|phone_number|email", " ") |> # redactions -> no signal
  str_replace_all("\\b(bb|lb|bp|msc|mec|mc|fc|pg|kj)\\b|[^a-z ]", " ")   # role tags + punct

tok <- notes |> transmute(match_id_18char,
          txt = clean_txt(coalesce(match_support_contact_notes, ""))) |>
       unnest_tokens(word, txt) |> anti_join(stop_words, by = "word")

# term frequency per match, join to length, correlate
term_len <- tok |> count(match_id_18char, word) |>
  left_join(select(match, match_id_18char, match_length), by = "match_id_18char") |>
  group_by(word) |> filter(n() > 20) |>
  summarise(r = cor(n, match_length)) |> arrange(desc(r))
head(term_len, 15); tail(term_len, 15)

Early vs late notes

The prompt asks whether language differs between early and late stages. Tag each note by its position in the match's sequence, then compare word frequencies.

notes["rank"]  = notes.groupby("Match ID 18Char").cumcount() + 1
notes["total"] = notes.groupby("Match ID 18Char")["rank"].transform("max")
notes["phase"] = np.where(notes["rank"]/notes["total"] <= 0.34, "early", "late")
# then run the same tokenize+count, grouped by phase, and compare top terms
notes <- notes |> group_by(match_id_18char) |>
  mutate(rank = row_number(), total = n(),
         phase = if_else(rank/total <= 0.34, "early", "late")) |> ungroup()
# tokenize, count by phase, and compare with a log-odds or simple ratio

Turn findings into the coordinator "drop-down" questions

Deliverable (required for UG): a small set of check-in questions with limited drop-down answers that flag at-risk matches. Each question maps to a data-driven risk signal, and specific answers act as risk triggers. A judge-ready version looks like a coordinator triage tool:

QuestionDrop-down optionsRisk triggerGrounded in
Meeting cadence healthExceeding 1×/mo · Meeting 1×/mo · Missed/postponed · Unresponsive Missed / Unresponsivecadence + on-schedule ratio
Youth engagement levelHighly enthused · Neutral/quiet · Reluctant to attend Reluctant to attendnote sentiment trend
Logistical / transit barrierNo friction · Commute-distance issue · Schedule/sports conflict Distance / schedule conflictclosure reasons + Census distance
Goal-alignment progressionActively setting goals · Casual hanging out · Disconnected Disconnectedrationale-for-match text themes

Keep it to 4–5 questions with closed picklists so answers are consistent and scoreable — a coordinator can complete it during a monthly check-in call.

Step 4 · Predict Match Length (the scored part)

Build a regression at the match grain. Validate honestly with a hold-out split and RMSE. Start simple (linear), then try a random forest / gradient boosting.

from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.pipeline import Pipeline

# choose TEST-SAFE features (knowable early, not truncation-dependent)
num = ["Big Age","avg_days_between_calls","note_chars"]
cat = ["Program Type","Big Gender","Little Gender"]
data = match.dropna(subset=["Match Length"]).copy()
X, y = data[num+cat], data["Match Length"]

pre = ColumnTransformer([("cat", OneHotEncoder(handle_unknown="ignore"), cat)],
                        remainder="passthrough")
model = Pipeline([("pre",pre),
    ("rf", RandomForestRegressor(n_estimators=400, random_state=42))])

Xtr,Xva,ytr,yva = train_test_split(X, y, test_size=0.2, random_state=42)
model.fit(Xtr, ytr)
pred = model.predict(Xva)
rmse = mean_squared_error(yva, pred, squared=False)
print("validation RMSE:", round(rmse, 2))  # compare vs baseline below

# baseline = predict the mean for everyone; you MUST beat this
baseline = mean_squared_error(yva, [ytr.mean()]*len(yva), squared=False)
print("baseline (mean) RMSE:", round(baseline, 2))
library(tidymodels); set.seed(42)

data <- match |> filter(!is.na(match_length)) |>
  select(match_length, big_age, avg_days_between_calls, note_chars,
         program_type, big_gender, little_gender)

split <- initial_split(data, prop = 0.8)
tr <- training(split); va <- testing(split)

rec <- recipe(match_length ~ ., data = tr) |>
  step_impute_median(all_numeric_predictors()) |>
  step_novel(all_nominal_predictors()) |>
  step_dummy(all_nominal_predictors())

rf <- rand_forest(trees = 400) |> set_engine("ranger") |> set_mode("regression")
fit <- workflow(rec, rf) |> fit(tr)

augment(fit, va) |> rmse(match_length, .pred)      # your RMSE
# baseline you must beat:
sqrt(mean((va$match_length - mean(tr$match_length))^2))
Model-improvement ladder (do in order, keep what lowers validation RMSE): add alignment features from Workshop A → add text features (note sentiment, TF-IDF top terms) → try gradient boosting (XGBoost / LightGBM / CatBoost — CatBoost handles categorical fields like Big Occupation natively) → tune with cross-validation → ensemble the best models (average or a Ridge "stacker") → consider modeling log(Match Length) for the skew.

Leakage-safe validation: GroupKFold + synthetic truncation

This is what separates strong models from ones that look great locally and collapse on the real test set. Two rules: (1) never let one match land in both train and validation folds — group on Match ID 18Char; (2) the test notes were truncated, so truncate your training folds the same way before computing features, or your model learns from "future" notes it won't have.

from sklearn.model_selection import GroupKFold
import numpy as np

def truncate_notes(notes_df, frac_range=(0.2, 0.6), seed=42):
    """Drop the last 20-60% of each match's notes to mimic the test set."""
    rng = np.random.default_rng(seed)
    keep = []
    for mid, g in notes_df.groupby("Match ID 18Char"):
        g = g.sort_values("Completion Date")
        frac = rng.uniform(*frac_range)              # how much to chop
        n_keep = max(1, int(len(g) * (1 - frac)))
        keep.append(g.iloc[:n_keep])
    return pd.concat(keep)

# cross-validate: truncate INSIDE each training fold, then build features
gkf = GroupKFold(n_splits=5)
groups = match_ids                                  # Match ID per row of your match-level X
scores = []
for tr_idx, va_idx in gkf.split(X, y, groups):
    # 1) truncate the raw notes for the training matches, 2) rebuild features,
    # 3) fit on the truncated-derived features, 4) score on the held-out matches
    model.fit(X.iloc[tr_idx], y.iloc[tr_idx])
    p = model.predict(X.iloc[va_idx])
    scores.append(np.sqrt(mean_squared_error(y.iloc[va_idx], p)))
print("CV RMSE:", np.mean(scores).round(2), "±", np.std(scores).round(2))
Why the truncation matters: if you engineer features on complete training histories but the test set is cut short, your training features (e.g. note counts, last sentiment) come from a different distribution than test — the model silently overfits. Simulating the cut in each fold makes your local RMSE trustworthy.

Step 5 · Produce the submission file (exactly 300 rows)

Retrain on all training data, predict the test matches, and write the CSV in the exact required format. A malformed file scores a 1 automatically.

# 1) build the SAME features for the test file (one row per match)
test = pd.read_excel("Data/Test-Truncated-Restated.xlsx")
# ... repeat the Step-1 aggregation on `test` to get test_match (300 rows) ...

# 2) refit on ALL training rows, predict test
model.fit(X, y)
test_pred = model.predict(test_match[num+cat])

# 3) write the required 4-column file
sub = pd.DataFrame({
    "RowID": range(1, len(test_match)+1),
    "MatchIDChar": test_match.index,
    "PredictedMatchLength": np.round(test_pred, 2),
    "YourTeamID": "U50",          # <-- your assigned Team ID
})
assert len(sub) == 300, "must be exactly 300 predictions!"
sub.to_csv("outputs/Testset_Predictions_Submit.csv", index=False)
# build test_match the same way as `match`, then:
final <- workflow(rec, rf) |> fit(data)      # refit on ALL training
preds <- predict(final, test_match)$.pred

sub <- tibble(
  RowID = seq_len(nrow(test_match)),
  MatchIDChar = test_match$match_id_18char,
  PredictedMatchLength = round(preds, 2),
  YourTeamID = "U50")
stopifnot(nrow(sub) == 300)
write_csv(sub, "outputs/Testset_Predictions_Submit.csv")

Step 6 · Rubric-mapped deliverable checklist

Rubric categoryYour UG deliverable
Completeness (25%)All Novice items + cadence + note text + early/late + coordinator questions
Accuracy of prediction (15%)Valid 300-row CSV; validation RMSE that beats the mean baseline
Impact (15%)Feature importance from the model, translated to plain language
Methods (15%)Correct grain, honest validation, leakage avoided — say so explicitly
Creativity (15%)Text features + the drop-down risk questions
Communication (15%)"Here's who's at risk and the 3 questions to catch it early"

Workshop C Graduate — Deep NLP + external data

Everything in A & B, plus: sentiment/emotion in the notes, modeling Closure Reason, mining Rationale for Match & Closure Details, joining U.S. Census data by Block Group, and an early-intervention strategy.

Step 1 · Sentiment & emotional tone of notes

Move beyond keywords to sentiment (positive/negative) and emotion. Track how sentiment trends across a match — a downward slope is an early-warning signal.

from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
sia = SentimentIntensityAnalyzer()

notes["sent"] = notes["Match Support Contact Notes"].fillna("") \
                    .map(lambda t: sia.polarity_scores(t)["compound"])

# per-match sentiment features (test-safe: use EARLY notes only)
early = notes[notes["rank"] <= 3]
sent_feat = early.groupby("Match ID 18Char")["sent"].agg(["mean","min"])
sent_feat.columns = ["early_sent_mean","early_sent_min"]

# sentiment SLOPE across the match (early -> late)
import numpy as np
def slope(g):
    if len(g) < 3: return np.nan
    return np.polyfit(g["rank"], g["sent"], 1)[0]
sent_slope = notes.groupby("Match ID 18Char").apply(slope)
library(tidytext)
afinn <- get_sentiments("afinn")          # word -> score

note_sent <- notes |> transmute(match_id_18char, rank,
      txt = coalesce(match_support_contact_notes, "")) |>
  unnest_tokens(word, txt) |> inner_join(afinn, by = "word") |>
  group_by(match_id_18char, rank) |> summarise(sent = mean(value), .groups="drop")

# early-note sentiment + slope per match
sent_feat <- note_sent |> group_by(match_id_18char) |>
  summarise(early_sent = mean(sent[rank <= 3]),
            slope = if(n() >= 3) coef(lm(sent ~ rank))[2] else NA)
The memorable Grad slide: "Matches whose note-sentiment slopes downward in the first N calls close X% sooner." That's an actionable early-warning metric.

Step 2 · Model Closure Reason (classification)

Closure Reason has ~30 values. Group them into a few actionable buckets (Volunteer-side, Child/Family-side, Agency, Successful, COVID) and classify.

def bucket(r):
    if pd.isna(r): return "Active"
    r = str(r)
    if r.startswith("Volunteer"): return "Volunteer"
    if r.startswith("Child"):     return "Child/Family"
    if r.startswith("Agency"):    return "Agency"
    if "Successful" in r:       return "Successful"
    return "Other"

df["closure_bucket"] = df["Closure Reason"].map(bucket)
print(df["closure_bucket"].value_counts())

# then a RandomForestClassifier on demographic + alignment + early-note features,
# evaluated with a confusion matrix and macro-F1 (classes are imbalanced).
bucket <- function(r) case_when(
  is.na(r) ~ "Active",
  str_starts(r, "Volunteer") ~ "Volunteer",
  str_starts(r, "Child")     ~ "Child/Family",
  str_starts(r, "Agency")    ~ "Agency",
  str_detect(r, "Successful") ~ "Successful",
  TRUE ~ "Other")

df <- df |> mutate(closure_bucket = bucket(closure_reason))
count(df, closure_bucket, sort = TRUE)
# classify with a ranger workflow; evaluate with conf_mat() + f_meas()

Step 3 · Mine Rationale-for-Match & Closure Details

Two extra text fields. Rationale for Match (why the match was made) may predict longevity; Closure Details explains why matches actually ended — gold for designing the coordinator questions. Apply the same tokenize/TF-IDF/sentiment toolkit from Workshop B to each, grouped by closure_bucket.

Tie it together: the words that dominate Closure Details for the "Volunteer" bucket (scheduling, moving, workload) become the exact drop-down options a coordinator should ask about — you're using hindsight text to build a foresight tool.

Step 4 · Join U.S. Census data (external data = rubric points)

The data ships Census Block Group IDs for the Little's home, the Big's home, and the Big's employer (Community program). Join public Census tables (income, education, etc.) to test whether neighborhood context relates to match success. Data must be free & public.

  1. Decode the Block Group ID

    A 12-digit FIPS code = State(2) + County(3) + Tract(6) + Block Group(1). Split it into parts.
  2. Get a free Census table

    American Community Survey (ACS) 5-year, via the free Census API (api.census.gov) or the tidycensus R package (needs a free key) / census Python package.
  3. Join on the Block Group key

    Attach median household income, % bachelor's+, etc. to each match.
  4. Test the relationship

    Does Match Length differ by neighborhood income band? Add the strongest Census feature to your model and report the RMSE change.
  5. Compute Big↔Little distance

    Convert each block group to a lat/long centroid, then take the Haversine distance between the Big's and Little's block groups. For Community matches (where the volunteer drives), commute distance is a strong, intuitive predictor of whether meetings keep happening.
Geospatial feature (high value for Community matches): block-group centroids come free from the Census (TIGER/Line, or the INTPTLAT/INTPTLON columns). The Big–Little centroid distance ties directly to the "logistical/transit barrier" the client named — pair it with Program Type since it barely matters for Site-based matches.
# split the 12-digit block group FIPS into components
bg = df["Little Mailing Address Census Block Group"].astype("string").str.zfill(12)
df["state"]  = bg.str[:2]
df["county"] = bg.str[2:5]
df["tract"]  = bg.str[5:11]
df["bgrp"]   = bg.str[11:]

# then pull ACS median income (B19013_001E) for MN block groups from the free API,
# and merge on [state, county, tract, bgrp].
library(tidycensus)    # census_api_key("YOUR_FREE_KEY") once
mn_income <- get_acs(geography = "block group", state = "MN",
                     variables = c(med_income = "B19013_001"), year = 2022)
# GEOID is the 12-digit block group key -> join to your data
df <- df |> mutate(GEOID = str_pad(little_mailing_address_census_block_group, 12, pad="0")) |>
  left_join(select(mn_income, GEOID, med_income = estimate), by = "GEOID")
BYOK reminder: the Census API is free but needs your own free key — get it at api.census.gov/data/key_signup.html. Never hard-code keys into a shared repo.

Step 5 · The early-intervention strategy (the payoff)

Grad's headline deliverable: an early-intervention model + a plan BBBS can run. Combine everything into a risk score computed from information available early in a match.

  1. Define the target

    "Closes within the next 3–6 months" (a binary you can build from dates on training data).
  2. Use only early-available inputs

    early-note sentiment & slope, cadence, alignment, demographics, Census context. No leakage.
  3. Rank matches by risk

    Classifier outputs a probability → sort → BBBS focuses coordinators on the top-risk matches.
  4. Map risk drivers to actions

    Each top feature → a concrete intervention (re-match support, schedule coaching, volunteer check-in) for the specific closure reason it predicts.

Make the intervention framework concrete

Judges reward specificity. Map your top predictive drivers to named BBBS programs and a trigger, so the recommendation is something staff could operationalize on Monday:

Predicted risk driverInterventionTrigger
Geographic / transit friction Provide transit passes, or pivot the pair to site-based activities Big–Little commute distance exceeds a threshold
Relationship / engagement stagnation Send prompt toolkits from The Learning Center (TLC) to re-spark conversation Note sentiment slope turns negative / "conversational burnout" language
Expectation misalignment Mandatory 3-way conference: Match Coordinator + Big + Parent/Guardian Within 14 days of a missed meeting

Advanced method: survival analysis for censored matches

Why it fits: Match Length is a time-to-event variable, and the Active matches haven't ended yet — their true length is right-censored (we only know it's "at least X months"). Plain regression throws that information away or biases it. Survival models use it correctly.
  • Kaplan–Meier curves — visualize "retention over time" by Program Type or risk group (a great slide).
  • Cox Proportional Hazards — which factors raise/lower the hazard of closing, with interpretable hazard ratios.
  • Random Survival Forests — non-linear survival prediction on your full feature matrix.

Tools: lifelines (Python) or survival/survminer (R). Even if your submitted prediction is a plain RMSE regression, showing a survival view demonstrates real methodological depth to the academic judge.

Step 6 · Rubric-mapped deliverable checklist

Rubric categoryYour Grad deliverable
Completeness (25%)All UG items + sentiment/emotion + Closure Reason model + Rationale/Closure-Details mining + intervention strategy
Accuracy of prediction (15%)Best-effort Match-Length RMSE (valid 300-row file)
Impact (15%)Which factors most drive closure, quantified
Methods (15%)Sentiment, imbalanced-class classification (macro-F1), honest validation
Creativity + external data (15%)Census join + sentiment-slope early-warning metric
Communication (15%)"Here's a risk score and an intervention playbook BBBS can run"

From the client Q&A ★

The written handouts don't tell you everything. In the live Q&A, BBBS staff (Daniel Glass, Pat, Dan Atkins, John Malone) shared operational realities, decoded the note jargon, and — crucially — described how teams actually win. Treat this page as insider context that most teams won't have.

Why this matters Several of these facts change your code and your model directly (cadence thresholds, note cleaning, Site-vs-Community features). Others change your strategy (storytelling beats a marginally-better RMSE). Both win points.

1 · Operational cadence — the real call schedule

Match Support Calls follow a known schedule, so you can measure whether a match is on-cadence or slipping — a far stronger signal than raw "days between calls."

Match ageExpected cadenceRed flag
Year 1 (0–12 mo)Monthly — every ~30 days A gap stretching past 30–45 days = disengagement warning
After Year 1Quarterly — every ~90 days Gaps well beyond 90 days

Feature idea (UG/Grad): don't just average the gap — compute on-schedule ratio = (# calls within the expected window) ÷ (# expected calls), and an overdue flag for the most recent gap. See the updated cadence step in Workshop B.

Intervention angle (Grad): BBBS's current interventions are ad-hoc. They explicitly want structured tools — e.g. training modules at The Learning Center (TLC) and expectation-reset conversations with families — triggered early. Frame your early-warning model as the trigger for these concrete actions.

2 · Decode the notes — acronyms & redaction placeholders

The check-in notes are full of shorthand. Knowing it lets you (a) clean the text correctly and (b) engineer smarter features. Redaction placeholders carry zero predictive power — strip them or they'll pollute your TF-IDF vocabulary.

Role & process acronyms

TokenMeaning
MSC / MEC / MCMatch Support / Match Engagement / Match Coordinator
FCFunctional Contact — the check-in call itself
PG / KJParent / Guardian
TLCThe Learning Center (BBBS training platform)
JJJuvenile Justice grant initiative
BB / LBBig / Little (as seen in the raw notes)

Redaction placeholders — drop these

PlaceholderWas
B_firstnameBig's name (regex-replaced)
L_lastnameLittle's name
phone_numbera phone number
emailan email address

These are constant tokens with no signal. Remove them in your cleaning step (updated code in Workshop B, Step 3).

3 · Participant lifecycle & rematches

Lifecycle

Littles enroll between ages 8 and 13 and age out at high-school graduation or turning 18. So a match that ends because the Little graduated is a natural, successful ending — not a failure. This sharpens your "successful match" definition on the client page.

Rematches / multiple matches

IDs repeat: the same Little ID can appear with a new Big ID (a rematch), and experienced Big IDs take on new Littles. One person ≠ one row. If you analyze at the person level, a repeated ID is history, not a duplicate to delete. An "experienced Big" flag (Big appears in multiple matches) is a strong candidate feature.

Feature idea: big_prior_matches = how many earlier matches this Big ID has, and little_is_rematch = whether this Little ID had a previous Big. Both are knowable early and plausibly relate to match length.

4 · Program model — Site vs Community changes which features matter

Community-based (majority)Site-based
Where they meetIndependently, anywhere in the Twin CitiesA fixed facility (school / corporate partner)
TransportVolunteer drives — car access mattersCoordinated group transport — car access matters much less
ScheduleFlexible, pair-drivenStructured around the site
Distance / geographyBig influence (Census join, commute)Small influence
Modeling implication: the effect of Big Car Access, distance, and Census geography is conditional on Program Type. Consider an interaction (e.g. car_access × is_community) or model Community and Site matches separately, and say why. That nuance reads as real domain understanding to the judges.

5 · How teams actually win (judging dynamics)

"The best model doesn't always win"

Dan Atkins was explicit: teams with the top RMSE have lost to teams with compelling storytelling, and mediocre-model teams have reached the finals on clear business value. RMSE is only 15% of the rubric — treat it as table stakes, then win on insight and communication.

Round 1 is a laptop table pitch

You present from your laptop screen to 3–4 judges at a small table, across 4–6 rounds — no projector until the afternoon finals. Design slides that are readable at laptop size & arm's length: big fonts, one idea per slide, few dense tables.

Balance for the three judge archetypes. Every pitch is heard by a Business, a STEM/technical, and an Academic judge at once. Give each a hook:
  • Business → the action: "here are 3 coordinator questions and a TLC intervention trigger."
  • STEM/technical → the rigor: features, validation, RMSE vs baseline, leakage handled.
  • Academic → the method: why these tests/models, assumptions checked, limits stated.
Bottom line: a competitive RMSE + a crisp, human story about how BBBS keeps matches alive beats a slightly-lower RMSE with no narrative. Budget your prep time accordingly.

6 · Using AI tools — allowed, with transparency

The organizers addressed AI directly in the Q&A: you may use it, but you must own your work and disclose how you used it. Judges are explicitly instructed to ask teams about their AI use, so prepare for the question rather than dodging it.

✓ Encouraged / allowed

  • Coding assistance (debugging, boilerplate, refactoring).
  • NLP workflows on the notes (sentiment, summarization, keyword extraction).
  • Learning & explaining methods you then apply yourself.

✗ Penalized

  • Pay-to-win: buying excessive compute, managed specialized APIs, or paid infra to bypass the actual work.
  • Outsourced thinking: a solution that's clearly AI-generated with no student understanding behind it — this hurts your score.
  • Being unable to explain your own model, features, or code when asked.
Transparency is required, not optional. If you used AI tools (especially paid/commercial subscriptions), account for it in your presentation. A judge is instructed to ask "how did your team use AI?" — a clear, honest answer builds trust; a vague or evasive one raises red flags.
The work must be student-driven. The analysis, insights, modeling logic, and recommendations must come from you. AI is a power tool, not a substitute for understanding. If you can't defend a choice, don't put it in the deck.

How to handle it well

  • Keep a short "AI usage" note (a backup slide or appendix): which tools, for what tasks (e.g. "used an LLM to draft the text-cleaning regex; we reviewed and tested it").
  • Make sure every team member can explain any part a judge points to — features, metric, a specific code block.
  • Frame AI as leverage on your own thinking: "we hypothesized X, used AI to prototype it faster, then validated it on the data."
  • Prefer free, reproducible tools; don't lean on expensive managed services that a judge would read as buying an unfair advantage.
Turn the question into a strength. Teams that clearly separate "what the AI helped with" from "what we decided and validated" signal exactly the ownership and rigor the judges are probing for.

Which method, when?

A quick reference so you always reach for the right tool — and can defend it to the "methods" judge.

GoalMethodPythonRWatch out for
Summarize one numberdescribe / histogram / boxplotdf.describe(), seabornsummary(), ggplotSkew → report median
Number across groupsgrouped medians + Kruskal–Wallisscipy.stats.kruskalkruskal.test()Unequal group sizes
Two categories related?crosstab + chi-squarescipy.stats.chi2_contingencychisq.test()Small expected counts
Trend over timeline of yearly summarygroupby + plotgroup_by + geom_lineCensoring of recent matches
Predict a numberlinear → random forest → boostingscikit-learn, xgboosttidymodels, rangerValidate on hold-out; RMSE
Predict a categorylogistic / RF classifierscikit-learntidymodelsImbalance → macro-F1, not accuracy
Text keywordstokenize + TF-IDFTfidfVectorizertidytextRemove scaffolding/anon tags
Text emotionsentiment lexicon (VADER/AFINN)vaderSentimenttidytext + afinnSarcasm, short notes
Which features matterfeature importance / coefficientsmodel.feature_importances_vip packageCorrelation ≠ causation
Time-to-event with censoringKaplan–Meier, Cox PH, survival forestslifelinessurvival / survminerActive matches are censored, not "short"
Similarity of two setsJaccard indexmanual / sklearnbase R set opsUndefined when both sets empty
Distance between locationsHaversine on centroidssklearn.metrics.haversine_distancesgeosphere::distHaversineBlock-group centroid ≠ exact address

Validation discipline (the difference between UG/Grad winners and the rest)

  • Always keep a hold-out (or cross-validation). Never report training-set performance.
  • Always beat a baseline (predict the mean). If you don't, your features aren't working.
  • Set a seed so numbers are reproducible.
  • For the truncated test set, ask of every feature: "knowable early?" If not, drop it.
  • Report RMSE in months so it's interpretable ("we're off by ~9 months on average").

Pitfalls & judge tips

Data traps specific to this competition

Grain leakage

Modeling on the long note-level file lets "number of notes" leak the answer. Collapse to one row per match first.

Truncation mismatch

Test notes are cut short. Features about "how far notes go" won't transfer. Use early/cadence/attribute features.

Active ≠ missing

Active matches have blank Closure Reason and are still growing in length. Don't impute or drop them blindly.

Skew

Match Length is right-skewed. Use medians, consider log-transform, use rank-based tests.

Multi-value fields

Interest fields are ;-separated lists — split them before comparing.

Wrong file

Use the -Restated files (UG/Grad). Old files contained an address that was removed.

Presentation tips (from the guidelines)

  • Time yourself to 5–7 minutes. Judges cut you off; front-load your best insight.
  • Round 1 is a laptop table pitch to 3–4 judges, 4–6 times — no projector until finals. Use big fonts, one idea per slide, and few dense tables (readable at arm's length on a laptop).
  • Match slide count to rubric weights — ~25% of slides on completeness/outcomes.
  • Speak to all three judges: business impact, method correctness, rigor.
  • Every member speaks (team synergy is graded).
  • Lead with the "so what" for BBBS, not with your tech stack.
  • Have a backup: WiFi may be slow — don't rely on live/online demos.
  • Be ready to explain your AI use — judges are instructed to ask. Keep a short "AI usage" note and make sure everyone can defend their own work. See From the client Q&A.
  • No school branding anywhere (slides, clothing, lanyards).
  • Expect interruptions — rehearse handling a mid-slide question and returning to flow.

Do's & Don'ts for MinneMUDAC success

Do — winning strategies Don't — common pitfalls
Focus heavily on business context and storytelling. Don't spend 5 minutes explaining model algorithms.
Address every rubric item explicitly in your slides. Don't include school logos or branding (strict rule!).
Prepare offline backup slides and presentation files. Don't rely heavily on live Wi-Fi during the presentation.
Structure clear, actionable recommendations for leadership. Don't submit prediction files without checking format & Team ID.
Recommended resource A related session on presenting & pitching your analysis — watch / register on LinkedIn ↗. Worth a look before Round 1 to sharpen how you tell the data story.

A 5–7 minute story skeleton that fits the rubric

  1. The ask (15s)

    "BBBS wants longer, healthier matches and early warning of closures."
  2. The data (30s)

    Files, grain, response variables — prove you understand it.
  3. What we found (2–3 min)

    Top drivers of success, quantified; your best 2–3 charts.
  4. The model (UG/Grad, 1 min)

    Approach, validation, RMSE vs baseline, key features.
  5. What BBBS should do (1–2 min)

    Concrete actions + the coordinator drop-down questions / intervention plan.
  6. Confidence & limits (20s)

    What you're sure of, and caveats (censoring, truncation).
The one-sentence test. If you can finish "BBBS should ___ because our data shows ___," you have a winning story. If you can't, you have charts but no insight — go back to the EDA.

Team GitHub workflow

How the team writes code together without stepping on each other. The goal: no one's work is ever lost, everyone gets full commit credit, and main stays clean and presentation-ready.

Never lose work

Everyone works on their own branch; code enters main only through reviewed Pull Requests. Force-pushes and branch deletion are blocked.

Everyone gets credit

Every commit, message, and PR is permanently stamped with your GitHub identity. Your git history is your contribution record for the judges and advisor.

Keep main clean

main is always tested and demo-ready. Half-finished experiments live in feature branches, never on main.

Three rules that never bend

  • Never commit directly to main. Code enters only via a reviewed PR.
  • Work in your own feature branch. All development, EDA, and testing happen there.
  • Commit history = your credit. Commit often with clear messages under your own identity.

Step-by-step

  1. Clone the repo & set your identity (once)

    Clone, then make sure your commits carry your name and GitHub email.

    git clone https://github.com/<your-org-or-team-lead>/minnemudac-2026.git
    cd minnemudac-2026
    
    # stamp every commit with your identity (use your GitHub email)
    git config user.name  "Your Name"
    git config user.email "your-github-email@example.com"

    Tip: drop the --global flag (as above) so this identity applies to this repo only — handy if you share a machine.

  2. Create a personal feature branch

    Always branch off an up-to-date main. Name it feature/<your-name>-<task>.

    # 1) get the latest main
    git checkout main
    git pull origin main
    
    # 2) branch off it
    git checkout -b feature/alex-census-etl
  3. Develop & commit safely

    Commit frequently with descriptive messages, then push your branch to GitHub.

    git status                              # see what changed
    git add src/etl_census.py               # stage specific files (not "git add .")
    git commit -m "feat: census block group demographic parser"
    git push -u origin feature/alex-census-etl
    Message convention Start messages with a type: feat: (new), fix: (bug), docs:, refactor:, eda:. It makes the history skimmable and looks professional to judges.
  4. Open a Pull Request for review

    On GitHub, click the Compare & pull request banner. In the description say what you changed and how you tested it, then assign a teammate as Reviewer.

    [your feature branch] ──(Pull Request + review)──▶ [protected main]
  5. Review, merge, clean up

    A teammate reviews the Files changed tab, comments, and Approves. Merge with Squash and merge (tidy history) or Create a merge commit — both keep author attribution. Then delete the merged branch.

    git checkout main
    git pull origin main                    # bring the merged code down
    git branch -d feature/alex-census-etl   # delete your local branch

Repository protection (set once by the team lead)

In GitHub → Settings → Branches → Add branch ruleset for main:

SettingWhy
Require a pull request before mergingNo direct pushes to main.
Require at least 1 approvalPeer eyes on every change before it lands.
Block force pushesNo one can rewrite/overwrite shared history.
Restrict deletions of mainThe production branch can't be removed.
Enforce .gitignoreRaw data (*.xlsx, *.csv) and envs (.venv/) stay untracked.

Jupyter notebook rules (avoid corruption)

Never edit the same .ipynb at the same time. Notebooks are JSON under the hood — concurrent edits cause ugly merge conflicts and can corrupt the file. One person per notebook.
  • Personal notebooks for exploration: notebooks/eda_alex.ipynb, notebooks/eda_taylor.ipynb — one per person.
  • Move reusable logic into src/: feature functions, the ETL pipeline, and NLP cleaners live as .py modules and get imported into notebooks.
  • Optional but great: add nbstripout so notebook outputs aren't committed — smaller diffs, fewer conflicts.

Adding your presentation to the repo

The slide deck is your final deliverable, so it belongs in the repo like everything else — but a .pptx/.key file is binary. Git can store it, but it can't merge it, so two people editing the deck at once corrupts it exactly like a notebook. Treat the deck as a single-owner file.

One deck owner at a time. Decide who "holds the pen" on the .pptx for a given work session. Others hand over changes as notes, figures, or a short branch — never by editing their own copy of the same file in parallel and merging later.

Where it lives

presentation/
├── MinneMUDAC2026_TeamName.pptx   # the editable source (single owner)
├── MinneMUDAC2026_TeamName.pdf    # exported PDF = the reviewable "source of truth"
├── figures/                       # charts pulled in from outputs/ (PNG/SVG)
│   ├── match_length_by_program.png
│   └── sentiment_slope.png
└── notes/
    └── talk_track.md              # speaker notes / who-says-what, plain text = diffable

Choose how you author it

FormatGood forWatch out for
PowerPoint / Keynote (.pptx/.key) Familiar, fast, the competition norm (you present from your laptop) Binary — no merging; one owner; commit an exported PDF for review
Google Slides (link in README) True real-time co-editing, no merge conflicts Not versioned in Git; export a .pptx/PDF into the repo before the deadline as the archived copy
Markdown / reveal.js / Quarto Plain text = diffable, PR-reviewable, real version control Steeper setup; less pixel-level control than PowerPoint
Best of both: whatever you author in, always commit an exported .pdf. It renders on GitHub, everyone can view it without PowerPoint, teammates review it in the PR, and it's a frozen record of what the deck looked like at each commit.

Commit & version it like code

# the deck owner works on a branch, same as any change
git checkout main && git pull origin main
git checkout -b feature/alex-slides-v1

# add the deck AND its exported pdf
git add presentation/MinneMUDAC2026_TeamName.pptx presentation/MinneMUDAC2026_TeamName.pdf
git commit -m "docs: draft slides — findings + model + recommendations"
git push -u origin feature/alex-slides-v1
# then open a PR so the team reviews the PDF before it hits main
  • Don't rename to death. Keep one filename (..._TeamName.pptx) and let git hold the history — no final_v2_REALLYfinal.pptx. Git already tracks every version.
  • Tag the submitted deck. When you submit, mark it: git tag -a submission-v1 -m "Round 1 deck" (or cut a GitHub Release and attach the PDF). That freezes the exact judged version.
  • Big media? If you embed video or many large images, enable Git LFS for *.pptx/*.mp4 so the repo stays fast: git lfs track "*.pptx".
  • Pull figures from outputs/. Copy the final charts your pipeline produced into presentation/figures/ and reference those — so the deck and the analysis never drift apart.

Suggested repo layout

minnemudac-2026/
├── src/                    # shared, importable logic (one owner per file is easiest)
│   ├── etl.py              #   loading, cleaning, feature engineering
│   ├── text.py             #   note cleaners, TF-IDF, sentiment
│   └── model.py            #   train, validate, predict, write submission
├── notebooks/              # personal EDA — one notebook per person
│   ├── eda_alex.ipynb
│   └── eda_taylor.ipynb
├── presentation/           # the deck (.pptx) + exported .pdf + figures + talk track
│   ├── MinneMUDAC2026_TeamName.pptx
│   ├── MinneMUDAC2026_TeamName.pdf
│   └── figures/
├── outputs/                # figures + Testset_Predictions_Submit.csv (gitignored)
├── Data/                   # raw xlsx — NEVER committed (gitignored)
├── .gitignore
└── README.md
Note the exception: the presentation/ deck and PDF are the one place you do commit a binary deliverable — but keep raw Data/ and generated outputs/ gitignored. The deck is the product; the data is not yours to share.

Starter .gitignore

Keep raw data, secrets, and environments out of the repo. A ready-to-use copy ships in this project as .gitignore.sample — rename it to .gitignore in your repo.

# --- raw + generated data (never commit competition data) ---
Data/
*.xlsx
*.csv
outputs/

# --- secrets / API keys (Census etc.) — keep these private ---
.env
*.key
secrets.*

# --- python ---
.venv/
__pycache__/
*.pyc
.ipynb_checkpoints/

# --- R ---
.Rhistory
.RData
.Rproj.user/

# --- presentation autosave (keep the .pptx + .pdf, drop the cruft) ---
~$*.pptx
~$*.docx

# --- OS / editor cruft ---
.DS_Store
.vscode/
Data & keys never go in Git. Competition data must stay off public repos, and API keys must never be committed. Everyone downloads the Data/ folder locally and stores their own free keys in a local .env that git ignores.