← Back to list

“Removing the fun” from fantasy hockey: An attempt at automating drafting based on historical data

Every year, my friends and I organize a fantasy draft based on the NHL season.

Francis Toupin · 2026-09-14 01:06 · 50 claps · 8.6 min read
#hockey #data-science #sports-analytics #fantasy-hockey #fantasy-sports
Open on Medium ↗
Wiki topics: ML · Machine Learning HIS · History GRW · Growth & Analytics 🔬 · Science · General 🏆 · Sports · General

“Removing the fun” from fantasy hockey: An attempt at automating drafting based on historical data

Every year, my friends and I organize a fantasy draft based on the NHL season.

Every year… I end up out of the money. I think I have (at least) two problems:

  1. I undervalue players I don’t know a lot about
  2. I pick players with high points per game played (P/GP), but low games played because of injuries, hoping that this is the year they don’t get injured.

I could buy a draft guide, but I really like to get things done myself so if I win, I can be proud of my choices. Does it count if I use my data science and programming skills to make better picks?

Note: I will state when GenAI was used, if I don’t mention it it’s because it’s coming from me. GenAI was not used and probably never will be used to write for me, I enjoy it too much.

The context

We’re 14 poolers and we need to pick 20 players: 12 forwards, 6 defensemen and 2 goaltenders (goalers, if you’re from Quebec like I am). We need to respect the maximal salary cap of the current NHL season, which is 104M$ in 2026.

The draft order is randomized and at each odd round, the order is inverted to make it fair to the poor souls who pick last.

Skaters will accumulate goals and assists during the year and those points are weighted differently for forwards and defensemen:

  • For forwards, goals are worth 2 points, assists 1
  • For defensemen, goals are worth 3 points, assists 2

Goalies accumulate points based on wins (3), overtime losses (1) and shootouts (2 + 3 for the win).

The goal, obviously, is to build the best team that respects the salary cap.

I’m using MoneyPuck, PuckPedia and the NHL’s website to gather data for skater statistics, cap hit values and goalies statistics respectively. Both MoneyPuck and NHL allow to download csv files containing statistics and for PuckPedia I went through the ~8 pages of cap hits and saved the .html files. ChatGPT wrote the HTML parser for me using BeautifulSoup, not because I couldn’t do it myself, but because it’s a drag.

The algorithm

This year, for my personal interest, I followed an online class on optimization because honestly, it felt like magic. You formulate a problem and it is solved using voodoo, right? Well I’ll say it still feels like voodoo, but at least I got a better understanding of it. I did enjoy the class, but mostly the first part. It was general enough that it gave me a good idea of how to formulate problems and use solvers; the second part was a bit too technical for what I wanted to get out of the class.

Now that I’m an expert on optimization, I can say with confidence that I’m facing an optimization problem. It is akin to the knapsack problem in that you need to “fill your bag” with hockey players: I guess you’d need a really large bag and some pretty relaxed hockey players to do so. But it’s a variation of the knapsack problem because every time you pick an object, other people will pick objects before you have a chance to pick again.

The idea I have (I’ll let you know how that worked out next year) is to optimize my full remaining draft based on the available players, then to pick a player that would minimize the performance loss created by other poolers. So, if I get an optimal solution and can “stay on track” during the draft, I should end up with a pretty solid team at the end.

The workflow looks like this:

Workflow for optimization-based draft

Workflow for optimization-based draft

There are two components that are of interest here: the optimization and how to make the pick.

The optimization

Like the knapsack problem, I’m formulating the problem as a binary integer programming problem. I’ll first define X and c, then we’ll move on to cost function formulation and constraints.

X, the decision vector, represents the inclusion of all available players in my team: if x_i equals 1, it means that player “i” is part of the optimal solution.

c, the cost vector, represents the value associated with each player. There are tons of ways to model this and people smarted than I (or at least with more time) worked hard to create models that represent player value. But, like I said above, I like to do things myself. So, I decided to pick a simple heuristic: c is the average weighted points (AWP) of the last three seasons per player. If a player didn’t play all three seasons, I’m replacing missing years by the lowest season total before computing the average. I’m fairly confident saying that the best predictor of a player’s point total is his last year’s point total. Of course, many other things get in the way: trade to a new team, arrival of a better player, age, just to name a few. But this heuristic is simple and I’m sure it won’t be terrible.

*You might see flaws in this heuristic. Rookies can’t be modelled here, even though they’re an important part of a fantasy draft. It also doesn’t account for the infamous sophomore slump, where a rookie player can have a wonderful first season followed by a difficult second one, aging players, injured players, over-performing players. I’ll have to rely on my experience to make those decisions.

You can see the equations in the figure below. The numbers you see for the constraints will move during the draft to account for the players already drafted. “s” represents the cap hit of the player and pos_** is 1 if the player has said position and 0 otherwise (e.g. Connor McDavid would have pos_is_forward = 1).

The problem to optimize

The problem to optimize

I’m using scipy’s milp tool to optimize the problem and I got to say it’s very simple to write out the code once you have the data properly organized:

def solve_system(players_data:pd.DataFrame, remaining_cap_space:float, remaining_f_to_pick:int, remaining_d_to_pick:int, remaining_g_to_pick:int):
    cap_hits = players_data["salary"].to_numpy().astype(float) / remaining_cap_space
    cost_vector = players_data["avgPointsWeightedNormalized"].to_numpy()

    player_is_defenseman = (players_data["position"] == "D").to_numpy(dtype=int)
    player_is_goalie = (players_data["position"] == "G").to_numpy(dtype=int)
    player_is_forward = (players_data["position"] == "F").to_numpy(dtype=int)

    integrality = np.ones_like(cost_vector, dtype=int)
    bounds = Bounds(0, 1)
    cap_hit_constraint = LinearConstraint(A=cap_hits, lb=0.0, ub=1.0)
    n_forwards_constraint = LinearConstraint(A=player_is_forward, lb=remaining_f_to_pick, ub=remaining_f_to_pick)
    n_defensemen_constraint = LinearConstraint(A=player_is_defenseman, lb=remaining_d_to_pick, ub=remaining_d_to_pick)
    n_goalies_constraint = LinearConstraint(A=player_is_goalie, lb=remaining_g_to_pick, ub=remaining_g_to_pick)

    res = milp(c=-cost_vector, constraints=[cap_hit_constraint, n_goalies_constraint, n_defensemen_constraint, n_forwards_constraint], integrality=integrality, bounds=bounds)

    return res

I was quite curious to see what the most optimal team would look like if I were alone in my pool. This also creates an upper bound that I know will be impossible to beat. You can see in the table below what it looks like. If you know hockey even a little bit, you know it’s impossible to pick all of those players in a serious fantasy draft. If you know optimization, you know picking a subset of those players without considering the full draft would be a mistake. If you know both, we should grab a beer some day.

The ideal draft, if I wanted to play alone…

The ideal draft, if I wanted to play alone…

How to make the pick?

I can find an optimal solution at a time step, but I can’t find the optimal solution to the end of the draft, there’s too much uncertainty. How can I navigate the draft trying to remain as close as possible to an optimal solution?

That’s a pretty tough problem. One flaw I noticed while working on the algorithm is that if I simply always pick the best player of the optimal solution, I end up with no cap space left at round 12–17. So I can’t always pick the best player.

What I’ll try to do instead is to model rarity as the inversed replacement factor. Players below 50 AWP are simply not worth picking under this model, so they will have a rarity of 0.0.

Replacement is easy to model: How many players of equal or lower salary have outperformed said player? If many players can replace the observed player, than picking him has no added value. You can see the equation below. To respect our integrity, I will say ChatGPT helped me write this equation. I’m not a math graduate, so this notation is not always clear to me, although I enjoy how communicative it is.

This formula leads to interesting results most of the time. You can see in the table below that all players below would be worth picking in a fantasy league that respects cap space (a case could be made for D’Astous as I expect they won’t be able to repeat last year’s performance). There’s a nice mix of cap hits: some players make a lot of money, but are also insanely good like Makar and Hughes, but there are also some cheap players that have above average performance like Burns and Benn. We can also see that defensemen are highly valuable in my league format (maybe too much, but I already tried arguing about this in the past!)

Rarest players in my league format

Rarest players in my league format

There’s also a case to be made that sometimes you should pick the best available player even if they’re not rare enough. You probably noticed in the list above that Kucherov, McDavid and MacKinnon, arguably the three best forwards in the league, are missing from this list. That’s a bit of a weakness in my model: picking rarest players is a good way to keep high-perfoming players while saving cap space, but you’re missing out of the top performers.

I’ll just have to decide myself whether I pick the rarest or best player when I have to make a pick!

Tying it up…

This section will touch more on the software than the algorithm, but I think it’s still an interesting part of this project.

I have explained an algorithm that allows to make picks based on available players, but I still need to know who the available players are. I’m web scraping the draft list every time I launch the script and I rebuild every poolers lists. Then, I can see how far I am from drafting and I use my algorithm to simulate picks for all players until it is my turn. I keep the best and rarest picks for every pooler simulation and print that list. This gives me a quick “draft list” that I can use while I’m away from my computer.

What I’d improve?

Algorithm-wise, here are things I know I need to improve:

  1. Model uncertainty in the optimization itself. I know (from the class I took) that optimization under uncertainty is a thing, but I’m not comfortable enough to use it right now.
  2. I’d like to see if I can use a Minimax algorithm to help figure out which picks would lead me to an optimal solution.
  3. Use advanced statistics to figure if a player has played an unsustainable season (for example keeping a shooting percentage of 15–20% year after year is absurd, but sometimes players hit hot streaks) or if they are under-performing
  4. Use data from other leagues to model rookies performance. I know there are analytics fans out there that model league strength relative to NHL, so you can estimate that if a player has X points in the SHL for example, they’d get Y points in the NHL.
  5. I’ve noticed, now that my draft is halfway done, that the algorithm have been proposing the same players for a couple rounds. Players I won’t take because I’m certain they will have worst seasons than before. I should simply add an option to exclude them.

Software-wise, I’d like to be able to access my draft list from my phone. What I’d do is publish the algorithm to AWS under fastAPI and just send an HTTP request to my web server, which would launch the script and return my draft list in a json format.

I hope this was a fun read, my draft isn’t over, but I’ll keep you updated about how that went. Next year, I’d like to post another article with a follow-up and the improvements I’ve made.

Soon enough, I’ll finish cleaning up the code and you’ll be able to find it here if you’d like to read on it. I will not share the data itself as it is not mine to share.

In the meantime, don’t you dare tell my friends about this!


메타데이터
post_id
394bc7fcff70
slug
removing-the-fun-from-fantasy-hockey-an-attempt-at-automating-drafting-based-on-historical-data-394bc7fcff70
url
https://medium.com/@francis.toupin/removing-the-fun-from-fantasy-hockey-an-attempt-at-automating-drafting-based-on-historical-data-394bc7fcff70
canonical_url
https://medium.com/@francis.toupin/removing-the-fun-from-fantasy-hockey-an-attempt-at-automating-drafting-based-on-historical-data-394bc7fcff70
author_url
https://medium.com/@francis.toupin
status
ok
fetched_at
2026-09-15 15:31:29