> ./load-projects-NFL Data Bowl 2025-2025-11-14T00:00:00+00:00.sh

> loading progress bar...

[ ██████████████████████████████████████████████████ ] 100%

> retrieving projects@November 14, 2025...

The NFL Data Bowl 2025 is an analytics competition hosted on Kaggle that focuses on analyzing NFL player tracking data. The competition challenges participants to develop machine learning models to predict various aspects of player performance and game outcomes using advanced data science techniques. The entire project is open source and available on my GitHub repository.

Datasets

The first thing to do in this competition is to try to understand the dataset and turn it into a format that can be used for training gomlx models. The data is in several csv files, each containing different types of information depending on the file name. If the filename starts with ‘input’ it contains the input data for the model. If it contains ‘output’ it contains the actual outcome for the data in the model.

Frames are recorded at a rate of 10 FPS. The coordinate system is as follows:

The coordinates used in the NFL Data Bowl 2025 dataset

Coordinates used in the dataset. The origin is at the bottom left corner of the field. The x-axis runs along the length of the field, going from 0 at the end of the home team endzone to 120 at the end of the away team endzone. The y-axis runs across the width of the field with 0 at the home sideline and 53.3 at the away sideline. Left in the dataset refers to moving towards the hometeam endzone and right refers to moving towards the away team endzone. The angles are measured in degrees with 0 degrees pointing in the positive y direction and increasing clockwise.

The input data contains the following columns:

Column Name Description
game_id Unique identifier for each game
play_id Unique identifier for each play
player_to_predict Unique identifier for the player whose performance is being predicted
nfl_id Unique identifier for the player in the NFL database
frame_id Frame identifier for each play. Starts at 1 for each game_id/play_id
play_direction Direction offense is moving (left or right)
absolute_yardline_number Distance to endzone for the offense
player_name Name of the player
player_height Height of the player in ft-in
player_weight Weight of the player in lbs
player_birth_date Birth date of the player
player_position Position of the player
player_side Side the player is playing on. (offense or defense)
player_role Role of the player in the play (Defensive Coverage, Targeted Receiver, Passer or Other Route Runner)
x x coordinate of the player on the field
y y coordinate of the player on the field
s Speed of the player in yards per second
a Acceleration of the player in yards per second squared
o Orientation of the player in degrees
dir Direction of the player’s movement in degrees
num_frames_output Number of frames to predict for the play
ball_land_x x coordinate of the ball landing position
ball_land_y y coordinate of the ball landing position

Looking at this I’m probably going to want to implement the dataset based on the game_id and play_id, so that I can easily get all the data for a given play. I want to flag the player_to_predict and use that as the target for the model. So what I’m seeing is that the game will be a set of plays, each play will be a set of frames, and each frame will contain the data for all the players on the field at that time. I also don’t want to overwhelm the memory by loading all the data at once, so I will use a batched dataset that loads the data for a given play when it is needed. This way I can also easily implement data augmentation by randomly flipping the coordinates and angles for each play.

This should end up looking something like this:

type GameData struct {
    plays map[string]PlayData
}

type PlayData struct {
		PlayDirection string
		AbsoluteYardlineNumber float64
    Frames []frameData
}

type FrameData struct {
    Players []PlayerData
}

type PlayerSide uint

const (
    OFFENSE PlayerSide = iota
    DEFENSE
)

type PlayerRole uint

const (
    DEFENSIVE_COVERAGE PlayerRole = iota
    TARGETED_RECEIVER
    PASSER
    OTHER_ROUTE_RUNNER
)

type PlayerData struct {
    NflID string
    PlayerToPredict bool
    PlayerName string
    PlayerHeight float64
    PlayerWeight float64
    PlayerBirthDate time.Time
    PlayerPosition string
    PlayerSide PlayerSide
    PlayerRole PlayerRole
    X      float64
    Y      float64
    S      float64
    A      float64
    O      float64
    Dir    float64
}

type NFLDataBowl2025Dataset struct {
	InputData map[string]GameData    
}

*** Probably unexported though ***

gomlx dataset

To work with gomlx, I will need to implement a gomlx.Dataset that can load the data for a given play in a format that can be used for training. This has to implement a specific interface, and to do batching I will need to implement a custom function to handle it. We’ll cross that bridge when the time comes.

I’ll need to be able to convert it to a flat train.tensor compatible object. I’ll also need a way to batch the csv reads, so I don’t run out of memory loading too many frames at once! This is all handled as part of the dataset package.

Model

The model I’m using for this competition is the gomlx ‘simple’ model. It’s a model implemented in pure-go! The architecture is an MLP model that uses a basic ADAM optimizer.

Statistical Mechanics

The statistical mechanics method uses the training data to essentially build the forcefield the player particles experience, and use that to make predictions about where the players go next.

How it works:

  1. Index training data (lazy — only game_ids scanned at startup).
  2. Stream every training game through GameIter; extract empirical acceleration distributions into a ForceTable, grouped by (player_role, speed_bucket, dir_bucket).
  3. Load test rows from test_input.csv + submission template.
  4. For each test row, run -runs independent particle simulations starting from the player’s last observed kinematic state.
  5. Take the mean trajectory and read off the position at output frame N (frame_id/num_frames_output encodes which future step is requested).
  6. Write out/submission.csv with columns id,x,y.

Results

To compare the results, I wrote a command to compare the two methods. It uses the dataset to train a model using the ml package as well as runs a montecarlo simulation using the simulate package. In order to gauge each model’s accuracy, I set aside some of the training data for use as test data. I then was able to use that to compare not only each result against eachother, but the results against a separately known value.

I ran my comparison command using

$ go run ./cmd/compare/ ... -epochs 10 -infer-batch 256 -runs 100
=== Prediction Accuracy Comparison (Euclidean distance, yards) ===

Method                N       MAE      RMSE     Median
------                -       ---      ----     ------
ML (MLP)      [all]   31637   3.7473   4.7710   2.9560
Monte Carlo   [all]   31637   1.1727   1.9834   0.5900

--- Early frames (1–5) ---
ML (MLP)      13690   2.8791   3.2652   2.6428
Monte Carlo   13690   0.2289   0.3334   0.1408

--- Mid frames (6–15) ---
ML (MLP)      15119   3.2715   3.8331   2.8671
Monte Carlo   15119   1.5507   1.9963   1.2032

--- Late frames (16+) ---
ML (MLP)      2828   10.4933   11.1572   10.1764
Monte Carlo   2828   3.7207    4.7080    3.1274

Winner (by MAE):
  All     Monte Carlo wins (ΔMAE = 2.5746 yds)
  Early   Monte Carlo wins (ΔMAE = 2.6502 yds)
  Mid     Monte Carlo wins (ΔMAE = 1.7208 yds)
  Late    Monte Carlo wins (ΔMAE = 6.7727 yds)

This shows that using only 10 epochs and a simple machine learning method, Monte Carlo makes better predictions.

Let’s see what happens when I crank it up to 40 epochs.

=== Prediction Accuracy Comparison (Euclidean distance, yards) ===

Method                N       MAE      RMSE     Median
------                -       ---      ----     ------
ML (MLP)      [all]   31637   3.7473   4.7710   2.9560
Monte Carlo   [all]   31637   1.1727   1.9834   0.5900

--- Early frames (1–5) ---
ML (MLP)      13690   2.8791   3.2652   2.6428
Monte Carlo   13690   0.2289   0.3334   0.1408

--- Mid frames (6–15) ---
ML (MLP)      15119   3.2715   3.8331   2.8671
Monte Carlo   15119   1.5507   1.9963   1.2032

--- Late frames (16+) ---
ML (MLP)      2828   10.4933   11.1572   10.1764
Monte Carlo   2828   3.7207    4.7080    3.1274

Winner (by MAE):
  All     Monte Carlo wins (ΔMAE = 2.5746 yds)
  Early   Monte Carlo wins (ΔMAE = 2.6502 yds)
  Mid     Monte Carlo wins (ΔMAE = 1.7208 yds)
  Late    Monte Carlo wins (ΔMAE = 6.7727 yds)

This took 20 minutes to calculate and had very little improvement. Monte Carlo appears to be winning every metric by a bit.

.> /load-comments-NFL Data Bowl 2025-linux-x64.sh

comments powered by Disqus