---------------------------------------------------------------------- This is the API documentation for the baseball_trajectory library. ---------------------------------------------------------------------- ## Data Player search and per-player career queries against the MLB Stats API. ## Model Weighted quadratic aging-curve fit and career summaries. ## Similarity Bill James similarity scores. ## Plots Matplotlib trajectory charts. ---------------------------------------------------------------------- This is the User Guide documentation for the package. ---------------------------------------------------------------------- ## Getting Started ## Prerequisites - Python 3.10 or newer. - An internet connection (the first search downloads a season roster from the MLB Stats API). ## Install Clone the repository, then create and activate a virtual environment (required on Debian/Ubuntu and other externally-managed Python installs — `.venv/` is already git-ignored): ```bash python3 -m venv .venv source .venv/bin/activate ``` Install in editable mode with the development extras: ```bash pip install -e ".[dev]" ``` ## Run Start the Shiny server: ```bash baseball-trajectory ``` The app binds to `127.0.0.1:8000`. Open in a browser. ## Your first trajectory 1. Leave **Player type** on *Active players* and **Position** on *Batter* (the defaults). 2. Type `Trout` in the **Player name** box. The dropdown populates with matches as you type — each row shows the player's position, e.g. *Mike Trout — CF (2011–Active)*. 3. Pick **Mike Trout** in the dropdown. A small info card appears confirming the pick (full name, position, debut–final seasons). 4. Click **Get Stats**. The trajectory plot, career summary, and season log fill in. 5. Try changing **Metric** from `OPS — On-base + Slugging` to `HR — Home runs` to see his home-run arc. ![The result of the first-trajectory walkthrough: scatter of seasons (size proportional to PA), the weighted-quadratic fit, a 95% confidence ribbon, and a dashed line at the fitted peak age.](images/career-trajectory-trout.png){width=720px fig-alt="Mike Trout's OPS-by-age trajectory"} ### Looking up a retiree Toggle **Player type** to *Retired players*, then type e.g. `Griffey` or `Ryan`. The auto-typeahead window widens to ~50 seasons in this mode, so older retirees come up directly. If a name still doesn't appear, click the **Search** button to do an even deeper lookup. See the [User guide](02-user-guide.qmd) for the full workflow. ## Stopping the server Press `Ctrl-C` in the terminal where `baseball-trajectory` is running. ## Next - Read the [User guide](02-user-guide.qmd) for the full sidebar walkthrough, including how to look up older retired players. - See the [Deployment](07-deployment.qmd) page if you want to host the app yourself (Posit Connect Cloud, Docker, etc.). ### User guide The sidebar is set up as a two-step workflow: **find a player, then load their stats.** Each step has its own button. ## 1. Tell the app what kind of player you want Three controls at the top of the sidebar set the search scope and the metric list: | Control | Choices | What it does | | ------------- | --------------------------------------------- | -------------------------------------------------------------------------------------- | | **Player type** | *Active players*, *Retired players* | Sets how far back the player search reaches. *Active* uses a small 3-season window for instant typeahead; *Retired* widens to ~50 seasons so older retirees come up. | | **Position** | *Batter*, *Pitcher* | Swaps the metric list and the weight column used in the curve fit (PA for batters, IP for pitchers). | | **Metric** | Batter or pitcher rate stats — see list below | Which stat to plot. Each dropdown option spells the acronym out, and a one-line definition appears below the dropdown when you change it. Full definitions, formulas, and benchmarks live on the [Metrics](03-metrics.qmd) page. | The Metric dropdown swaps with **Position**: | Position | Available metrics | | -------- | ----------------- | | **Batter** | **OPS** (on-base + slugging), **OBP** (on-base percentage), **SLG** (slugging percentage), **AVG** (batting average), **HR** (home runs), **ISO** (isolated power; `SLG − AVG`) | | **Pitcher** | **ERA** (earned run average; lower is better), **WHIP** (walks + hits per inning pitched; lower is better), **K/9** (strikeouts per 9 innings), **BB/9** (walks per 9 innings; lower is better), **HR/9** (home runs per 9 innings; lower is better) | ![The sidebar with the defaults: Active players, Batter, OPS. The inline help line under the Metric dropdown shows the formula and benchmark for the selected stat.](images/sidebar-active-batter.png){width=320px fig-alt="Sidebar configured for an active batter"} ## 2. Find a player Type a name in the **Player name** box (≥2 characters). As you type, the **dropdown below auto-populates** with matches — each row shows the player's name, primary position, and debut–final seasons: ``` Mike Trout — CF (2011–Active) Steve Trout — P (1978–1989) ``` That little position tag (`CF`, `P`, etc.) lets you confirm at a glance that you're picking a player whose actual position matches the **Position** toggle on the left. ### The dropdown is filtered by Position To stop you from picking a position player while *Pitcher* is selected (or vice versa), the dropdown is filtered by primary position: | Position toggle | What you see in the dropdown | | --------------- | ------------------------------------------------------------ | | **Batter** | Every primary position except pure pitchers (`P`). Catchers, infielders, outfielders, DHs, and two-way players (`TWP`) all stay in. | | **Pitcher** | Only `P` (pure pitcher) and `TWP` (two-way players such as Ohtani). Position players are hidden. | If your name search has matches but none survive the filter — e.g. you searched *"Trout"* with *Pitcher* selected and only Mike Trout (CF) came back — the picker shows a hint like *"No matching pitchers found. Try a different name or switch Position to Batter."* Flipping the **Position** toggle re-filters the existing search instantly without re-querying the API. ![The sidebar with Position set to Pitcher. The Metric list switches to ERA / WHIP / K/9 / BB/9 / HR/9, and the dropdown filter only admits players whose primary position is P or TWP.](images/sidebar-pitcher-mode.png){width=320px fig-alt="Sidebar in Pitcher mode"} ### Looking up a retiree? The auto-typeahead window depends on **Player type**: - **Active players** — covers the current season + 3 prior seasons. - **Retired players** — covers ~50 prior seasons (Griffey Jr., Nolan Ryan, Pedro Martínez, etc. all show up). If a retiree still doesn't appear (the auto window is capped to keep typeahead fast), click the **Search** button. That triggers a deeper lookup — up to ~80 seasons back in Retired mode — wide enough for mid-century players like Bob Feller or Stan Musial. ::: {.callout-note title="The first typeahead in Retired mode is slow"} Switching to *Retired players* and typing your first name walks through ~50 seasonal rosters from the MLB Stats API. On a cold cache that takes 15–25 seconds; you'll see a top-of-page progress bar while it runs. Every subsequent keystroke is instant. ::: ## 3. Confirm the pick After the dropdown is populated, pick a row. A small **info card** appears underneath with the player's name, primary position, and debut–final seasons: ![Light-blue card under the picker showing the active selection. Reads SELECTED PLAYER, then the name, then the position and career-year range.](images/selected-player-card.png){width=320px fig-alt="Selected-player info card"} This is your last chance to double-check the player matches the **Position** toggle before loading stats. If the position shown there disagrees with what you have on the left (e.g. you picked a center-fielder while *Position: Pitcher* is selected), the app will show a warning notification when you click Get Stats and the plot will suggest switching the Position toggle. ## 4. Click Get Stats Pressing **Get Stats** is what actually loads the trajectory plot, the career summary, and the season log. Picking a name in the dropdown alone doesn't trigger any work — that's deliberate, so the chart only updates on an explicit commit and dropdown re-renders never thrash the display. ## 5. Adjust filters (optional) - **Min PA / IP per season** — drops noisy partial seasons before the curve is fit. Hover the **ⓘ** icon next to the label for the full explanation. Default is 100; raise it for true full-time players or lower it to include rookie debuts and injury years. See [Metrics → Min PA / IP per season](03-metrics.qmd#min-pa-ip-per-season) for the threshold reference table. - **Refresh data cache** — clears the in-process roster and career caches. Useful when you've left the app running across a roster move or a new MLB game day. ## Reading the chart - Each dot is one season; **dot size scales with playing time** (PA for batters, IP for pitchers). - The solid curve is the **weighted quadratic fit**. - The shaded ribbon is the **95% confidence band**. - The dashed vertical line is the **fitted peak age** — only drawn when the quadratic is concave-down (i.e. a real peak exists). - For pitcher rate metrics where lower is better — **ERA** (earned run average), **WHIP** (walks + hits per inning), **BB/9** (walks per 9 innings), and **HR/9** (home runs per 9 innings) — the y-axis is **inverted** so the peak is at the top of the chart. ![A batter trajectory. Dot size scales with PA; the curve is the weighted-quadratic fit; the dashed line marks the fitted peak age.](images/career-trajectory-trout.png){width=720px fig-alt="Batter trajectory — Mike Trout's OPS by age"} ![A pitcher trajectory on a lower-is-better metric. Note the inverted y-axis: lower ERA is up, so the fitted peak still visually sits at the top of the chart.](images/career-trajectory-kershaw.png){width=720px fig-alt="Pitcher trajectory — Clayton Kershaw's ERA by age"} ## Reading the Career Summary The one-row Career Summary table reports: | Column | Meaning | | ------------------------ | ------------------------------------------------------------------ | | **Weighted *metric*** | The PA- or IP-weighted career average (rounded to two decimals). | | **Total PA / Total IP** | Lifetime opportunity in the metric's natural unit. | | **Best Season** | The single season with the highest (or for ERA/WHIP, best) value. | | **Best *metric* / Age** | Value and the player's age in the best season. | | **Peak Age** | The age the fitted quadratic peaks at (rounded to two decimals). | ## Reading the Season Log The Season Log shows one row per metric — not one row per season. Each row contains a **nanoplot** ([great-tables][gt-nanoplot]) tracing the metric's year-by-year values across the player's career. Use it to spot rise/peak/decline patterns at a glance without leaving the page. ![One row per metric, each with an inline sparkline of year-by-year values. The Metric column spells out the acronym (e.g. "OPS — On-base + Slugging") so no scrolling back to the legend.](images/season-log-nanoplots.png){width=720px fig-alt="Season Log nanoplots"} [gt-nanoplot]: https://posit-dev.github.io/great-tables/reference/GT.fmt_nanoplot.html ## Switching to another player 1. Clear the **Player name** box and type the next name. 2. Pick the new player from the refreshed dropdown. 3. Click **Get Stats** again to commit. The plot, summary, and season log all update to the newly committed player. The previous player remains visible until you click Get Stats, so you can browse search results without losing what you were looking at. ## Reference ### Metrics Reference for every stat the app can plot, plus the **Min PA / IP** knob that filters per-season data before the curve is fit. All formulas use the abbreviations as they appear in the underlying MLB Stats API. ## Batting metrics For batters, the **weight column** in the weighted least-squares fit is PA (plate appearances). Anchor points below are full-season values for full-time players. ### AVG — Batting average Hits per at-bat: ``` AVG = H / AB ``` A decimal between 0 and 1. | Range | Read | | ------ | --------------------- | | `.300+` | Excellent | | `.275` | Solid regular | | `.250` | Average MLB hitter | | `.220` | Below average | ### OBP — On-base percentage Fraction of plate appearances where the batter reached base by hit, walk, or hit-by-pitch: ``` OBP = (H + BB + HBP) / (AB + BB + HBP + SF) ``` Where `SF` = sacrifice flies. OBP excludes errors and fielder's choices. | Range | Read | | ------- | ------------ | | `.400+` | Elite | | `.360` | All-Star | | `.330` | Solid regular| | `.300` | Below average| ### SLG — Slugging percentage Total bases gained per at-bat. A double counts as 2, a triple as 3, a home run as 4: ``` SLG = (1B + 2·2B + 3·3B + 4·HR) / AB ``` | Range | Read | | ------- | -------------- | | `.500+` | Power hitter | | `.450` | Strong power | | `.400` | Average | | `.350` | Limited power | ### OPS — On-base + Slugging The sum of OBP and SLG. The single most-used summary of offensive value: ``` OPS = OBP + SLG ``` | Range | Read | | ------- | -------------- | | `.900+` | All-Star tier | | `.800` | Above average | | `.700` | Average | | `.600` | Below average | ### HR — Home runs The count of home runs in a season — a raw total, not a rate stat. The curve fits this against age directly. | Range | Read (full season) | | ------ | ------------------- | | `40+` | Elite power | | `30` | Power hitter | | `20` | Full-time regular | | `10` | Part-time / contact| ### ISO — Isolated power Pure extra-base power, with singles' contribution removed: ``` ISO = SLG − AVG ``` A batter who hits only singles has SLG = AVG and therefore ISO = 0. | Range | Read | | ------- | -------------- | | `.250+` | Elite power | | `.200` | Power threat | | `.150` | Average | | `.100` | Light power | ## Pitching metrics For pitchers, the weight column is IP (innings pitched). All per-9 rates multiply by 9 to extrapolate to a full game's worth of innings. ### ERA — Earned run average Earned runs allowed per 9 innings: ``` ERA = 9 × ER / IP ``` Where `ER` excludes runs that scored after a fielding error. **Lower is better.** | Range | Read | | ------ | -------------- | | `<2.00`| Cy Young tier | | `3.00` | All-Star | | `4.00` | Solid regular | | `5.00+`| Below average | ### WHIP — Walks + Hits per Inning Pitched Baserunners allowed per inning: ``` WHIP = (BB + H) / IP ``` **Lower is better.** | Range | Read | | ------ | -------------- | | `<1.00`| Elite | | `1.20` | All-Star | | `1.30` | Solid regular | | `1.40+`| Below average | ### K/9 — Strikeouts per 9 innings ``` K/9 = 9 × K / IP ``` **Higher is better.** | Range | Read | | ----- | -------------- | | `11+` | Power pitcher | | `9.0` | Strong | | `7.0` | Average | | `5.0` | Contact pitcher| ### BB/9 — Walks per 9 innings ``` BB/9 = 9 × BB / IP ``` **Lower is better** (better control). | Range | Read | | ------ | ----------------- | | `<2.0` | Elite control | | `2.5` | Strong | | `3.5` | Average | | `5.0+` | Wild | ### HR/9 — Home runs per 9 innings ``` HR/9 = 9 × HR / IP ``` **Lower is better.** | Range | Read | | ------- | -------------- | | `<0.8` | Elite | | `1.0` | Strong | | `1.2` | Average | | `1.5+` | Homer-prone | ## Min PA / IP per season The **Min PA / IP per season** knob in the sidebar drops per-season rows from the curve fit when they fall below a minimum playing-time threshold. The unit follows the **Position** toggle: - **PA (plate appearances)** when *Position* is *Batter*. - **IP (innings pitched)** when *Position* is *Pitcher*. It exists because a 50-PA September call-up or a 20-IP injury year carries almost no signal about a player's true ability. A weighted quadratic fit will still treat such a row as a data point (lightly weighted, but present), and very-low-weight seasons can drag the fit when they're outliers. Setting a minimum drops those rows entirely. ### Common thresholds | Value | What it represents | | --------- | ------------------------------------------------------------------------------ | | `502 PA` | The official batting-title qualification (3.1 PA × games scheduled). | | `162 IP` | The official ERA-title qualification (1 IP × games scheduled). | | `300 PA` / `100 IP` | Typical "full-time regular" lower bound. | | `100` | The default; keeps most non-trivial seasons. | | `0` | Disables the filter — every season counts toward the fit. | For a player whose role changed substantially over time (starter-turned-reliever, regular-turned-bench), tuning this knob up to focus on their prime role often gives a cleaner curve. For a young player with only a few full seasons, tune it down to include near-rookie years. ## See also - [Modeling approach](04-modeling.qmd) — how the quadratic fit uses these metrics as the response variable, and how weights are applied. - [Methods](05-methods.qmd) — worked example using OPS for Mickey Mantle, with the centered-form interpretation. ### Modeling approach The app fits a single-metric **weighted quadratic aging curve** for each player. ## The model For seasons indexed by Age, weighted by playing time: ``` y = β₀ + β₁·Age + β₂·Age² + ε ``` Implementation: `statsmodels.api.WLS(y, X, weights=w).fit()`, with weights equal to PA (batters) or IP (pitchers). Seasons with more playing time pull the curve harder than partial seasons. ## Why weighted A 90-PA September call-up and a 700-PA full season carry the same arithmetic information to ordinary least squares, but they're not equally informative about the player's true ability. Weighting by PA pushes the fit toward seasons that contain more signal. ## Peak age When β₂ < 0 the parabola opens downward and there's a real maximum. Setting the derivative to zero gives the peak age in closed form: ``` Age* = -β₁ / (2·β₂) ``` That's the value the app marks with a dashed vertical line and labels *Peak: ##.#*. When β₂ ≥ 0 the curve is flat or concave-up — usually a sign of an unfinished or unusual career — and the peak marker is suppressed. ## Confidence band `get_prediction(X).summary_frame(alpha=0.05)` produces a 95% mean prediction interval at each input age. The shaded ribbon spans `mean_ci_lower` to `mean_ci_upper`. It widens at the edges of the career, where fewer seasons constrain the fit. ## Career summary `summarize_career()` returns: | Field | What it is | | ---------------- | ------------------------------------------------------------ | | `weighted_mean` | Career rate stat, weighted by the same PA/IP as the fit. | | `best_season` | The season with the highest `y_col` (Season, Age, value). | | `peak_age` | Closed-form peak from the fit (or `None` if non-concave). | | `total_weight` | Career total of PA or IP. | The one-row summary table in the UI is built from this dict. ## Assumptions and caveats - **A single quadratic shape** describes the arc. Bimodal arcs (injury-recovery careers, mid-career position changes, knuckleballers) won't be modeled well. - **Weights are exogenous.** A manager who benches a struggling player reduces their PA, which biases the fit slightly toward years the player was healthy and effective. - **No era adjustments.** A 1908 OPS is treated the same as a 2019 OPS. See [Limitations](08-limitations.qmd) for more. ### Methods This page walks through the analytical methods that back the app, in the order they're typically used: fit a player's aging curve, interpret its coefficients, find similar players, and overlay their trajectories. All snippets use `polars` and the package's own helpers. They run in a notebook or REPL after `pip install -e .`. None of the snippets import `pandas`. ## A worked example: Mickey Mantle The book *Analyzing Baseball Data with R* (Marchi, Albert, Baumer) uses Mickey Mantle as its walking example for aging curves. We'll do the same — but with Mantle's player ID and modern MLB Stats API data. ```python from baseball_trajectory.data import search_player, get_batting_career matches = search_player("Mantle", seasons_back=29) mantle_id = matches.filter(matches["fullName"] == "Mickey Mantle")["playerID"][0] mantle = get_batting_career(mantle_id) mantle.select(["Season", "Age", "PA", "AB", "OPS"]).head() ``` `get_batting_career` already handles the bookkeeping the R walkthrough does manually: zero-filling `SF` and `HBP` for old seasons, recomputing `SLG`, `OBP`, `OPS` from the underlying counting stats, and aggregating multi-stint seasons. Age is computed with the standard mid-season rule (July 1) — see [Data sources](06-data-sources.qmd#age-computation) for details. ## The quadratic aging curve For a given metric `y` — OPS (on-base + slugging), AVG (batting average), ERA (earned run average), or any of the other metrics listed on the [Metrics](03-metrics.qmd) page — we fit a weighted least-squares quadratic across the player's seasons: ``` y = β₀ + β₁·Age + β₂·Age² + ε ``` Weights are PA for batting metrics and IP for pitching metrics, so full-season campaigns pull harder than 30-PA cups of coffee. `fit_player_curve` returns the fitted coefficients along with the closed-form peak age and the predicted value at that peak: ```python from baseball_trajectory.model import fit_player_curve fit = fit_player_curve(mantle, y_col="OPS", weight_col="PA") print(fit["peak_age"], fit["max_value"], fit["curvature"]) ``` ### Centered form (R-style interpretation) A non-centered fit hides the intuition behind the coefficients: `β₀` (the predicted value at Age = 0) and `β₁` (the slope at Age = 0) aren't interesting numbers. Re-fitting with `(Age − center)` as the predictor gives each coefficient a direct meaning. `fit_aging_curve` re-parameterizes as: ``` y = A + B·(Age − center) + C·(Age − center)² + ε ``` with `center=30.0` by default. The coefficients are interpretable: | Coefficient | Interpretation | | ----------- | -------------------------------------------------------------------------- | | `A` | Predicted `y` at `Age = center` (default age 30). | | `B` | Slope at `Age = center`. Positive ⇒ still rising at 30; negative ⇒ already declining. | | `C` | Curvature. For a true peak `C < 0`; more negative ⇒ sharper peak. One simple reading: `C` is the *change in y from peak to one year past peak*. | With `C < 0`, the peak age and the value at the peak are closed-form: ``` peak_age = center − B / (2·C) max_value = A − B² / (4·C) ``` In Python: ```python from baseball_trajectory.model import fit_aging_curve fit = fit_aging_curve(mantle, y_col="OPS", weight_col="PA", center=30.0) fit["A"], fit["B"], fit["C"], fit["peak_age"], fit["max_value"] ``` The R walkthrough reports Mantle's fitted curve as roughly `1.043 − 0.023·(Age − 30) − 0.0039·(Age − 30)²`, with a peak at age 27 and a peak OPS of 1.077. The Python fit reproduces those numbers (modulo small differences from the API's per-season data vs. the Lahman bundle). `fit_aging_curve` and `fit_player_curve` describe the *same parabola* — the centered form just shifts which coordinates the coefficients correspond to. `peak_age` and `max_value` are translation-invariant and agree exactly between the two functions. ### Plotting Mantle's curve The app's `plot_trajectory` overlays the fit, the 95% confidence ribbon, and a marked peak age. From a notebook: ```python from baseball_trajectory.plots import plot_trajectory fig = plot_trajectory( mantle, fit_player_curve(mantle, "OPS", "PA"), y_col="OPS", weight_col="PA", player_name="Mickey Mantle", ) fig.savefig("mantle-ops.png", dpi=150) ``` The plot mirrors the R version: scatter of OPS-by-age (point size proportional to PA), a fitted curve, a vertical dashed line at the peak, and an annotation showing the peak's location. ## Comparing players: Bill James similarity scores To pick a meaningful comparison group, the app uses Bill James's similarity-scoring algorithm, the same one Baseball-Reference's *Similarity Scores* page uses. Start at 1000 points and subtract: | Stat | One point per… | | ----------- | ----------------------------------------------- | | `G` | 20 games | | `AB` | 75 at-bats | | `R` | 10 runs scored | | `H` | 15 hits | | `2B` | 5 doubles | | `3B` | 4 triples | | `HR` | 2 home runs | | `RBI` | 10 RBI | | `BB` | 25 walks | | `SO` | 150 strikeouts | | `SB` | 20 stolen bases | | `AVG` | 0.001 batting average | | `SLG` | 0.002 slugging | …then subtract the absolute difference in **position values** (Bill James's table): | Position | Value | | -------- | ----- | | C | 240 | | SS | 168 | | 2B | 132 | | 3B | 84 | | OF | 48 | | 1B | 12 | | P / DH | 0 | ### In Python ```python from baseball_trajectory.data import get_player_career_totals from baseball_trajectory.similarity import similarity_score mantle_totals = get_player_career_totals(mantle_id) schmidt_totals = get_player_career_totals("121246") # Mike Schmidt similarity_score(mantle_totals, schmidt_totals) ``` `get_player_career_totals(player_id)` returns a dict with the career counting/rate stats plus the player's primary position — the exact shape `similarity_score` expects. ### Finding the *N* most similar players `find_similar_players(target, candidates, n)` ranks a pool of candidates against a target's career totals. The pool is the caller's responsibility: the function performs no network I/O. A typical notebook pattern: ```python import polars as pl from baseball_trajectory.data import get_player_career_totals from baseball_trajectory.similarity import find_similar_players target = get_player_career_totals(mantle_id) candidate_ids = ["121246", "117540", "120903", "..."] # known sluggers candidates = pl.DataFrame([get_player_career_totals(pid) for pid in candidate_ids]) top = find_similar_players(target, candidates, n=5) top.select(["fullName", "POS", "HR", "AVG", "sim_score"]) ``` **Why caller-supplied candidates?** Walking the entire MLB-Stats-API roster history is thousands of HTTP calls and seconds-to-minutes of latency. Limiting the pool to ~50 hand-picked candidates (e.g., the all-time HR leaderboard) gives meaningful comparisons in 30 seconds or so on first run, then is instant from cache. If you want an automated candidate pool, two patterns work: 1. **Seasonal pool** — pull a season's roster via `_load_roster(year)` and feed those IDs through `get_player_career_totals`. Good for "comparable to *this* era". 2. **Position-filtered pool** — fetch career totals for a small list of known players at the target's position. Good for "comparable types of player". ## Comparing trajectories side-by-side Once you have a player + comparison group, the small-multiples plot shows their curves on a shared axis grid: ```python from baseball_trajectory.data import get_batting_career from baseball_trajectory.plots import plot_trajectories_facet players = { "Mickey Mantle": get_batting_career(mantle_id), "Frank Thomas": get_batting_career("113194"), "Eddie Mathews": get_batting_career("118258"), "Mike Schmidt": get_batting_career("121246"), "Sammy Sosa": get_batting_career("123790"), } fig = plot_trajectories_facet(players, y_col="OPS", ncol=2) fig.savefig("comparison.png", dpi=150) ``` Each panel contains the player's per-season scatter (sized by season, fixed scale across panels) and a quadratic fit. Reading across panels: - Players whose fit is steep and concave-down peaked early (or briefly). - Flat fits — small `|C|` — describe steady careers (the Paul Molitor archetype). - Symmetric, deep parabolas describe classic *rise to peak, fall to retirement* careers (the Mike Schmidt archetype). For pitching metrics, pass `lower_better=True` to invert each panel's y-axis so the peak is at the top. ## Peak age and curvature, summarized Once you've fit curves for a group, the two most informative summary numbers per player are the **peak age** and the **curvature** `C`. A scatter of (peak_age, C) places each player on a simple map: early-vs- late peakers along the x-axis, sharp-vs-flat declines along the y-axis. ```python from baseball_trajectory.model import fit_aging_curve from baseball_trajectory.plots import plot_peak_vs_curvature fits = { name: fit_aging_curve(df, y_col="OPS", weight_col="PA") for name, df in players.items() } fig = plot_peak_vs_curvature(fits) ``` Players in the bottom-left peaked early and declined sharply; players in the top-right peaked late with shallow falloff. ## Historical scans The R walkthrough closes with three sweeping questions that require career fits for *every* player in the database: 1. How has the average peak age changed over baseball history? 2. Do longer careers peak later? 3. Does peak age vary by fielding position? The shape of the answer is the same in Python — fit one model per player, summarize the coefficients, plot the trend — but the data plumbing is heavier here. The MLB Stats API doesn't expose a single "give me every player's career totals" endpoint, so a full scan means walking ~30 seasons of rosters and fetching career stats per player (thousands of HTTP calls). ### Recipe Pseudocode for the general scan, given an in-process cache: ```python from baseball_trajectory.data import _load_roster, get_batting_career from baseball_trajectory.model import fit_aging_curve # 1. Build the candidate pool: every player who appeared in seasons N..N-29. pool = set() for year in range(2026, 1996, -1): pool.update(_load_roster(year)["playerID"].to_list()) # 2. Pull each player's career; fit; keep peak_age and midcareer. records = [] for pid in pool: career = get_batting_career(pid) # cached if career.height < 5: # skip very short careers continue fit = fit_aging_curve(career, "OPS", "PA") if fit is None or fit["peak_age"] is None: continue records.append({ "playerID": pid, "peak_age": fit["peak_age"], "midcareer": (career["Season"].min() + career["Season"].max()) / 2, "career_AB": career["AB"].sum(), }) # 3. Plot peak_age against midcareer (a LOESS or rolling-mean trend works # well — matplotlib alone doesn't ship LOESS, but `statsmodels.nonparametric. # smoothers_lowess.lowess` does). ``` This is intentionally not wrapped in a single helper: the run takes long enough to warrant explicit batching, error handling, and persistence to disk between runs. For sustained analytical use, persist the career-totals frame as a Parquet/CSV after the first scan and load it on subsequent runs. ### What the R walkthrough finds For reference, the *Analyzing Baseball Data with R* book reports the following from a Lahman-bundle scan: - **Peak age over time** — gradual rise from ~27 (1880s) to ~28 (2010s). - **Peak age vs. career length** — players with ≥9000 career AB tend to peak closer to 30; players with the minimum 2000 AB peak closer to 27. - **Peak age by position** — most positions cluster between 27 and 32; outliers (peak after 37) are concentrated at corner-OF and utility-IF spots, often players whose careers were extended by position changes. These shouldn't change much when sourced from the MLB Stats API. If you run the scan and want to make the findings part of the app, the recommended path is to compute offline and ship the resulting Polars DataFrame as a static parquet file alongside the package — not to re-fetch on every app start. ## Reference | Symbol | Where defined | | ---------- | ------------------------------------------------------ | | `A`, `B`, `C` | `fit_aging_curve` return dict (centered form) | | `peak_age` | `fit_player_curve` and `fit_aging_curve` return dict | | `max_value` | `fit_player_curve` and `fit_aging_curve` return dict | | `curvature` (= `C`) | `fit_player_curve` return dict | | `POSITION_VALUE` table | `baseball_trajectory.similarity` | | `similarity_score`, `find_similar_players` | `baseball_trajectory.similarity` | | `get_player_career_totals` | `baseball_trajectory.data` | | `plot_trajectories_facet`, `plot_peak_vs_curvature` | `baseball_trajectory.plots` | ### Data sources All stats come from the **MLB Stats API** (`statsapi.mlb.com`), the official source of record for Major League Baseball. The app calls the API directly via `requests` — no Python wrapper. ## Endpoints | Endpoint | Used by | | ---------------------------------------------------------------------------- | ----------------------------------------- | | `GET /api/v1/sports/1/players?season={year}` | `_load_roster()` (typeahead + deep) | | `GET /api/v1/people/{id}` | `get_*_career` (biographical data) | | `GET /api/v1/people/{id}/stats?stats=yearByYear&group={group}&sportId=1` | `get_*_career` (per-season splits) | ## Caching Three layers, all in-process: - **Roster cache** (`_CACHE`, dict keyed by `roster_`). One network round-trip per season per process. After the first fetch, every keystroke filters the cached Polars DataFrame locally — sub-millisecond per call. - **Career cache** (`@lru_cache(256)` on `get_batting_career` and `get_pitching_career`). Two HTTP calls per first lookup of a player; zero on subsequent lookups within the same process. - **Manual refresh** — `data.refresh_cache()` clears all of the above. The *Refresh data cache* sidebar button calls it. ## Search windows `search_player(query, seasons_back=N)` walks rosters from the current season back through `N` prior seasons, returning on the first season that yields matches. The app picks `N` based on the **Player type** toggle and on whether the user clicked **Search** explicitly: | Mode | Typeahead (auto, on each keystroke) | Search button (explicit deep lookup) | | ------------------------------ | -------------------------------------- | --------------------------------------- | | **Active players** | `seasons_back=3` | `seasons_back=29` | | **Retired players** | `seasons_back=50` | `seasons_back=80` | Active mode keeps typeahead fast (one season's roster is enough for the current MLB roster). Retired mode widens both windows so older retirees (Griffey Jr. 2010, Pedro Martínez 2009, Nolan Ryan 1993, Stan Musial 1963, …) come up — at the cost of a one-time roster-download burst when the user toggles into Retired and types their first keystroke (~15–25 seconds on a cold cache, instant afterwards). The Search button is also the *deep-search* fallback. If auto-typeahead returns nothing (e.g. typing a name in Active mode that only appears in older rosters), clicking Search runs the wider lookup and refreshes the dropdown — without committing or loading stats. ## Position in the roster cache Each cached roster row carries the player's primary fielding position (`primaryPosition.abbreviation` from the MLB Stats API — e.g. `CF`, `P`, `SS`). `search_player` returns it as a `position` column, and the app uses it in two ways: 1. **Dropdown labels** include the position next to the player's name — *Mike Trout — CF (2011–Active)* — so users can spot a mismatch before committing. 2. **The dropdown is filtered by the Position toggle**, so users never see candidates whose primary position can't yield the requested kind of career data. ### Position-filter rules The filter is applied in the app layer (`_filter_by_position` in `app.py`) right after every `data.search_player` call, before `search_results` is published to the dropdown: | Position toggle | Primary-position codes kept | | ----------------- | ----------------------------------------------------------------- | | **Pitcher** | `P` (pure pitcher), `TWP` (two-way player, e.g. Ohtani) | | **Batter** | Everything *except* `P` — `C`, `1B`, `2B`, `3B`, `SS`, `LF`, `CF`, `RF`, `OF`, `DH`, `TWP`, … | Rows whose primary position is `null` are excluded in both modes — we can't confirm the player has enough data either way. The filter is intentionally **static**: it uses the `primaryPosition` field that comes with the roster response, not a per-player career-stats lookup. That keeps the dropdown fast (no extra HTTP calls), at the cost of a slightly conservative definition of "has enough data" — a position player who once threw a knuckleball in a blowout game will be filtered out, but that's the right call for an aging-curve fit. When a search returns matches but the filter drops them all, the picker shows a position-aware empty state (*"No matching pitchers found. Try switching Position to Batter."*) instead of an empty dropdown. ## Multi-team season aggregation `yearByYear` splits return one row per team-season stint when a player was traded mid-season. The career loaders aggregate these: - **Counting stats** — summed across stints. - **Rate stats** — recomputed from the summed totals (so a traded batter's AVG is total H ÷ total AB, not the average of his per-team averages). This gives mathematically correct season-level rates regardless of trades. ## `inningsPitched` parsing The MLB API returns IP in **official notation**: `"180.2"` means 180⅔ innings, not 180.2 decimal. The tenths digit is *outs*, not real tenths. `_parse_ip()` converts to decimal innings, and the career loader stores `IPouts = round(decimal_ip * 3)` as the integer-aggregation form before recomputing IP. ## Age computation Age is taken as of **July 1** of each season — the standard mid-season rule that Baseball-Reference and FanGraphs use: ``` Age = season - birth_year - (1 if birth_month >= 7 else 0) ``` Players born July or later are counted as one year younger for that season. Birth date comes from the player's `/api/v1/people/{id}` response, parsed by `_parse_birth_date()`. ## No pandas API responses arrive as Python `dict`s and `list`s. They're built into Polars DataFrames directly via `pl.DataFrame(rows)` — no pandas intermediary. The one place pandas appears is `pl.from_pandas(...)` on the output of `statsmodels.get_prediction().summary_frame()`, which is converted on the same line and never used as pandas. ## Deployment ### Deployment The app is a standard Shiny for Python application — it can run anywhere Shiny apps run. This page covers the supported targets. ## Local For development: ```bash pip install -e ".[dev]" ``` Then either the console script: ```bash baseball-trajectory ``` Or the entry-point file directly: ```bash shiny run app.py ``` Both bind to `127.0.0.1:8000`. ## Posit Connect Cloud Two files at the repo root make the project deployable to [Posit Connect Cloud](https://connect.posit.cloud/) out of the box: | File | Role | | --------------------- | ---------------------------------------------------------------------------------------------------------------- | | `requirements.txt` | Connect Cloud's installer reads only this file. It does not run `pip install -e .` against `pyproject.toml`. | | `app.py` (root) | Deployment shim. Adds `src/` to `sys.path` and re-exports the Shiny `app` so Connect Cloud's primary-file mechanism finds it. | ### Steps 1. Push the project to a GitHub repository (Connect Cloud deploys from GitHub). 2. In Connect Cloud, click **New Content → Shiny for Python**. 3. Select your GitHub repository and branch. 4. Set **Primary file** to `app.py` — the root one, not `src/baseball_trajectory/app.py`. 5. Click **Deploy**. Connect Cloud installs `requirements.txt`, imports the primary file, and serves the resulting Shiny `App` object. ### Pinning dependencies The default `requirements.txt` lists deps without version constraints — Connect Cloud picks compatible versions at deploy time. For reproducible deploys, pin to your working environment: ```bash pip freeze | grep -E "^(shiny|polars|numpy|statsmodels|matplotlib|requests)==" > requirements.txt ``` Or use `pip-compile` (from `pip-tools`) to derive `requirements.txt` from `pyproject.toml`: ```bash pip install pip-tools ``` ```bash pip-compile pyproject.toml -o requirements.txt ``` Re-run on dependency changes and commit the result. ### Egress The app fetches roster and career data from `https://statsapi.mlb.com`. If your Connect Cloud account has egress restrictions, allowlist that host. ### Why two `app.py` files? The package layout uses `src/baseball_trajectory/app.py` for the real Shiny code. The root-level `app.py` is a deployment shim — it exists because Connect Cloud doesn't install the package, just runs the primary file. Local development uses the console script (`baseball-trajectory`), which goes through `__main__.py` and never touches the root shim. | File | Role | | -------------------------------------- | ------------------------------------------------------------------ | | `src/baseball_trajectory/app.py` | Real Shiny UI + server code. | | `app.py` (root) | Adds `src/` to `sys.path` and re-exports `app`. Used by Connect Cloud and `shiny run app.py`. | ## Other Shiny hosts Posit Connect (on-prem), ShinyApps.io, and self-hosted Uvicorn all support the same primary-file pattern. The root `app.py` is portable across them. For pure local serving without the console script: ```bash shiny run app.py ``` For container deploys, install `requirements.txt` and run `shiny run app.py` as the container entry point. A minimal Dockerfile: ```dockerfile FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY src/ src/ COPY app.py . EXPOSE 8000 CMD ["shiny", "run", "--host", "0.0.0.0", "--port", "8000", "app.py"] ``` ## Documentation site The docs themselves deploy to GitHub Pages via `great-docs setup-github-pages` — see the **Publishing** section of the README. ### Limitations Known behaviours of this app and where they come from. ## Search coverage The MLB Stats API endpoint we use (`/api/v1/sports/1/players?season=Y`) returns only players who appeared in season `Y`. The app stitches together rosters across recent seasons: - **Typeahead** covers the current season + 3 prior seasons. - **Deep search** (Search-button fallback) covers ~30 prior seasons. Players who retired before the deep-search window aren't reachable through the UI. The MLB Stats API has older records; adding the all-time `/api/v1/people/search` endpoint would extend coverage if needed. ## Single-shape model The curve assumes one quadratic describes the entire career. This is a poor fit for: - **Injury-recovery careers** — a dip mid-career, a recovery, then decline. - **Position changes** that move a player's offensive baseline (e.g. catcher → first base). - **Knuckleballers** and other pitchers with unusual aging patterns. The output isn't lying when this happens; it's just averaging. ## Aggregation across stints Multi-team seasons (when a player is traded mid-year) are aggregated into a single season row by **summing counting stats**. Rate stats are **recomputed from the summed totals** — so a traded batter's AVG is total H ÷ total AB, not the mean of his per-team averages. This is the right thing in almost every case, but if you're pulling data programmatically and need stint-level granularity, you'll need to call the MLB Stats API directly. ## No adjustments Raw counting and rate stats only: - **No park factors** — Coors Field hitters look better than they should. - **No era adjustments** — a 1908 OPS is treated the same as a 2019 OPS. - **No DH/non-DH splits** — pitchers' bat-side stats from before the universal DH are mixed in. - **No league-strength weighting**. ## Age cutoff Age is computed as of July 1 — the standard mid-season rule. Players born July or later are counted as one year younger for that season. Borderline cases (a player born June 28 vs. July 2) appear in adjacent age buckets even though they're days apart. ## "Active" / "final season" labels In the search dropdown, the trailing label is one of: - `Active` — the player has the `active=True` flag on the most recent roster they appear on. - A four-digit year — the most recent season the player appears on a searched roster. This is a proxy for *final season*, not an authoritative answer. Players in the deep-search range who played in a season *outside* our 30-year window will show a year inside the window.