Ivan vs the Machine: What Happened When I Put an AI Model Against a Sports Journalist for an Entire World Cup

A Monte Carlo simulation, a failed GPU experiment, and a weighting algorithm borrowed from game theory, tested over 38 World Cup matches against a sports journalist's gut instinct.

The Setup

For the 2026 FIFA World Cup, the Brazilian YouTube show Cultura Eclética and my platform RecomendeMe ran a recurring segment called Ivan vs the Machine. Every round, sports journalist Ivan Santos gave his prediction for each match. In parallel, a model I built predicted the same matches using nothing but data.

Thirty-eight matches later, from the group stage to the final in New Jersey, the score was:

  • The Machine: 28/38 correct (73.7%)
  • Ivan Santos: 19/38 correct (50.0%)

The Machine also called the champion correctly in the final: Spain over Argentina, 1–0 in extra time.

The final scoreline isn't really the story, though. What's more useful is the two architectures I had to build to get there, and why I ended up throwing away a GPU pipeline for something that runs on a laptop in under a second.

v1: Neural embeddings on a GPU, and why it broke.

The first version of the model trained neural attack/defense embeddings for every national team via dot-product interactions, a Dixon-Coles-style approach, over a historical dataset of 15,871 international matches dating back to 1872. It ran in PyTorch on an NVIDIA RTX 3090.

That GPU wasn't sitting in my own rig. It came from Nosana, a decentralized GPU compute marketplace built on Solana. Node operators list idle GPU capacity, jobs get matched, and payment settles on-chain. I got in through hackathon credits and used them to run the training and simulation workloads. For short, one-off ML jobs that don't justify a long-term cloud contract, it's a reasonable way to get GPU time without setting up an AWS account.

The GPU wasn't the problem. The model design was.

In production, training loss diverged to loss: nan starting from epoch zero, which produced invalid lambda parameters for the Poisson goal model underneath. Digging into why exposed the real issue: blending 150 years of historical results into one embedding space mixes eras that have almost nothing in common. France's 1998 squad and France's 2026 squad share a crest, not much else. Debutant or data-sparse teams made this worse — Cabo Verde's 0–0 draw with Saudi Arabia in Round 1 is a good example of a team whose thin history produces noisy embeddings no amount of gradient clipping can fix.

Round 1 result with v1: 3/6 for the Machine, tied with Ivan.

v2: Drop the GPU, reason more like a human does.

For Round 3 onward, I stripped out PyTorch, the GPU, and the 150 years of history, and rebuilt the strength model around two signals a human pundit actually uses.

1. Current squad value (Transfermarkt)

Total market value of each called-up squad, in millions of euros, as of June 2026, log-normalized so a €1.5B squad doesn't completely drown out the signal from a €20M one:

France €1,520M England €1,360M Portugal €1,010M Argentina €807M Croatia €387M Colombia €302M Algeria €257M Austria €245M Ghana €234M DR Congo €144M Uzbekistan €85M Panama €34M Jordan €20M ...

2. In-tournament form

Computed from group-stage data: goals scored, goals conceded, points per match, and a pressure factor for teams that need a result.

form_strength = ( 0.40 * avg_attack + # goals/match, normalized 0.35 * tanh(goal_diff) + # goal difference, squashed 0.25 * points_ratio # pts / (matches * 3) )

Combined into a single strength score:

strength = 0.6 * squad_value_strength + 0.4 * form_strength

From strength to a scoreline

Each team's strength feeds a Poisson expected-goals calculation:

lambda_home = BASE_GOALS * (strength_home / (strength_home + strength_away)) * 2.0 lambda_away = BASE_GOALS * (strength_away / (strength_home + strength_away)) * 2.0

BASE_GOALS = 2.6, roughly the average goals per match in recent World Cups. A ±12% pressure adjustment kicks in when there's a meaningful gap in points-per-match between the two sides.

Monte Carlo instead of a single guess.

Rather than output one predicted scoreline, the model draws 50,000 samples from the two Poisson distributions and derives win/draw/loss probabilities from the resulting outcome distribution:

home_goals = np.random.poisson(lambda_home, 50_000) away_goals = np.random.poisson(lambda_away, 50_000) p_home_win = (home_goals > away_goals).mean() * 100 p_draw = (home_goals == away_goals).mean() * 100 p_away_win = (home_goals < away_goals).mean() * 100

50,000 iterations per match, under a second on an ordinary CPU. No GPU needed. The whole v2 pipeline runs on a laptop, and that drop in complexity from v1 is a big part of why it actually worked.

Letting Ivan and the Machine compete for weight.

Starting Round 2, I stopped just comparing Ivan's picks and the Machine's picks side by side, and started treating them as two competing experts inside a Hedge / Multiplicative Weights algorithm, the same family of online-learning algorithms used in portfolio theory and prediction-market aggregation.

```

whoever is wrong loses weight, whoever is right keeps it

weight = (1 - eta) # on a miss
weight
= (1 + eta * 0.3) # on a hit

renormalized to sum to 1 after every round

```

eta = 0.25, a soft learning rate. With only 38 matches total, a more aggressive rate would have made the weights swing wildly after a single upset.

After Round 1, the weights actually favored the human:

| Expert | Weight | Correct picks |
|---|---|---|
| Ivan Santos | 58.9% | 3/6 |
| The Machine | 41.1% | 3/6 |

Same number of correct picks, different weights, because which matches each side got right matters under Hedge, not just how many. Ivan called Uruguay–Spain correctly, and the Machine missed it, and that one disagreement outweighed the games they'd both gotten right.

From there on, the combined "consensus" pick for each match was a weight-adjusted vote between the two, so the system automatically leaned on whichever expert had been more reliable recently.

How the tournament actually played out.

A few rounds shaped the final result more than others:

Round of 32. Paraguay knocked out four-time champion Germany on penalties after a 1–1 draw. Both Ivan and the Machine had backed Germany and missed. In the Netherlands vs. Morocco match, the tightest probability split of the knockout stage (49% to 51%), the Machine caught Morocco's shootout win while Ivan stayed with the Dutch.

Round of 16. Norway beat Brazil 2–1. Both sides had picked Brazil. Nobody saw that one coming.

Semifinals. This is where the gap really opened up. Spain over France (2–0) and Argentina over England (2–1) both went against Ivan's picks, while the Machine's squad-value-plus-form blend got both finalists right.

The final. Ivan backed Argentina to win a fourth star. The Machine weighed Spain's knockout-stage form — three straight 2–0 wins over Portugal, Belgium, and France — against a modest 54.2% model edge, and went with Spain. Ferran Torres decided it in extra time.

By the end of the tournament, the Hedge weights sat at 66% Machine, 34% Ivan, which lines up with a 28-for-38 record against a 19-for-38 record.

What I'd tell anyone building something similar.

Simpler beat sophisticated once the data got thin. v1's neural embeddings had access to far more historical data than v2 ever used, and v2 still won by a wide margin. A century and a half of results turned out to be a weaker signal than "who's actually on the roster right now."

The GPU was useful for experimenting, not for the final product. The Nosana/Solana setup was a cheap way to get GPU time for the exploratory phase without setting up a cloud contract, and that's a fair use case for it. But the model that actually shipped didn't need a GPU at all, and I'd been optimizing the wrong thing by assuming it would.

The model is good with numbers and blind to everything else. Most of the Machine's edge came from matches where the answer really was "who has the better squad and the better form," which is a numbers problem, and it's what the model is built for. But the games Ivan won tended to be the ones decided by something no spreadsheet tracks: a team playing with something to prove, a squad that had stopped believing in its coach, a shootout that comes down to who's calmer under pressure. Ivan reading Egypt's penalties over Australia, or Switzerland holding its nerve against Colombia, came from years of watching how players behave under pressure, not from a dataset.

A Poisson model has no idea what morale is. That's not something more features would fix — it's just outside what this kind of model can see. The takeaway isn't that AI beat human intuition; it's that the two are useful for different parts of the same problem, and most matches happen to be decided by the part the model is good at.

A few known limitations, for anyone building on this:

  • Transfermarkt valuations carry a league bias — Premier League and La Liga players tend to be overvalued relative to smaller leagues, which can reproduce the Cabo Verde problem in reverse.
  • Squad values lag real injuries and suspensions. There's a manual injury_adjustmentfield in theFormdataclass for one-off corrections.
  • With this few matches, the Hedge weights are noisier than they'd be over a full league season, which is why etawas kept low.
  • The model has no tactical awareness at all — no formations, no pressing schemes, no head-to-head history.

Credits

| Entity | Role |
|---|---|
| RecomendeMe | Model development & technical infrastructure |
| Cultura Eclética | On-air presentation — Ivan Santos |
| Transfermarkt | Squad valuation data (June 2026) |
| Mart Jürisoo | Historical match dataset (v1) |
| Nosana | Decentralized GPU compute on Solana (v1) |

Full source, the Hedge implementation, and both model versions are released under MIT — links in the repo.