Projects Octagon IQ

BuildLive2026

Octagon IQ

The thesis data kept growing. Octagon IQ is where it ended up: a fight prediction model, a live site, and an audit that found five critical defects in my own features.

Links
octagon-iq.ie
Stack
TypeScript · Bun · Hono · SQLite · Drizzle · Python · scikit-learn · XGBoost · React
The Octagon IQ landing page: the brain logo and OCTAGON-IQ wordmark over a dark arena, a cage radar drawn on the left and yellow and blue ridgelines on the right, four stat panels comparing Joshua Van and Alexandre Pantoja ahead of UFC 331, and links to upcoming fights, fighters, records and rankings along the bottom.

The short version

Octagon IQ is three things in one repository: a fight prediction model, a live analytics site at octagon-iq.ie, and the data audit that sits underneath both. The site carries fighter ratings, matchup breakdowns and round-by-round statistics for every UFC card. You can use it at octagon-iq.ie.

It grew out of my masters thesis data, which kept growing after the thesis was done. What I ended up caring about is the audit rather than the model. Six parallel audits over the transform code and the Python cleaning scripts turned up five critical defects in features I had written myself, and one of them moved information backwards through time.

The figures come from after those fixes. On a held-out test set the model reaches AUC 0.6991 and 64.8% accuracy, and cross-validated AUC is 0.640 ± 0.018.

The caveat, up front. That cross-validated band is the number I would defend, and it is a modest result honestly measured rather than a good one loosely measured. The single holdout figure is the flattering one and it sits on one split.

TypeScript on Bun with Hono, SQLite and Drizzle behind the site, Python with scikit-learn and XGBoost for the model, React on the front end. The site is live at octagon-iq.ie and I am still working on it.

How it works

Octagon IQ predicts the outcome of UFC fights from a fighter’s record, their ratings history and the shape of their recent form. The model running on the site is the one I call v10-clean. It uses 81 features, trimmed down from 147, drawn out of a raw pool of 173 across fifteen categories. The accuracy figures above are its held-out test scores, and the cross-validated band is five-fold.

The work I am most attached to sits under the model rather than in it.

In July 2026 I ran six parallel audits across the transform code and the Python cleaning scripts, about 11,400 lines in total. They turned up five critical defects. Every one of them was confirmed against the database before I touched anything.

The first was a labelling bug. The routine deciding which corner a fighter had fought in matched names by exact string only, and small spelling differences like “Zach” against “Zachary Reese” fell straight through it. Fifteen fights matched neither corner, and at least six credited the win to the wrong fighter. Those results then fed the Elo ratings, the fight snapshots and the training labels. The judges’ scorecards ran through the same comparison and flipped with them.

The second was a corner-orientation bug. Best-odds and line-movement features were attached to a fighter by their row order on the scrape page instead of by their corner. Across 7,115 fights, row index 1 landed on the red fighter 3,378 times and on the blue fighter 3,396 times, which is a coin flip. Roughly half of every sign-bearing feature in that group was noise.

The third was a run-order bug. Tier scores were computed before the Elo transform had populated the column those scores read from, leaving 70% of the weight sitting on an always-empty value. The whole range came out between exactly 0.35 and exactly 0.65, which is the shape a constant makes.

The fourth is the one I would properly call a leak. Features labelled “pre-UFC” were counting 12,067 non-UFC professional fights that happened after the fighter’s UFC debut, across 1,387 fighters. A fighter released in 2016 who went on to win eight in a row elsewhere had those later wins baked into his 2014 features.

The fifth was a duplication bug. SQLite treats NULLs as distinct inside a unique constraint, and the upsert on method-odds rows never fired for rows with a null fighter id. The table held 33,421 rows against 32,641 distinct keys, with some rows sitting there in sixteen identical copies, one per historical re-run.

Four of those five are ordinary data-quality faults: a bad join, a wrong orientation, a pipeline stage running too early, a constraint that did not constrain. Only the fourth moves information backwards through time.

I fixed all five and rebuilt the pipeline from the clean layer up. The figures at the top come from after the fixes, which is the only reason I trust them enough to publish.

One of the charts the site is built on is the age cliff: the age band in which each division’s average Elo change per fight crosses zero.

Dot-and-range chart of ten UFC divisions, showing the age band in which each division’s average Elo change per fight turns negative. Flyweight is earliest at 25-27 and Light Heavyweight latest at 42-50, seventeen years apart. Women’s Flyweight sits at 33-35, Women’s Strawweight, Lightweight and Featherweight at 35-37, and Heavyweight, Middleweight, Welterweight and Bantamweight at 37-39. A footnote records that Heavyweight’s dip is mild and recovers in the next band, and that the bands are single-source and not resampled for confidence intervals.

Speed-led divisions turn first and the heavier, grappling-led ones turn last. The footnote is on the chart because it belongs there: these bands come from one source and carry no confidence intervals, so the ordering is the finding and the exact ages are not.

The image at the top of this page is the site’s landing page. It pulls the next UFC card and compares the two fighters in its main event. The art on either side is original, drawn to replace press photos the live site still uses until that change is deployed.

The Events page lists every card in the database, 798 of them, with filters for type, year, country and division.

The Octagon IQ Events page. A sidebar holds the site navigation, with Events highlighted. The page header reads “Fight cards: Events, 798 events”. Filters run along the top: all, upcoming or past, a search box, and dropdowns for type, year, country and division. Below them is a list of cards, newest first, starting with UFC Fight Night: Bonfim vs. Brady on 7 November 2026 in Las Vegas and running down to UFC 331: Van vs. Pantoja 2 on 19 September 2026.

The Fighters page covers all 2,773 fighters. It splits them by gender, division, style and tier, and names the current champion of each division. I left the fighter photos out of this screenshot and the next one.

The Octagon IQ Fighters page, headed “Roster: Fighters, 2,773 fighters”. Gender filters show 2,499 men and 274 women. A grid of division cards gives each division’s size and champion, for example Heavyweight with 331 fighters and Tom Aspinall, Welterweight with 575 and Islam Makhachev, and Flyweight with 165 and Joshua Van. Below it, style filters count 1,792 strikers, 1,062 grapplers and 969 well-rounded fighters, and a row of tier badges runs from SSS with 54 fighters down to D with 508.

The Compare page puts any two fighters side by side. Here it is set up for the UFC 331 main event.

The Octagon IQ Compare page, headed “Tale of the tape: Head-to-head comparison”. Joshua Van, “The Fearless”, sits in the red corner with a UFC record of 10-1, and Alexandre Pantoja, “The Cannibal”, sits in the blue corner at 14-4. Both are 125 lbs, orthodox and SSS tier. Van is tagged a volume striker and Pantoja a pressure wrestler. A Predict Matchup button sits between the corners. Below, a tale of the tape compares Elo at 1743 against 1736, age at 24 against 36, pro record at 18-2 against 30-6, reach at 65 against 67 inches, and height at 65 inches each.

The Lab page holds sport-wide patterns from the same data. This one is the finish rate for each round, split into knockouts and submissions.

Stacked bar chart titled “Danger Zones by Round”. Round 1 finishes about 28% of the 8,783 fights that reach it, falling to about 10% in round 5. Red KO/TKO sits under blue submissions in every bar.

The site also hosts the head trauma study. One of its findings corrects the first version: raw counts made round 1 look 3.6 times deadlier than round 3, but per minute actually fought the gap is 2.2 times.

Two bar charts side by side. On the left, raw KO/TKO finishes per round: 1,393 in round 1, 800 in round 2, 390 in round 3, 29 and 24 in rounds 4 and 5. On the right, KO/TKOs per 100 fighter-minutes at risk with 95% intervals: 1.97, 1.49, 0.90, 0.83 and 0.78.

Survive a knockdown in round 1 and the chance of being finished later in the same fight is 17.3%, against 10.1% without one.

Bar chart titled “Knockdown escalation”, with 95% intervals. Knocked down in round 1: 17.3% against 10.1% not knocked down. Round 2: 9.1% against 4.7%. Rounds 1 and 2 pooled: 14.7% against 7.7%.

The site is live and I am still working on it.

The long version

This section is being written.