Skip to content
Back to guides
Model checks

Build an NFL Spread Model in TensorFlow.js From Scratch

Shark Snip Editorial 11 min read

Read the price, role, and market first

A complete TensorFlow.js NFL betting model tutorial — define the architecture, prep the data, train in the browser, and evaluate against the closing spread.
13 sections
Build an NFL Spread Model in TensorFlow.js From Scratch cover art

This is the walkthrough I wish existed when I tried to build my first NFL spread model. Every tutorial Google surfaces is either Python on a beefy server or a dressed-up Excel pivot table. This one ships an actual neural network trained in TensorFlow.js, in your browser, on real NFL data — the same model architecture we use as the spread brick on Tinker. By the end you will have a working binary cover classifier, a 2024 holdout Brier score, and the weights saved as a brick you can drop into the builder.

I will not assume you know JavaScript well, but I do assume you have used Chrome devtools at least once. We are going to write maybe 80 lines of code total.

The dataset and the label

NFL spread models almost always benefit from being framed as a binary classifier. The label is: did the home team beat the closing spread? Using the positive=home convention we use everywhere in this codebase, that is:

const cover = (homeScore - awayScore) - spreadLine > 0 ? 1 : 0;
// Push (margin exactly equals spread) handled as 0.5

Some examples from 2024 to ground this. Bills home -3.5 vs Dolphins, final 31-10 → margin 21, cover = 1. Chiefs home -3.5 vs Saints Week 4, final 26-13 → margin 13, cover = 1. 49ers home -2.5 vs Cardinals Week 16, final 38-13 → cover = 1. Jets home +1.5 vs Rams Week 5, final 17-20 → margin -3, cover = 0 because -3 is not greater than +1.5.

Where the data comes from in the browser

The default /tinker NFL pack pulls from nflverse-derived parquet files (schedules, drives, EPA, ELO) preprocessed into a single 18MB gzip pack that streams to your tab on first load. Loading it is one line:

const pack = await loadPack('nfl-spread-v3');
console.log(pack.rows.length); // 8224 game-team rows through 2024 Week 22

Define a small, honest model

People bring transformers and 8-layer towers to NFL spread modeling. You do not need that. Twelve to twenty-four tabular features and ~8K rows want a two-layer MLP with dropout. Anything bigger is overfitting the variance.

import * as tf from '@tensorflow/tfjs';

function buildModel(numFeatures) {
  const model = tf.sequential();
  model.add(tf.layers.dense({
    inputShape: [numFeatures],
    units: 48,
    activation: 'relu'
  }));
  model.add(tf.layers.dropout({ rate: 0.25 }));
  model.add(tf.layers.dense({ units: 48, activation: 'relu' }));
  model.add(tf.layers.dropout({ rate: 0.25 }));
  model.add(tf.layers.dense({ units: 1, activation: 'sigmoid' }));

  model.compile({
    optimizer: tf.train.adam(0.001),
    loss: 'binaryCrossentropy',
    metrics: ['accuracy']
  });
  return model;
}

Why those numbers

48 units per layer is a sweet spot for 12-24 input features. Below 32 you lose capacity to model interactions like "high pass rate vs bad pass defense in cold weather." Above 96 you start memorizing 2020 Mahomes-era weirdness that does not generalize. Dropout 0.25 is aggressive enough to keep val loss stable. Adam at 1e-3 is the lazy default that just works; you can tune to 5e-4 if you see oscillation.

The training loop, with the right guardrails

Time-respecting split first, always. NFL is a temporal process — last week's injuries inform this week's outcomes. Shuffle before splitting and your "amazing" Brier evaporates in production.

const splitByYear = (rows, trainYears, valYear, testYear) => {
  const train = rows.filter(r => trainYears.includes(r.season));
  const val   = rows.filter(r => r.season === valYear);
  const test  = rows.filter(r => r.season === testYear);
  return { train, val, test };
};

const { train, val, test } = splitByYear(pack.rows, [2018,2019,2020,2021,2022], 2023, 2024);
const xTrain = tf.tensor2d(train.map(r => r.features));
const yTrain = tf.tensor1d(train.map(r => r.cover));
const xVal   = tf.tensor2d(val.map(r => r.features));
const yVal   = tf.tensor1d(val.map(r => r.cover));

const model = buildModel(xTrain.shape[1]);
await model.fit(xTrain, yTrain, {
  epochs: 50,
  batchSize: 64,
  validationData: [xVal, yVal],
  callbacks: tf.callbacks.earlyStopping({
    monitor: 'val_loss',
    patience: 5,
    restoreBestWeight: true
  })
});

What the loss curve should look like

Train loss should fall faster than val loss, and val loss should hit a clear floor between epoch 15 and 30. If val loss never improves past epoch 5, your model is too small or your features are bad. If train loss keeps falling while val rises, you are overfitting — bump dropout to 0.35 or shrink units to 32.

Internal benchmark for the architecture above on the standard /tinker pack: epoch 1 val Brier 0.249, epoch 8 around 0.241, epoch 20 plateau at 0.237. Vegas closing spreads on the same 2023 val set hit Brier 0.244. We beat the line by 0.007 — small in absolute terms, real in CLV terms.

Evaluate against the closing line, not just labels

Brier is necessary but not sufficient. The harder test: does your model produce different predictions than Vegas? A model that just learns "predict whatever the closing spread implies" will have a good Brier but zero edge. Two diagnostics:

  • Predicted vs market scatter. Plot your model's implied home margin vs the closing spread line. Slope should be near 1.0, but with meaningful scatter — that scatter is where edges live. If the points sit perfectly on the diagonal you have a market-replicator, not a model.
  • Edge-bucket calibration. Group bets by predicted edge (1-2, 2-3, 3-5, 5+). Win rate per bucket should rise with edge. If the 5+ bucket loses to the 1-2 bucket, your model is mis-calibrated at the extremes — covered in depth in the Brier explainer.

Save weights and export as a brick

Once a model trains and validates, save it locally:

await model.save('indexeddb://nfl-spread-v3');
// Or to download as files:
await model.save('downloads://nfl-spread-v3');

The downloads variant gives you nfl-spread-v3.json (architecture + metadata) and nfl-spread-v3.weights.bin (the raw float32 tensor). Drop those into /tinker's "import brick" panel and the model becomes a reusable block in your catalog. From there it can be wired into /picks for live scoring, A/B'd against the production brick, or shared in workshop.

Brick versioning conventions

Use semver-ish tags. v3.0 when you change the feature schema, v3.1 when you retrain on more data, v3.1.1 when you tweak hyperparameters but keep features+data identical. The brick metadata records seed, training set, val Brier, and CLV — so a backtest you publish in our monthly release notes is reproducible.

Tuning notes from production

A few hyperparameters matter more than the rest. Most do not.

Batch size

32-128 all train to nearly identical val Brier. 64 is the default because it lands cleanly on power-of-two memory boundaries in TF.js. Going below 32 makes training noisier; above 128 and small datasets like NFL spreads underfit because batch gradient is too smooth.

Learning rate

Adam at 1e-3 is the lazy default and it works for NFL spreads. If you see val loss oscillating in the first 5 epochs, drop to 5e-4. If val loss plateaus too early (epoch 4-5), bump to 1.5e-3. We have not seen a tuned learning rate beat the lazy default by more than 0.001 Brier on this problem.

Dropout

0.2-0.3 is the sweet spot. Below 0.15 the model overfits the 2018-2022 train. Above 0.4 it underfits and val loss never drops below 0.243. Two dropout layers between the dense layers is enough; adding a third before the output head hurts calibration.

Epochs and early stopping

Set epochs to a high number (50-100) and rely on early stopping. The model will reliably finish between epoch 18 and 30 depending on random init. Trying to manually pin a specific epoch count is the most common form of hyperparameter overfitting.

The seed

Set tf.randomUniform's seed before model build. Without a seed your weights initialize differently each run and val Brier varies by ±0.001-0.002. Pin the seed for reproducibility, document it in the brick metadata, and use a different seed only when explicitly probing model robustness.

Sanity checks before you trust any model

Three diagnostic runs you should do before betting any dollars based on the model's predictions.

Shuffle the labels

Train the same architecture on the same features but with randomly-shuffled cover labels. The val Brier should collapse to roughly 0.250 (random binary). If it stays at 0.240, you have a feature that is leaking the label. The most common offender: a "cover_prediction_from_betting_market" column accidentally included from the data join.

Train on just spread_line

Strip all features except the closing spread, train the same MLP. Val Brier should be roughly 0.244 (matching Vegas, because the closing spread is Vegas). If your full model beats this by less than 0.003, the additional features are not adding meaningful signal. If by 0.005+, you have a real model.

Train without spread_line

Strip the spread_line feature, train on everything else. Val Brier should land around 0.246-0.249 — slightly worse than Vegas, because you have all the public information except the market's aggregation. This tells you how much your "pure model" knows independent of the market. The delta between this and the full-model Brier is the market's information content; the delta between full-model Brier and Vegas's Brier is your edge.

From single model to a brick you can deploy

The model file is 38KB. The architecture metadata is a few hundred bytes. Together they form a brick — a portable unit of inference that runs anywhere TF.js does, including a phone browser, an Electron app, or a Cloudflare Worker. The builder ingests bricks and wires them into composable predictors. A common pipeline:

  • Spread brick (this post) outputs cover probability.
  • Total brick (separate file, similar architecture, predicts over/under) outputs total-over probability.
  • Composer wires both into a same-game parlay edge calculator — see SGP math.
  • Output streams to the picks dashboard for live evaluation against current sportsbook lines.

That whole pipeline runs in the browser. Two bricks, one composer, one dashboard. No server.

Going further once it works

The MLP above is the starting point, not the finish line. From here:

  • Add presnap formation features from the Big Data Bowl pack — the average pre-snap motion rate of the home offense correlates with cover when it deviates from the team's season norm.
  • Stack the MLP with a tree-based brick in workshop. The ensemble's Brier almost always beats either alone by 0.001-0.003 because trees and MLPs make different kinds of mistakes.
  • Compare your stack's predictions against a properly-run backtest and against the published spread brick on /picks. If your model loses to the production model, the diff isolates where to dig.
  • Read the comparison with Python pipelines so you know what you are giving up and gaining.

The whole pipeline — define, train, evaluate, save, deploy — fits in a single browser tab in under five minutes on a modern laptop. The barrier to "having a real model" is much lower than the tutorials make it look.

Common first-model failure modes

Six failures I see repeatedly when bettors share their first TF.js NFL models for feedback.

1. Random shuffle before split

Brier looks amazing in training (0.21), evaporates the moment you hit real future games. Always sort by date and split by season, never use tf.data.shuffle() across the full dataset before splitting train/val/test.

2. Using closing line as a feature

If you train on closing spread as an input, the model just learns to copy Vegas. Brier scores will look great because they match Vegas; ROI will be zero because you have no edge. Closing line is the benchmark, not a feature. Use opening line or a midweek snapshot if you need a market-style feature.

3. Forgetting to normalize

ELO values range 1300-1900, EPA/play ranges -0.3 to +0.3, days_rest is single digits. Without normalization (z-score or min-max scale), the model's gradient updates are dominated by the largest-magnitude features. Add a normalization layer or pre-normalize the data into the tensor.

4. Training too long without early stopping

50 epochs without early stopping on 8K rows of NFL data is a one-way ticket to overfit. Val loss will start rising after epoch 25-30. Without tf.callbacks.earlyStopping you keep the worst weights.

5. Treating accuracy as the headline

Accuracy at 53% sounds bad. For a binary spread classifier, the goal is not 50% baseline; it is the implied probability from the closing line, which already encodes most of the information. Beating Vegas Brier by 0.005 with 53% accuracy is meaningfully profitable.

6. Trusting the first checkpoint

Train the model three times with three different seeds. Check that val Brier lands within 0.002 each time. If it varies wildly (0.235 once, 0.244 another, 0.241 a third), your architecture is unstable and you need more regularization or fewer parameters.

Model inputs worth naming

Use names as evidence, not decoration. The useful SEO win is that Josh Allen, Ja'Marr Chase, Bijan Robinson and Puka Nacua and Chiefs, Bills, Dolphins, Rams and 49ers appear inside decisions, thresholds, and internal links instead of being dumped into a keyword list.

  • NFL model: route participation for Ja'Marr Chase, rushing attempts for Josh Allen, pressure rate allowed by the Bengals, and red-zone carry share for Jonathan Taylor should be separate features.
  • NBA model: usage, projected minutes, rest, and pace should move Nikola Jokic or Shai Gilgeous-Alexander props differently than a one-number power rating.
  • MLB model: Tarik Skubal strikeout projection, Coors Field park factor, lineup confirmation, and bullpen rest need their own columns.
  • Review loop: grade entry price, closing price, bet result, and model error separately so lucky results do not hide bad forecasts.

Build or audit the workflow in Tinker and review it with CLV.

Research note board

Use this model-audit board to keep features, validation, and bet sizing from collapsing into one confidence score.

Model layerWhat to inspectExample inputDowngrade when
FeatureWhether the variable maps to the sport and marketJosh Allen role data or ADP price movementThe feature is a proxy for something you can measure directly
ValidationOut-of-sample error, CLV, calibration, missing dataChiefs market movement after injury newsWins come without beating the close or improving calibration
SizingBankroll, confidence interval, correlation, market limitCLV exposure compared with related ticketsMultiple bets repeat the same thesis at full stake

Bet responsibly — set limits, never chase losses.

Model calibration: predicted vs observed

Predicted win probability bucket vs the empirical win rate inside that bucket on the test set. Points on the y=x reference line are perfectly calibrated; points below mean the model is overconfident in that bucket.

EV per $100 across win rate × odds grid

Expected value of a $100 stake at each combination of true win rate and market odds. Anywhere the cell is positive you have a long-run profitable bet; the magnitude shows how aggressive Kelly will size it.

Frequently asked questions

Why pick binary cover instead of predicting the actual margin?
A margin regression head (predict that Jets-Bills will be Bills by 4.2) is mathematically richer but harder to calibrate cleanly. For a first model, binary cover (1 if home covers, 0 if not, 0.5 push) lets you use binaryCrossentropy loss, sigmoid output, and Brier score evaluation — three numbers that move together. Once your binary model beats Vegas Brier by 0.005+, graduate to a two-head model that predicts margin and total. The 49ers -7 vs Cardinals game from 2024 Week 16 is the canonical case where margin head would have called the 38-13 blowout while binary head was indifferent above the spread.
What is the smallest feature set that actually beats Vegas Brier?
Twelve features hit the floor in our internal A/B: pregame_elo_home, pregame_elo_away, qb_elo_home, qb_elo_away, off_epa_pp_last8_home, off_epa_pp_last8_away, def_epa_pp_last8_home, def_epa_pp_last8_away, spread_line, total_line, days_rest_home, days_rest_away. With those twelve, the MLP we describe in this post hits Brier 0.241 on 2024 holdout (Vegas 0.244). Adding wind_mph + roof + surface pushes to 0.238. The 24-feature pack in /tinker reaches 0.236 but the marginal feature each adds is 0.0003-0.0005 — a real but small lift.
How long does training take on a phone vs a laptop?
On a 2023 iPhone 14 Pro through mobile Safari, 50 epochs over 8,200 NFL rows is 1m18s with the WebGPU backend (cudnn-like kernels via Apple Metal). Same model on a 2020 M1 MacBook Air is 38s. On a 2019 ThinkPad with integrated Intel graphics it falls back to the WebGL backend and runs about 2m10s. The model is small enough that battery impact is negligible — you can train ten architectures over a coffee without draining a phone meaningfully.
Can I add a feature that is not in the default pack?
Yes. /tinker exposes a 'derived feature' tab where you write a small TypeScript function that returns a per-row value. We use this for things like 'days since the team's QB last took a sack' or 'rolling 4-week red-zone pass rate vs season average'. The derived feature gets timestamped to the row's kickoff_iso so it cannot leak forward, and saved alongside your model's metadata so the backtest is reproducible by anyone who loads your brick.

Build a free model in 60 seconds →

Go →
11m read time
29 players/teams
8 key angles
Angles in this read 6 angles

NFL 2026 market context

NFL betting examples work best when quarterback, team, and market context stay attached: Chiefs/Bills/Ravens/Eagles/Lions angles should connect to price, schedule, injuries, and game environment.
Patrick MahomesJosh AllenLamar JacksonJoe BurrowJalen HurtsJustin HerbertC.J. StroudTua TagovailoaChiefsBillsRavensEaglesLionsBengalsclosing line valuetarget shareair yardsred-zone roleroute participation
Build an NFL Spread Model in TensorFlow.js From Scratch data infographic
Chart view of the article's core numbers. Source: inline-lib-modelVsMarketCalibration-tfjs-nfl-spread-model-from-scratch.

Get picks in your inbox

One email, every slate — ranked edges, no touts. Unsubscribe any time.

Start free — pick NFL

Go →

We use cookies for essential site functionality. With your consent, we also use cookies for analytics and performance monitoring. See our Privacy Policy.