A full-stack ML system that recommends NFL play calls for the Chicago Bears in real time, driven by two CatBoost models trained on 2025 play-by-play data.
flowchart LR
A["React Form\n(game situation)"] -->|"POST /recommend"| B["FastAPI\n(rate limited)"]
B --> C["Recommendation\nService"]
C --> D["~138 Candidate\nPlays Generated"]
D --> E["CatBoost\nClassifier\nSuccess Prob."]
D --> F["CatBoost\nRegressor\nProjected Yards"]
E --> G["Policy Layer\n(context weights,\nhard constraints)"]
F --> G
G -->|"Top play +\n6 alternatives"| B
B -->|JSON| A
Each request generates ~138 candidate plays — 3 personnel groups (11, 12, 13) × formations × run geometry (location × gap × ball carrier) + 3 personnel × formations × pass geometry (location × depth) — scores them through both models in a single batched call, then applies a context-aware policy layer that adjusts weights for down-and-distance, red zone, two-minute drill, and late-game scenarios before selecting the best play and up to 6 deduplicated alternatives.
Both models are trained on 2025 Chicago Bears offensive play-by-play data via nflreadpy, split by game_id to prevent data leakage. The engine is opponent-agnostic and situation-driven; posteam_type (home/away) is collected from the user and fed directly to the model.
| Model | Algorithm | Task | Eval Metric |
|---|---|---|---|
| Success Classifier | CatBoostClassifier | Binary — did the play meet the down-specific yardage threshold? | AUC |
| Yards Regressor | CatBoostRegressor (Quantile) | Continuous — how many yards does this play gain? | MAE / RMSE |
Success thresholds by down:
- 1st down — gain >= 40% of yards to go
- 2nd down — gain >= 60% of yards to go
- 3rd / 4th down — full conversion
18 input features: down, ydstogo, yardline_100, game_seconds_remaining, half_seconds_remaining, score_differential, posteam_timeouts_remaining, defteam_timeouts_remaining, no_huddle, posteam_type, play_type, run_location, run_gap, run_player, pass_location, pass_depth_bucket, shotgun, offense_personnel (11 / 12 / 13)
To see validation metrics, run
python backend/ml/training/train.pyand check the printed AUC, MAE, and RMSE output.
pip install -r requirements.txt
uvicorn backend.app.main:app --reload
# API available at http://localhost:8000cd frontend/my-vite-app
npm install
npm run dev
# UI available at http://localhost:5173Set VITE_API_BASE=http://localhost:8000 (default) or point to your deployed backend.
Request:
{
"down": 3,
"distance": 7,
"fieldPosition": 65,
"quarter": 4,
"timeRemaining": "02:30",
"scoreDifference": -3,
"posteam_type": "home",
"posteam_timeouts_remaining": 2,
"defteam_timeouts_remaining": 1
}Response:
{
"recommendedPlay": {
"type": "pass",
"shotgun": "shotgun",
"pass_location": "middle",
"pass_depth_bucket": "short",
"offense_personnel": "11",
"success_prob": 0.61,
"expected_yards": 8.2,
"score": 0.652341,
"run_location": "unknown",
"run_gap": "unknown",
"run_player": "not_run"
},
"successProbability": 61.0,
"expectedYards": 8.2,
"riskLevel": "medium",
"alternativePlays": [...]
}{ "ok": true }Rate limit: 60 requests / minute per IP.
backend/
app/
main.py FastAPI app, CORS, rate limiting, lifespan model loading
api/routes/ Endpoint handlers
services/ recommendation_service.py, policy_layer.py
ml/
features/ build_features.py — ETL from nflreadpy
training/ train.py — CatBoost model training
artifacts/ Serialized model files (.pkl)
frontend/
my-vite-app/
src/
components/ GameSituationForm, BestPlayRecommendation
services/ apiService.ts
types.ts
HealthCheck/
ping.py GitHub Actions health ping (prevents Render cold starts)
| Layer | Technology |
|---|---|
| Frontend | React 19, TypeScript (strict), Vite, React Router |
| Backend | Python, FastAPI, slowapi (rate limiting) |
| ML | CatBoost, scikit-learn, pandas, nflreadpy |
| Deployment | Vercel (frontend), Render (backend) |
| CI | GitHub Actions (health check every 5 min) |
MIT
