Mariners — A Differential Analysis

Bioinformatics, by way of baseball

Author

B. Snel

Published

June 23, 2026

The pitch

Bioinformatics workflows boil down to a small set of motifs: load a matrix, normalize it, look for structure, and call out the rows or columns that don’t behave like the rest. The same motifs work fine on baseball, and baseball has the advantage of being immediately legible — you don’t need to know what a gene does to recognize a player having a hot month.

Bioinformatics view Baseball mapping
count / expression matrix player × stat table
z-scored heatmap with clustering offensive profile heatmap
volcano plot observed wOBA vs Statcast xwOBA, sized by PA
PCA on samples player profile embedding, coloured by position

Environment

Reproducibility is handled with renv. Run renv::restore() once and every package version below is pinned to the lockfile checked into the repo, so the analysis renders the same anywhere.

Code
# Load all the R packages we need (ggplot2 for charts, dplyr for data wrangling, etc.)
# "here" makes file paths work regardless of which computer renders this notebook,
# and charts.R holds the chart builders shared with the landing page.
source(here::here("R", "00_setup.R"))
source(here::here("R", "charts.R"))

1 · Load the matrix

Code
# Pull the latest Mariners batting and pitching numbers from the MLB Stats API
# (or from a saved backup if we're offline — see the note below).
# In the deploy pipeline the data is fetched once before rendering, so only
# fetch here if the saved files are missing (e.g. a standalone render).
if (!file.exists(here("data", "batting_2026.rds"))) {
  source(here::here("R", "01_fetch_data.R"))
}

# Read the saved data files into memory
batting  <- readRDS(here("data", "batting_2026.rds"))
pitching <- readRDS(here("data", "pitching_2026.rds"))

# Show the top 8 hitters by plate appearances as a formatted table
batting |> dplyr::arrange(dplyr::desc(pa)) |> head(8) |> knitr::kable(digits = 3)
player pos mlbam_id pa hr bb k ba obp slg woba xwoba diff babip hard_hit_pct avg_ev avg_la
Julio Rodríguez CF 677594 341 14 26 70 0.248 0.311 0.431 0.324 0.344 -0.020 0.276 105 89.966 13.270
Cole Young 2B 702284 317 6 21 57 0.253 0.316 0.361 0.303 0.328 -0.025 0.293 89 88.076 16.147
Josh Naylor 1B 647304 312 8 24 41 0.250 0.312 0.366 0.300 0.324 -0.023 0.267 95 87.959 14.087
Randy Arozarena UNK NA 302 7 28 66 0.291 0.378 0.448 0.365 0.353 0.012 0.361 86 91.651 7.717
J.P. Crawford SS 641487 254 10 38 47 0.219 0.350 0.386 0.334 0.351 -0.017 0.234 59 88.282 13.294
Luke Raley RF 670042 211 14 11 68 0.240 0.301 0.500 0.344 0.357 -0.013 0.291 60 90.747 16.066
Cal Raleigh C 663728 205 7 24 64 0.162 0.258 0.302 0.255 0.293 -0.038 0.200 35 87.350 23.205
Dominic Canzone DH 686527 201 12 18 39 0.281 0.353 0.562 0.390 0.384 0.006 0.295 69 92.916 11.901

The fetch step prefers the live FanGraphs / MLB Stats API feed. When the call fails — no network, a rate limit, an API hiccup — it falls back to a deterministic synthetic dataset so the rest of the notebook always renders. (On the live site that fallback is detected and never published.)

2 · Heatmap

Code
# Build the heatmap: each column is a player, each row is an offensive stat
# Colors show how far above or below average each player is for that stat
# Players with similar hitting profiles get grouped together automatically (the tree on top)
# Skip the script's PNG write (its dev.off() would break knitr's figure capture),
# then build the heatmap silently and grid.draw its gtable on knitr's device.
options(mariners.skip_png = TRUE)
source(here::here("R", "02_heatmap.R"))
grid::grid.draw(draw_heatmap(silent = TRUE)$gtable)

Read this exactly like an expression heatmap: columns are samples (players), rows are features (offensive rate stats), values are within-feature z-scores. The dendrogram on the top axis is the same hierarchical clustering you’d run on a cohort — players with similar offensive profiles end up adjacent.

3 · Volcano plot

Code
# Build the volcano plot:
#   - Left/right position = how much a player is out- or under-performing
#     what the ball-tracking data says they "should" be hitting
#   - Up/down position = how confident we are in that gap (more PAs = higher up)
#   - Dot size = number of plate appearances (bigger dot = larger sample)

# Even partway through a season, sample sizes are usually too small for the
# z-test to reach p < 0.05 at realistic wOBA−xwOBA gaps. Instead we color
# players whose gap exceeds ±0.020 — the y-axis still shows which gaps have
# the most statistical backing behind them.

chart_volcano(batting)   # shared builder from R/charts.R

The x-axis is effect size: observed wOBA minus the Statcast expected wOBA (xwOBA) derived from each player’s batted-ball profile (exit velocity, launch angle, sprint speed on grounders). The y-axis is the significance of that gap given the player’s plate appearances so far — small samples sit near the floor; players who have separated from their own expected line over a meaningful number of PAs get pushed up.

The cosmetic dashed gates are at ±0.020 wOBA and p < 0.05. Points outside those gates are the season’s “differentially expressed” hitters.

4 · PCA

Code
# Each player has 8 offensive stats (BA, OBP, SLG, wOBA, xwOBA, HR, BB, K).
# That's 8 dimensions — impossible to visualize on a flat screen.
#
# PCA ("Principal Component Analysis") finds the two most informative
# "summary axes" that capture the biggest differences between players,
# and projects everyone onto those two axes so we can plot them on a
# single chart. Think of it like taking an 8-dimensional cloud of points
# and finding the best camera angle to photograph it in 2D.
#

# PC1 (x-axis, 59% of variance) — Overall offensive quality

# Left = better hitters (the woba, xwoba, slg, obp arrows all point left)
# Right = weaker hitters (Leo Rivas, Rob Refsnyder — low across the board)
# This is the single most important axis: it captures nearly 60% of all the variation between players
# PC2 (y-axis, 20% of variance) — How they get their value

# Up = contact-oriented, low-strikeout hitters (Connor Joe, Brendan Donovan — they put the ball in play)
# Down = power-and-strikeout hitters (Cal Raleigh, Julio Rodríguez — the k and hr arrows both point down)
# So the two axes together tell you: "how good is this hitter?" (left-right) and "what kind of hitter are they?" (up-down).

# A few examples reading the chart:

# Luke Raley (far left, middle) — good hitter, balanced between power and contact
# Cal Raleigh (right, far bottom) — below-average results so far, but the profile is all power and strikeouts
# Randy Arozarena (far left, slightly below center) — productive hitter leaning toward the power side
# Connor Joe (top center) — contact-first, doesn't strike out, but not much power either

source(here::here("R", "04_pca.R"))
p   # the PCA biplot built by the script, drawn inline

Same biplot you’d produce from prcomp() on a count matrix: the player scores cluster by offensive profile, and the feature loadings (the grey arrows) tell you which stats are driving each axis. PC1 typically separates power from on-base; PC2 picks up the strikeout/contact axis.

5 · Pitching staff as a sanity check

Code
# ERA is the runs a pitcher has actually allowed; xERA is what the
# ball-tracking data says they "should" have allowed based on quality of contact
# A negative bar = pitcher has been better than expected (possibly lucky)
# A positive bar = pitcher has allowed more runs than his stuff would predict (possibly unlucky)

chart_pitching_luck(pitching)   # shared builder from R/charts.R

6 · Batting luck chart

Code
# wOBA is what actually happened at the plate; xwOBA is what Statcast says
# *should* have happened based on exit velocity, launch angle, sprint speed.
# Positive bar = player is out-performing (lucky / hot streak)
# Negative bar = player is under-performing (unlucky / cold streak)

batting |>
  dplyr::arrange(woba) |>
  dplyr::mutate(player = forcats::fct_inorder(player)) |>
  ggplot(aes(x = player, y = diff, fill = pos)) +
  geom_col() +
  geom_hline(yintercept = 0, color = "grey40") +
  coord_flip() +
  scale_fill_manual(
    values = c(
      C  = "#0C2C56", "1B" = "#005C5C", "2B" = "#1B998B",
      SS = "#3A86FF", "3B" = "#8338EC", LF = "#FB5607",
      CF = "#FFBE0B", RF = "#FF006E", DH = "#6A4C93", UT = "#7F7F7F",
      UNK = "#AAAAAA"
    )
  ) +
  labs(
    title    = "wOBA − xwOBA: Who's getting lucky?",
    x        = NULL,
    y        = "wOBA − xwOBA  (positive = outperforming batted-ball profile)",
    fill     = NULL
  )

Same idea as the pitching chart but for hitters. Players with positive bars are getting results that exceed what the ball-tracking data would predict — a mix of genuine hot streaks and sequencing luck (e.g. hits falling in at a higher-than-expected rate). Negative bars point to hitters whose underlying contact quality is better than the stat line shows — regression candidates to the upside.

7 · Hard contact vs results

Code
chart_hard_hit(batting)   # shared builder from R/charts.R

This plot answers the question: does hitting the ball hard actually lead to better outcomes? The dashed trend line shows the expected relationship — generally yes, but there are outliers. Players below the line are getting unlucky: they’re making elite contact that isn’t converting into hits at the expected rate. Players above are squeezing more value out of softer contact through plate discipline, placement, or sequencing luck.

A note on the grey band: it is the 95% confidence interval for the trend line itself — how precisely we’ve pinned down the average hard-hit-to-wOBA relationship given this small sample, not a “normal range” for individual players. With only a dozen-plus hitters, plenty of perfectly ordinary players fall outside it, so judge a player by how far they sit above or below the dashed line, not by whether they land inside the band.

8 · BABIP — who’s getting lucky?

Code
# BABIP = batting average on balls in play (excludes HRs and strikeouts).
# League average is ~.300 — sustained extremes are rare.
# High BABIP = hits are falling in (lucky, or elite bat-to-ball skill).
# Low BABIP = hard-hit balls finding gloves (unlucky, or poor launch angles).

chart_babip(batting)   # shared builder from R/charts.R

BABIP is one of the best early-warning indicators in baseball. A hitter with a .370 BABIP isn’t necessarily a better hitter — they might just be getting favorable sequencing (bloopers, well-placed grounders). Over a full season, most hitters settle between .280 and .320. Players in red are candidates to cool off; players in navy are due for positive regression. This is the same logic as identifying batch effects in a sequencing run — the underlying biology (talent) hasn’t changed, just the noise around the measurement.

9 · Launch angle vs production

Code
# Average launch angle (degrees) vs actual production (wOBA).
# The shaded band marks the productive line-drive window (~10–25°):
#   - far left  = too many grounders (low, weak contact on the ground)
#   - far right = too many pop-ups (steep, easy fly-ball outs)
# We plot against observed wOBA, NOT xwOBA, because xwOBA is itself built
# from launch angle — plotting it here would just be measuring the input
# against itself.

chart_launch_angle(batting)   # shared builder from R/charts.R

Exit velocity tells you how hard a ball was hit; launch angle tells you where it was aimed. The two together are what Statcast turns into xwOBA — and launch angle is the one a hitter has the most swing-shaping control over. Unlike hard-hit rate, the relationship to production isn’t a straight line: value lives in a sweet spot. Pound everything into the dirt (single-digit launch angles) and you trade extra-base hits for groundouts; get under the ball too often (high-20s and up) and fly balls die on the warning track or in gloves. The most productive hitters cluster inside the shaded band, where launch angles translate into line drives.

Read it alongside the hard-contact and BABIP charts: a hitter making elite contact (high hard-hit %) but sitting at the edges of the launch-angle band is a swing-tweak candidate — the raw power is there, it’s just being launched at the wrong angle. One whose average angle is squarely in the band but whose wOBA lags is more likely fighting sequencing luck than a mechanical problem.