Every "no-code sports betting model" product I tested in 2025 and 2026 — Rithmm, BALLDONTLIE Lab, the half-dozen Patreon-gated GPT wrappers — does the actual training on a server farm somewhere. You hand them a CSV or a prompt, they hand you back a number. The weights are theirs. The features are theirs. If they go down, your model goes down. If they decide to start charging more, you have no leverage. This post is the opposite: backtest a real NFL spread model in TensorFlow.js, end to end, in your browser tab. No upload. No account. The trained weights live on your machine, exportable as a 40KB JSON file you can email to yourself.
I am going to walk through the exact loop we use to ship every model on Tinker: load a feature pack, define an architecture, train with a time-respecting split, evaluate against the closing line, then convert the model into a brick that scores live games on picks. Numbers and code paths are real — pulled from the model card we published when we shipped Mahomes-era spread brick v3.
Why a browser is a serious place to train a model
"Real" data scientists snicker at the idea of training in JavaScript. They are usually picturing a 2019 TF.js demo that classified cat photos at 4 FPS. The actual numbers for an NFL spread model in 2026 look different. On a 2020 MacBook Air (M1, no external GPU), a 24-input dense MLP with two hidden layers of 64 units trains 50 epochs over 8,200 rows in 38 seconds. The Brier score converges to within 0.003 of the same architecture in PyTorch on a Colab T4. The difference is rounding, not modeling.
The privacy argument is real, not theoretical
Server-trained model SaaS products read every feature you upload. Some of those features are public (Vegas lines, ELO ratings) but a lot of them are not — your sportsbook's true closing price, your own bankroll history, the timestamps that reveal which states you bet from. A browser model never sees a server. The TF.js runtime executes inside the tab. When you save weights, they go to IndexedDB or your filesystem. Nothing crosses the network unless you choose to publish a brick to the open-weights workshop.
Reproducibility lives or dies on the seed
A backtest you cannot rerun is a story, not a test. Browser training has one advantage Python notebooks rarely match: the entire input data is a static, content-addressed parquet pack the user downloaded once. The split logic is in client code. The seed is set explicitly. If two people in two different countries train the same architecture against the same pack with the same seed, they get bit-identical weights. Try getting that out of a Colab notebook six months later.
The feature pack: what loads into the tab
Before we touch a model, look at what we are training on. The default NFL spread pack ships 24 features per game-team row through 2024 Week 22. Names are stable and documented inline in the /tinker dataset inspector:
- schedules-v1 — kickoff_iso, home_team, away_team, spread_line (positive = home favored), total_line, roof, surface, temperature_f, wind_mph
- elo-v3 — pregame_elo_home, pregame_elo_away, qb_elo_home, qb_elo_away
- epa-rolling-v2 — off_epa_pp_last8_home, def_epa_pp_last8_home, off_epa_pp_last8_away, def_epa_pp_last8_away, pass_rate_over_expectation_home, pass_rate_over_expectation_away
- rest-and-travel-v1 — days_rest_home, days_rest_away, miles_traveled_away, timezone_delta
- injuries-v1 — starter_outages_home, starter_outages_away, qb_status_home (active/questionable/out), qb_status_away
The label is binary: did the home team cover the closing spread? With the positive=home convention, that is cover = (home_score - away_score) - spread_line > 0. We handle pushes as a soft 0.5 label.
Forward-walking time splits, not random shuffles
The single most common way amateurs blow up an NFL backtest is calling shuffle() before splitting. Games from 2024 leak into the training set, features that depend on rolling averages get fed by future weeks, and you produce a Brier of 0.21 that evaporates the moment you bet it. The /tinker split is locked: 2018-2022 train, 2023 val, 2024 test, no shuffles, no mixing. The notebook will refuse to evaluate if you try to override.
Define the model in 30 lines of TF.js
Here is the architecture for the spread brick we shipped on April 8, 2026. Copy-paste it into the /tinker code cell and it runs.
import * as tf from '@tensorflow/tfjs';
function buildSpreadModel(numFeatures) {
const model = tf.sequential();
model.add(tf.layers.dense({
inputShape: [numFeatures],
units: 64,
activation: 'relu',
kernelInitializer: 'glorotUniform'
}));
model.add(tf.layers.dropout({ rate: 0.2 }));
model.add(tf.layers.dense({ units: 64, activation: 'relu' }));
model.add(tf.layers.dropout({ rate: 0.2 }));
model.add(tf.layers.dense({ units: 1, activation: 'sigmoid' }));
model.compile({
optimizer: tf.train.adam(0.001),
loss: 'binaryCrossentropy',
metrics: ['accuracy']
});
return model;
}
That is the whole model. Two hidden layers, two dropout layers, one sigmoid head outputting cover probability. People over-engineer this — five-layer transformers on 8K rows of tabular NFL data overfit garbage. Keep it small.
Why ReLU and not tanh
Tabular NFL features have heavy-tailed inputs (a Bengals offense ranking 1st in EPA last week, then 27th this week is normal). ReLU's piecewise linearity handles those tails without saturating the way tanh does. We A/B-tested both architectures on the same Mahomes-Saints week-4 backtest set: ReLU edged tanh by 0.002 Brier consistently.
The training loop you actually run
In /tinker the loop is one click. Under the hood:
const { xTrain, yTrain, xVal, yVal } = await loadPack('nfl-spread-v3');
const model = buildSpreadModel(xTrain.shape[1]);
await model.fit(xTrain, yTrain, {
epochs: 50,
batchSize: 64,
validationData: [xVal, yVal],
shuffle: true, // within batches only, not across the time split
callbacks: tf.callbacks.earlyStopping({
monitor: 'val_loss',
patience: 5,
restoreBestWeight: true
})
});
Three things to notice. Early stopping with patience 5 — without it the model overfits by epoch 30. shuffle: true is safe because we already pre-split by year. restoreBestWeight: true sends back the weights at the lowest val loss, not the final epoch.
What to expect while it trains
On the Air, the loss chart drops fast: epoch 1 val Brier 0.249 (a hair worse than Vegas), epoch 8 down to 0.241, plateaus around epoch 22 at 0.236, early stopping kicks in epoch 27. Accuracy hovers around 53-54%, which sounds underwhelming until you remember the goal is not to beat 50% — it is to beat the implied probability from the closing spread, which already prices in 60-70% of available information.
Backtest, not just evaluate
"Backtest" in this codebase means more than computing Brier on holdout. It means simulating the betting decisions you would have actually made and seeing how the bankroll evolved. The /tinker backtest harness takes your trained model and replays 2024 week by week:
- Score every game with the model's cover probability
- Compute edge vs the closing spread's implied cover probability (Normal CDF of margin gap over standard error)
- Apply a Kelly-fraction stake (clipped at 5% of bankroll, with a loss-floor guardrail)
- Settle against the actual result and the actual closing line
On the spread brick v3 backtest, 2024 regular season: 272 bets placed at edge ≥ 1.5 points, 148-119-5, ROI +6.8%, average CLV +0.42 points. That CLV number is the single most important diagnostic. If you cannot beat the close on average, the ATS record is variance. If you beat the close by half a point on 250+ bets, you are pricing the market correctly even on weeks the score doesn't cooperate. We hammer this point in our CLV explainer and the bet-tracking guide.
Decompose ATS into edge buckets, not just season totals
One number per season hides everything. The brick's edge histogram tells you: bets in the 1.5-2.5 point edge bucket went 38-32 (54%), bets at 2.5-4.0 edge went 51-39 (56%), bets above 4.0 edge went 59-48 (55%). Translation: the model is calibrated. Higher edges produce higher win rates roughly proportional to what their EV math predicts. If your highest-confidence bucket underperforms your lowest, you have a calibration problem — see the Brier explainer for the diagnostic plot.
Export weights and turn the model into a brick
The piece nobody else lets you do: take the trained model out of the tab and into a reusable artifact. Click "Export" in /tinker and you get two files. spread-brick-v3.json contains the architecture, the input feature schema, the training metadata (seed, epochs, val Brier, CLV). spread-brick-v3.weights.bin is the raw float32 weight tensor — 38KB total for our architecture.
Drop both files into the builder and they become a reusable block in the Lego-style brick catalog. Wire that block into a new model: it scores incoming /picks games the same way it scored the backtest. The brick is portable — you can publish it to the open-weights workshop, share the JSON in Discord, or keep it private on your machine. The point is that the weights are yours.
Versioning matters more than people admit
Brick filenames include a semver tag for a reason. When you change feature inputs (we added wind_mph in v3.2), or retrain on more data (v3.3 after 2024 Week 22), the brick name bumps. Old bricks keep working with old data, new bricks live alongside. This is how we publish monthly model release notes: every change is auditable, every rollback is a single click.
A walked-through example: Mahomes Chiefs at Saints, Week 4 2024
To make this concrete, take a single bet and trace the model's reasoning through it. October 7, 2024. Chiefs (road) at Saints (home). Closing line: Chiefs -3.5, total 41.5. Public money: roughly 65% on Chiefs ML, 60% on Chiefs cover. The brick v3 score for that game:
- Pregame ELO: Chiefs 1638, Saints 1494. QB ELO: Mahomes 1801, Carr 1532.
- Rolling 8-game offensive EPA/play: Chiefs 0.11, Saints -0.02.
- Rolling 8-game defensive EPA/play allowed: Chiefs -0.04 (good), Saints 0.06 (poor).
- Rest: both teams on standard 7-day rest. Travel: Chiefs traveling 691 miles east.
- Model predicted home margin: -8.4 (Saints lose by 8.4). Closing spread implied: -3.5.
- Edge: 4.9 points toward Chiefs cover. Cover probability: 0.621.
- Kelly fraction at -110 vig: 0.084 of bankroll. Capped at 0.05.
Bet placed: Chiefs -3.5 at -110, 5% of bankroll. Actual outcome: Chiefs 26, Saints 13. Margin 13, cover by 9.5 points. Bet won. Backtest harness logs the CLV: bet placed at Chiefs -3.5, closing line stayed -3.5, CLV 0.0 points on this single bet. The win was margin variance, not edge — which is exactly what a calibrated 0.62 probability picks tells you in advance.
Now contrast that with a model that hits the same Chiefs -3.5 cover but at a different reason: maybe an under-priced Mahomes ELO. The win rate is the same, but the CLV diagnostic differs. That kind of inspection is what backtest tooling has to surface — and what tab-based browser tooling makes trivially auditable.
Comparing your brick against published benchmarks
Once you have a model, the next question is whether it actually beats anything that matters. Three benchmarks I always run against.
Vegas closing Brier (the only one that pays)
For NFL spread cover 2018-2024, Vegas closing Brier is 0.244. If your model's holdout Brier is above 0.244, you are losing to the line. Below 0.244 by 0.002-0.006 is a plausibly-beatable margin. Below by 0.008 or more, sanity-check for label leakage. The exact numbers shift by season — Vegas Brier was 0.246 in 2020 (Covid-influenced sample), 0.241 in 2022 (a particularly information-rich season).
Public consensus (Pythagorean ELO)
The 538-style Pythagorean ELO model is the strongest "free public" benchmark. Open Source Football publishes weekly. Brier around 0.247 — slightly worse than Vegas. If your model beats public ELO but not Vegas, you have a publicly-tradable thesis with no market edge. If you beat both, you have something.
Last year's brick
The strongest signal that your new architecture is better than your old one is a direct A/B over the same backtest window. /tinker's "compare brick" panel runs both models on the same 2024 holdout and shows Brier delta, ATS record delta, ROI delta, and CLV delta. If the new brick is better only on the bottom three but not on Brier, you got lucky — keep iterating.
Common backtest mistakes and how /tinker prevents them
Five years of watching bettors try to roll their own backtests has surfaced a short list of repeat offenses. /tinker has guardrails for each.
- Post-game features in pregame rows. Joining a Bills offense's EPA-per-play that includes this game onto the pregame row leaks the label. /tinker timestamps every feature and refuses to join future-timestamped features into past rows.
- Survivorship bias on injuries. If you only have injury reports for games where injuries actually happened, you are systematically underestimating starter availability. /tinker's injuries-v1 pack covers all games, including the boring ones.
- Using closing line as a feature. The closing line is your benchmark, not your input. Train on the opening line or the midweek line if you need a price feature.
- Vig forgotten in the backtest. A naive Brier-good model can be a money-loser at -110. The /tinker backtest applies the actual closing vig (positive=home convention) to every settled bet.
- Sample-size triumphalism. 60% ATS on 40 bets is noise. The harness shows the 95% confidence band on yield as you bet count grows — see the chart pattern in how to track your bets.
Where to go after the first backtest
Once you have one working brick, the deeper plays open up. Stack two models (a Brier-tuned MLP and an XGBoost-style gradient brick) in workshop and watch the ensemble outperform either alone. Add presnap formation features from the Big Data Bowl pack — covered in the from-scratch walkthrough. Compare your numbers honestly against a Python pipeline in the latency/privacy/reproducibility comparison. Build a calibration plot on /tinker's calibration tab so you know whether your 60% picks actually hit 60%.
The point of putting all of this in a browser is not novelty. It is that the model, the data, the seed, the result, and the deploy target are all on the same machine, in the same tab, in code you can read. That is what a backtest is supposed to be.
Why people who can already do this in Python still come back to the browser
Most of /tinker's heavy users are people who spent years building betting models in Python first. They show up because the cold-start cost of "I have an idea, let me see if it improves Brier" is 30 seconds in the browser vs 30 minutes in Python (warm up venv, re-pull data, set seeds, write loader). The browser does not replace Python for the cases Python is good at — it replaces Python for the 80% of iterations that are quick variant tests.
The other repeat-use pattern: ensembling. The browser lets you load a Python-trained ONNX model, a TF.js MLP, and a custom feature transformer side by side, blend their predictions, and watch ensemble Brier in real time. The blend coefficient is a slider. In Python that is a Jupyter cell with a re-run on every tweak; in /tinker it is a slider that updates the plot continuously.
If you have done the Python version and never been satisfied with how slow the iteration loop felt, the browser is worth a Friday afternoon to try. Start with the same model you trust in Python, see if you can reproduce the Brier within 0.001 in a fresh /tinker session, then start iterating on variants. Most people who do this stop opening Jupyter for spread-style models within two weeks.
Named modeling examples
A model page is more useful when the feature examples are concrete. Josh Allen rushing attempts, Ja'Marr Chase target share, Nikola Jokic assist rate, Tarik Skubal strikeout projection, Igor Shesterkin starter confirmation, and Islam Makhachev control time are all different prediction problems. A single “player form” feature cannot explain them all, so the model needs sport-specific inputs and review notes.
- NFL: separate route participation, pressure rate, and red-zone role from box-score volume.
- NBA: separate usage, minute projection, pace, and back-to-back fatigue.
- MLB: separate starter skill, handedness, park, weather, and lineup confirmation.
- NHL and UFC: late confirmations and fight-week news can matter more than a season average.
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, Bengals, Eagles and Lions 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.



