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 layer | What to inspect | Example input | Downgrade when |
|---|---|---|---|
| Feature | Whether the variable maps to the sport and market | Josh Allen role data or ADP price movement | The feature is a proxy for something you can measure directly |
| Validation | Out-of-sample error, CLV, calibration, missing data | Chiefs market movement after injury news | Wins come without beating the close or improving calibration |
| Sizing | Bankroll, confidence interval, correlation, market limit | CLV exposure compared with related tickets | Multiple 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.



