'The PP Recommender': Autoencoded Osu! Beatmap Recommendations

Created on 2026-08-12Updated on 2026-08-12in progress

Project Link: pp.bryanchan.org
(Some) source code: github.com/brrryry/pp
(I really need to add more visuals for my math stuff...)



Yeah...this title is gas.

I recently hit a 5-digit rank in osu, and I still haven't been able to deploy a full coding project despite my idea bank being filled to the brim. The idea that I got the farthest with was the Osu! Beatmap Generator, but I found out that this has already been done. Since I was out of ideas, I decided to just...play more osu.


5 digit rank!

New milestone achieved! :D


While doing this, I used a few different websites to help me get better at the game. One that stood out to me was the osu! skillset analyzer [1] made by user Kumokoni [2]. This website scans your top 200 plays, pinned scores, recent plays, and your favorite maps. Then, it calculates a 12-axis user skill portfolio.


My portfolio!

My skillset analyzer result. (source)


This website is fantastic, and I have no complaints. It has a very slick UI, and the portfolio is explained EXTREMELY well. It inspired me to look a little bit deeper. What if we could try to recommend maps, but based on axis-free embeddings?

When I talk about "axis-free", I'm referring to the removal of the 12-axis classification. After scrolling through quite a few forums, I found that different people have different ideas of what defines an "axis" of skill. Some examples may include:

  • Aim Control
  • Stamina
  • Reading Slow/Fast Approach Rates
  • Finger Control
  • Complex Pattern Reading
  • Precision
  • The list goes on...

So, what if we didn't use any axes to remove disparity? That's what this project aims to do. Without further ado...


PP Recommender


PP Recommender Showcase

A brief showcase (source)


This website takes username inputs and fetches some top/recent osu replays. Using these replays, it will recommend maps for the user to play.

By definition, it's hard to show the user why a certain map is recommended. Since we don't have explicit skill tags, we can't really say "map X has high aim difficulty" or "map Y has high stream difficulty." Instead, we rely on the map's properties like star rating, accuracy, length, etc. as a proxy for difficulty. This model "learns" the difficulty of the map.

What we CAN do is show the user which maps have similar embeddings to the maps they've played.


Example Recommendation

Example of a map recommendation.


While there is the obvious con of not being able to explain why a map is recommended, there are a few pros to this approach:


  • Reduced Ambiguity: No need to define or worry about "hidden" axes (like readability, technicality, or streaming speed) that might be interpreted differently by different people. The embedding handles this implicitly.

  • Simpler Architecture: We can use a simpler, "axis-free" autoencoder architecture (like the Denoising Autoencoder) instead of a more complex model with multiple decoders for each skill axis (e.g., a stacker autoencoder).

  • Data Flexibility: Since we are not relying on pre-defined axis labels, we can train the model on various datasets (e.g., user replays, map characteristics) without needing to map them to specific axes first.

  • Strong Foundation: A subsequent axis model can be added on TOP of this model, allowing us to still get the explainability while using the vector embeddings as a base for the model.

  • Modded Compatability: This model can learn which maps are likely to have certain mods based on their structure, something that is difficult to do with explicit axes. It can also recommend maps with mods that the user hasn't played before.


Modded Map Recommendation Example

Example of a modded map recommendation for mrekk. Since the influential maps are modded, the model recommends other maps (in modded settings).


In a way, this kind of model has higher potential for better recommendations since we don't rely on multiple subjective axis definitions.

Additionally, the website allows you to view the replays that are being used to calculate your recommendations.


Replay list

Example of the replay list


This is a really simple website, but there's a lot of room for expansion. Some ideas I had include:

  • Multiplayer Graphs: Letting players compare their replays with their friends. This would require creating a "player fingerprint" - something that isn't too difficult with the already embedded maps.
  • Anomolous Map Finder: Find maps with low similarity scores - this could be used to find maps that are "outside of your playstyle" or maps that are generally difficult for most players.
  • Map Structure Validation: Allowing mappers to compare sections of their map to a benchmark of other maps.
  • Beatmap Generator: Again, one of my original ideas. If we simply reverse-engineer our autoencoder, we can generate new maps.

There's also a lot of bugs at the moment that need to be fixed. To be honest, I vibe-coded a lot of the frontend, and the backend can still be improved in terms of efficiency. That being said, I wanted to deploy an MVP as fast as possible to get it into the hands of other players and get their feedback.

That's really it for the showcase. If you don't want to read my yap on the technical details, the blog basically ends here. Thank you for checking in :)


The Models

Oh boy. There's a bunch of math-heavy stuff to unpack here, and it may be hard to read in one sitting (because I can't write concisely).

I'd recommend having a background in linear algebra, calculus, and probability to fully understand this section.

Here's a brief idea of the topic list:

  • CNN/LSTM models
  • Autoencoders and VAEs
  • K/L Divergence and Constrastive Learning
  • ALS Factorization and Embedding

To start, let's talk about how I inputted my data.


1. Data Processing

Osu! beatmap files (.osu files) have a specific format. From them, you can extract all the hit objects (circles, sliders and spinners). You can also get all the timing points (determines bpm, offset, slider velocity, etc.).

To put this data into a nice list of numbers, I did the following:

  • For each object, get the position and time.
  • Find the differences in position and time between consecutive objects. Now we have Δx\Delta x and Δy\Delta y values.
  • Get the type of object and its details.
  • Also put in the overall map stats (e.g. OD, AR, HP, CS).

All in all, I had a 13-dimensional vector.


  • Δx\Delta x - change in x position
  • Δy\Delta y - change in y position
  • Δt\Delta t - change in time
  • is_circle - 1 if circle, 0 otherwise
  • is_slider - 1 if slider, 0 otherwise
  • is_spinner - 1 if spinner, 0 otherwise
  • slider_velocity - slider velocity
  • slider_linearity - slider linearity
  • slider_bezier - slider bezier
  • OD - overall difficulty
  • AR - approach rate
  • HP - health drain
  • CS - circle size

I took a maximum of 2000 objects per map to keep the padding and training time reasonable. Thus, our input is a matrix of size 2000x13.


2. Variational Autoencoder (VAE) Math [3]

If you want to skip all the math, click here.

The variational autoencoder is a model that was introduced around 2013 by Diedrik P. Kingma and Max Welling. The paper can be found here [4].

Standard autoencoders have two parts: an encoder, and a decoder. The encoder turns an input vector (list of numbers) into a compressed vector (basically shrinking the input vector in size, i.e. dimensions). The decoder tries to reconstruct the original input from this compressed vector. An easy way to think about it is trying to compress a file into a zip file, and then decompressing the zip file. (Note: This is not actually how zip files work, but it's a good analogy.)


Autoencoder Diagram

A diagram of an autoencoder (NOT A VAE) from GeeksforGeeks [5]. (source)


In most cases, both the encoder and decoder are defined as multilayer perceptrons (fancy term for a type of neural network). Let's use a more mathematical approach (this will make it easier to compare VAEs later).


1. Mathematical Formulation of Autoencoders

Given an input vector xRdx \in \mathbb{R}^d, we create the encoder function fϕf_\phi which maps xx to a compressed vector zRdz \in \mathbb{R}^d (d<<Dd << D).

(The notation fϕf_\phi means that we have a function ff that depends on parameters ϕ\phi. In this case, ϕ\phi represents the weights and biases of the neural network.)

z=fϕ(x)z = f_\phi(x)

We use the decoder function gθg_\theta to reconstruct an estimation of the original vector x^\hat{x}.

x^=gθ(z)=gθ(fϕ(x))\hat{x} = g_\theta(z) = g_\theta(f_\phi(x))

Our goal is to learn parameters ϕ\phi and θ\theta such that x^x\hat{x} \approx x. In the event of continuous output space (like our map features), we can define our reconstruction loss Lrec(ϕ,θ)L_{rec}(\phi, \theta) using Mean Squared Error [20] (a typical deterministic loss function for regression tasks):

Lrec(ϕ,θ)=xx^22L_{rec}(\phi, \theta) = ||x - \hat{x}||^2_2

Autoencoders can be trained like any other neural network, so gradient descent is typically used.


That's how standard autoencoders work, but variational autoencoders are a bit different. Variational Autoencoders approach learning with a more probabilistic approach (specifically using Bayesian Inference [6]).



2. Maximum Likelihood Estimation [7] - A Primer

📊 Click here for more info!

Small tangent (I promise it's relevant), but do you know how statistics like sample mean and sample standard deviation are derived? I'm sure you've heard of them before.

If you have a i.i.d [12] normally distributed [18] random sample of data, say points x1,x2,,xnx_1, x_2, \dots, x_n, we can calculate the sample mean xˉ\bar{x} as:

xˉ=1ni=1nxi\bar{x} = \frac{1}{n} \sum_{i=1}^{n} x_i

This formula is beautifully simple, and it's built on the idea that we can maximize the chance of getting our sample data given a certain distribution.

As an analogy, think of flipping a coin. If the coin is truly fair (50/50), what's the probability of getting 3 heads in a row? The same idea goes here - if the true population mean is μ\mu, what's the probability of getting our sample data? We want to choose a μ\mu that maximizes this probability.

To figure this out, we use the Maximum Likelihood Estimation (MLE) method - a method for estimating the parameters of a statistical model given an observed dataset.

I'll spare the calculus lesson (do you know derivatives/integrals?), but let's go through an example.

We will make an estimation of the population mean μ\mu. We will call our estimate μ^\hat{\mu} (mu-hat).

We look at the likelihood of observing our data given the parameter μ\mu (the chance that we get this data if the mean is μ\mu). If each data point is sampled independently, then the likelihood of observing all of our data is the product of the likelihoods of observing each data point.

L(μ)=P(Xμ)=i=1n12πσ2e(xiμ)22σ2L(\mu) = P(X | \mu) = \prod_{i=1}^{n} \frac{1}{\sqrt{2\pi\sigma^2}} e^{-\frac{(x_i - \mu)^2}{2\sigma^2}}

The formula above looks ridiculous, and it looks like I pulled it out of thin air, but it's the normal probabilty density function. Now that I'm writing this, I realize that there's a lot to explain...oof...

Typically, we use log-likelihood (taking the logarithm) to make computations easier.

l(μ)=log(i=1n12πσ2e(xiμ)22σ2)=i=1nlog(12πσ2)+log(e(xiμ)22σ2)=i=1nlog(2πσ2)(xiμ)22σ2\begin{align} l(\mu) &= log(\prod_{i=1}^{n} \frac{1}{\sqrt{2\pi\sigma^2}} e^{-\frac{(x_i - \mu)^2}{2\sigma^2}}) \\ &= \sum_{i=1}^{n} log(\frac{1}{\sqrt{2\pi\sigma^2}}) + log(e^{-\frac{(x_i - \mu)^2}{2\sigma^2}}) \\ &= \sum_{i=1}^{n} -log(\sqrt{2\pi\sigma^2}) - \frac{(x_i - \mu)^2}{2\sigma^2} \end{align}

Now, how do we maximize l(μ)l(\mu)? We find the critical point (calculus 1)! Since we know that the function is concave (you can verify this by taking the Hessian of the function, but just take my word for it...), the critical point is guaranteed to be the maximum.

By differentiating the log-likelihood function with respect to μ\mu, we get:

ddμ[l(μ)]=ddμ[i=1nlog(2πσ2)(xiμ)22σ2]=i=1nddμ[log(2πσ2)]ddμ[(xiμ)22σ2]=012σ2i=1n2(xiμ)(1)=1σ2i=1n(xiμ)\begin{align} \frac{d}{d\mu} [l(\mu)] &= \frac{d}{d\mu} \left[ \sum_{i=1}^{n} -log(\sqrt{2\pi\sigma^2}) - \frac{(x_i - \mu)^2}{2\sigma^2} \right] \\ &= \sum_{i=1}^{n} \frac{d}{d\mu} [-log(\sqrt{2\pi\sigma^2})] - \frac{d}{d\mu} \left[ \frac{(x_i - \mu)^2}{2\sigma^2} \right] \\ &= 0 - \frac{1}{2\sigma^2} \sum_{i=1}^{n} 2(x_i - \mu)(-1) \\ &= \frac{1}{\sigma^2} \sum_{i=1}^{n} (x_i - \mu) \end{align}

And by setting it to 0, we solve for our estimator μ^\hat{\mu}:

1σ2i=1n(xiμ^)=0i=1n(xiμ^)=0i=1nxii=1nμ^=0i=1nxi=nμ^μ^=1ni=1nxi=xˉ\begin{gathered} \frac{1}{\sigma^2} \sum_{i=1}^{n} (x_i - \hat{\mu}) = 0 \\ \sum_{i=1}^{n} (x_i - \hat{\mu}) = 0 \\ \sum_{i=1}^{n} x_i - \sum_{i=1}^{n} \hat{\mu} = 0 \\ \sum_{i=1}^{n} x_i = n \hat{\mu} \\ \hat{\mu} = \frac{1}{n} \sum_{i=1}^{n} x_i = \bar{x} \end{gathered}

Therefore, our best estimation for the population mean is xˉ\bar{x}, commonly known as the sample mean!

I wish I learned how these formulas were derived back when I first took statistics...it's honestly fascinating.


3. Bayesian Inference [6] - Another Primer

🔮 Click here for more info!

To understand VAEs, we must first understand how Bayesian Inference [6] works in the context of statistics.

This technique involves using a prior belief (a hypothesis HH), and using new evidence (data DD) to update our beliefs (the posterior probability P(HD)P(H|D)).

We begin with Bayes' Theorem [8] - a way to calculate posterior probabilities. Bayes' Theorem is defined as:

P(HD)=P(DH)P(H)P(D) P(H|D) = \frac{P(D|H)P(H)}{P(D)}

Where:

  • HH: Hypothesis - A statement or proposition about the world
  • DD: Data - The observed evidence
  • P(HD)P(H|D): Posterior Probability - The probability of the hypothesis being true given the data
  • P(DH)P(D|H): Likelihood - The probability of the observed data given the hypothesis
  • P(H)P(H): Prior Probability - The probability of the hypothesis being true before observing the data
  • P(D)P(D): Evidence - The probability of the observed data

In MLE, we treated the hypothesis (or parameters) as fixed, unknown constants, and our goal was to find the single value that maximized the likelihood P(DH)P(D|H) of our observed data (for example, finding the MLE of μ\mu - a CONSTANT).

In the Bayesian framework, however, we treat the hypothesis (i.e. μ\mu) itself as a random variable. We assign it a prior probability distribution P(μ)P(\mu) (representing our beliefs before seeing any data). For instance, maybe we think that coin flips are usually fair, so we assign a prior probability distribution that is peaked around μ=0.5\mu = 0.5 (we do not say that μ\mu IS 0.5 - this is the difference).


We then update our beliefs by multiplying the prior by the likelihood P(Dμ)P(D|\mu) to get the posterior probability distribution P(μD)P(\mu|D) using Bayes' Theorem.

Here's an interesting way to think about it: MLE is basically Bayes' rule with a uniform prior distribution (P(H)P(H) = 1 for all H).


4. Inferring Posterior Distributions (Primers Over!)

We apply the Bayesian framework to infer the posterior probability distribution P(zx)P(z|x):

P(zx)=P(xz)P(z)P(x)P(z|x) = \frac{P(x|z)P(z)}{P(x)}

Where:

  • P(zx)P(z|x): Posterior Probability - The probability of the latent variable given the observed data
  • P(xz)P(x|z): Likelihood - The probability of the observed data given the latent variable
  • P(z)P(z): Prior Probability - The probability of the latent variable before observing the data
  • P(x)P(x): Evidence - The probability of the observed data

At the same time, we want to infer the posterior distribution P(zx)P(z|x) to find the latent representation zz for any given data point xx.

(TLDR: we want to maximize the likelihood of our vector xx while finding a compressed form zz that captures the most important features in xx.)

To apply this to our autoencoder, we parameterize the generative model (the decoder) with parameters θ\theta. Thus, our model's likelihood is Pθ(xz)P_\theta(x|z) and the joint distribution is Pθ(x,z)=Pθ(xz)P(z)P_\theta(x, z) = P_\theta(x|z)P(z) (assuming a fixed prior P(z)P(z)).

As mentioned earlier, the denominator, Pθ(x)P_\theta(x), is the model evidence. It represents the probability of generating our observed data under all possible latent configurations:

Pθ(x)=Pθ(x,z)dz=Pθ(xz)P(z)dzP_\theta(x) = \int P_\theta(x, z) dz = \int P_\theta(x|z) P(z) dz

The problem is that computing this integral is ridiculously hard to do for complex, high-dimensional datasets. The reason for this is that we would have to search over the entire infinite space of possible latent variables zz to compute the integral. If our zz was 1-dimensional, computation wouldn't be hard. However, compressed vectors are often 128 or 256 dimensional, making the integral impossible to compute.

You could try to differentiate the log-likelihood with respect to the parameters θ\theta directly, but computing that gradient requires taking an expectation over the true posterior Pθ(zx)P_\theta(z|x)—which is the very thing we are trying to infer in the first place (catch 22)!

Because we cannot calculate Pθ(x)P_\theta(x), we cannot compute the true model posterior Pθ(zx)P_\theta(z|x) directly using Bayes' Theorem.

How do we find zz (our compressed vector) AND θ\theta (our decoder parameters) then??


5. Variational Inference [9]: Approximating the Unknowable

Since we can't calculate the true model posterior Pθ(zx)P_\theta(z|x), we have to approximate it. We use a technique called Variational Inference to approximate this intractable posterior distribution (the crazy integral from before).

We do this by introducing a new, simpler distribution qϕ(zx)q_\phi(z|x) (called the variational posterior or the recognition model), which is parameterized by weights ϕ\phi. In practice, this distribution is parameterized by our encoder network.

We want to make qϕ(zx)q_\phi(z|x) as close as possible to the true model posterior Pθ(zx)P_\theta(z|x). In probability theory, we measure the difference (or divergence) between two probability distributions using the Kullback-Leibler (KL) Divergence [10].

On a surface level, we are trying to find the difference between our approximation of the posterior distribution and the REAL posterior distribution. We do this by integrating over our simpler distribution and multiplying it by the ratio of the two distributions (inside a log). Optimally, if the two distributions are identical, the KL divergence becomes log(1)=0log(1) = 0.

DKL(qϕ(zx)Pθ(zx))=qϕ(zx)log(qϕ(zx)Pθ(zx))dzD_{KL}(q_\phi(z|x) \parallel P_\theta(z|x)) = \int q_\phi(z|x) \log \left( \frac{q_\phi(z|x)}{P_\theta(z|x)} \right) dz

The KL divergence is always greater than or equal to 0 (DKL0D_{KL} \geq 0). If you'd like to know more about why, check the wiki article (I don't want to explain it LOL).

Our goal is now to minimize this divergence. It can serve as our loss function! But...

Hollup. The equation for KL divergence still contains the intractable true posterior Pθ(zx)P_\theta(z|x) inside the log. That was the whole problem...so how do we minimize it?

Well...we don't. Instead, we rearrange the equation to isolate the evidence Pθ(x)P_\theta(x) (which is equivalent to Pϕ(x)P_\phi(x) for our purposes).


6. The Pivot: Evidence Lower Bound (ELBO) [11]

Haha, ELBO. Pronounced like elbow. Like, pivot...elbow...

ok. anyways.

Given the parameters of our encoder (ϕ\phi) and decoder (θ\theta), we can use the following formula:

ELBO(θ,ϕ;x)=Eqϕ(zx)[logPθ(x,z)logqϕ(zx)]\mathrm{ELBO}(\theta, \phi; x) = \mathbb{E}_{q_\phi(z|x)} \left[ \log P_\theta(x, z) - \log q_\phi(z|x) \right]

The ELBO is a lower bound on the model evidence logPθ(x)\log P_\theta(x) (and thus also a lower bound on the KL divergence).

📐 Show the math derivation!

Let's expand the logarithm of the ratio inside the KL divergence:

log(qϕ(zx)Pθ(zx))=logqϕ(zx)logPθ(zx)\log \left( \frac{q_\phi(z|x)}{P_\theta(z|x)} \right) = \log q_\phi(z|x) - \log P_\theta(z|x)

Now, using Bayes' Theorem, we can write logPθ(zx)\log P_\theta(z|x) as:

logPθ(zx)=log(Pθ(x,z)Pθ(x))=logPθ(x,z)logPθ(x)\log P_\theta(z|x) = \log \left( \frac{P_\theta(x, z)}{P_\theta(x)} \right) = \log P_\theta(x, z) - \log P_\theta(x)

Substituting this back into the log ratio:

logqϕ(zx)logPθ(zx)=logqϕ(zx)logPθ(x,z)+logPθ(x)\log q_\phi(z|x) - \log P_\theta(z|x) = \log q_\phi(z|x) - \log P_\theta(x, z) + \log P_\theta(x)

Now, let's plug this back into the KL divergence formula (expressing it as an expectation):

DKL(qϕ(zx)Pθ(zx))=Eqϕ(zx)[logqϕ(zx)logPθ(x,z)+logPθ(x)]D_{KL}(q_\phi(z|x) \parallel P_\theta(z|x)) = \mathbb{E}_{q_\phi(z|x)} \left[ \log q_\phi(z|x) - \log P_\theta(x, z) + \log P_\theta(x) \right]

Since the expectation is with respect to zz, and logPθ(x)\log P_\theta(x) does not depend on zz (it is a constant relative to zz), we can pull logPθ(x)\log P_\theta(x) out of the expectation:

DKL(qϕ(zx)Pθ(zx))=Eqϕ(zx)[logqϕ(zx)logPθ(x,z)]+logPθ(x)D_{KL}(q_\phi(z|x) \parallel P_\theta(z|x)) = \mathbb{E}_{q_\phi(z|x)} \left[ \log q_\phi(z|x) - \log P_\theta(x, z) \right] + \log P_\theta(x)

Let's rearrange this equation to solve for the log-evidence logPθ(x)\log P_\theta(x):

logPθ(x)=Eqϕ(zx)[logPθ(x,z)logqϕ(zx)]+DKL(qϕ(zx)Pθ(zx))\log P_\theta(x) = \mathbb{E}_{q_\phi(z|x)} \left[ \log P_\theta(x, z) - \log q_\phi(z|x) \right] + D_{KL}(q_\phi(z|x) \parallel P_\theta(z|x))

Because the KL divergence is always non-negative (DKL0D_{KL} \geq 0), the expectation term on the left acts as a lower bound on the log-evidence. We call this the Evidence Lower Bound, or ELBO:

ELBO(θ,ϕ;x)=Eqϕ(zx)[logPθ(x,z)logqϕ(zx)]\mathrm{ELBO}(\theta, \phi; x) = \mathbb{E}_{q_\phi(z|x)} \left[ \log P_\theta(x, z) - \log q_\phi(z|x) \right]

The core relationship between our true log-likelihood and the ELBO is:

logPθ(x)ELBO(θ,ϕ;x)\log P_\theta(x) \geq \mathrm{ELBO}(\theta, \phi; x)

This is a beautiful mathematical trick. By maximizing the ELBO:

  • We maximize the log-likelihood of our model generating real data logPθ(x)\log P_\theta(x).
  • We implicitly minimize the KL divergence DKL(qϕ(zx)Pθ(zx))D_{KL}(q_\phi(z|x) \parallel P_\theta(z|x)), forcing our approximate posterior to converge to the true posterior.

Connecting ELBO Back to MLE

If this feels a bit detached from the MLE primer we went through earlier, here is the connection: our high-level objective in training the VAE (specifically the decoder) is to find parameters θ\theta that maximize the likelihood of the training data:

maxθlogPθ(x)\max_\theta \log P_\theta(x)

This is exactly Maximum Likelihood Estimation! Because we cannot optimize logPθ(x)\log P_\theta(x) directly due to the intractable integration over zz, we use the ELBO as a proxy goal (that's what the last few sections were about). When we maximize the ELBO with respect to θ\theta and ϕ\phi, we are performing approximate MLE on our neural network parameters.


7. ELBO Loss Analysis

Let's expand the joint probability Pθ(x,z)=Pθ(xz)P(z)P_\theta(x, z) = P_\theta(x|z) P(z) inside the ELBO definition to see how it maps to an autoencoder structure:

ELBO(θ,ϕ;x)=Eqϕ(zx)[log(Pθ(xz)P(z))logqϕ(zx)]=Eqϕ(zx)[logPθ(xz)+logP(z)logqϕ(zx)]=Eqϕ(zx)[logPθ(xz)]Eqϕ(zx)[logqϕ(zx)logP(z)]=Eqϕ(zx)[logPθ(xz)]DKL(qϕ(zx)P(z))\begin{align} \mathrm{ELBO}(\theta, \phi; x) &= \mathbb{E}_{q_\phi(z|x)} \left[ \log \left( P_\theta(x|z) P(z) \right) - \log q_\phi(z|x) \right] \\ &= \mathbb{E}_{q_\phi(z|x)} \left[ \log P_\theta(x|z) + \log P(z) - \log q_\phi(z|x) \right] \\ &= \mathbb{E}_{q_\phi(z|x)} \left[ \log P_\theta(x|z) \right] - \mathbb{E}_{q_\phi(z|x)} \left[ \log q_\phi(z|x) - \log P(z) \right] \\ &= \mathbb{E}_{q_\phi(z|x)} \left[ \log P_\theta(x|z) \right] - D_{KL}(q_\phi(z|x) \parallel P(z)) \end{align}

When training a neural network, we usually minimize a loss function, so we define the VAE loss as the negative ELBO:

LVAE(θ,ϕ;x)=Eqϕ(zx)[logPθ(xz)]+DKL(qϕ(zx)P(z))L_{\mathrm{VAE}}(\theta, \phi; x) = - \mathbb{E}_{q_\phi(z|x)} \left[ \log P_\theta(x|z) \right] + D_{KL}(q_\phi(z|x) \parallel P(z))

Let's dissect these two distinct terms:

  • Reconstruction Term: Eqϕ(zx)[logPθ(xz)]- \mathbb{E}_{q_\phi(z|x)} \left[ \log P_\theta(x|z) \right]
    This term measures how well the decoder (Pθ(xz)P_\theta(x|z)) reconstructs the original input xx from a latent vector zz sampled from the encoder (qϕ(zx)q_\phi(z|x)). Under a Gaussian assumption for continuous inputs, this expectation is equivalent to Mean Squared Error (MSE) - the distance formula.

  • KL Regularization Term: DKL(qϕ(zx)P(z))D_{KL}(q_\phi(z|x) \parallel P(z))
    This term measures how much our approximate posterior qϕ(zx)q_\phi(z|x) deviates from the prior distribution P(z)P(z). By choosing a simple standard normal prior, P(z)=N(0,I)P(z) = \mathcal{N}(0, I), we force the encoder to map inputs to a smooth, continuous, and centered region of the latent space, preventing overfitting.


The difference between these two terms represents a trade-off. If we minimize the reconstruction term, the model will prioritize accurate reconstruction, potentially ignoring the regularization term and leading to overfitting. On the other hand, if we minimize the regularization term, the model will prioritize a smooth latent space, potentially sacrificing reconstruction accuracy.


8. The Reparameterization Trick

OK, but there's another obstacle. To train the encoder and decoder end-to-end, gradients must flow backwards through the network: from the reconstruction loss, through the latent code zz, and into the encoder.

But zz is sampled stochastically from our simplified posterior: zqϕ(zx)z \sim q_\phi(z|x).

Because sampling is a random process, it is not differentiable...so you can't get a gradient from it. That means backpropagation cannot calculate how a small change in the encoder's parameters (ϕ\phi) affects the sample zz.

To solve this, Kingma and Welling introduced the Reparameterization Trick.

Instead of sampling directly from N(μ,σ2)\mathcal{N}(\mu, \sigma^2), we sample a noise variable ϵ\epsilon from a standard normal distribution:

ϵN(0,I)\epsilon \sim \mathcal{N}(0, I)

Then, we calculate zz (our compressed vector) using a deterministic, differentiable formula:

z=μ+σϵz = \mu + \sigma \odot \epsilon

Where \odot represents element-wise multiplication. By shifting the randomness to ϵ\epsilon (which doesn't depend on the encoder's parameters), the pathway from the encoder's outputs (μ\mu and σ\sigma) to zz becomes fully differentiable!


9. Contrastive Learning and InfoNCE Loss [13]

OK, we're almost done with the math and theory part of this project. Let's talk about contrastive learning.

I actually first learned about this in my Statistical Machine Learning class. I never thought I'd actually use it...

Contrastive learning aims to pull positive pairs (two augmented versions of the same beatmap) closer together in latent space while pushing negative pairs (different beatmaps in the batch) further apart. It's a discriminator, but for data. It tries to distinguish positive pairs from negative pairs. This helps prevent a VAE from collapsing (mapping everything to a single point in latent space).

First, we need a good augmented beatmap dataset. We apply stochastic data augmentations (such as small spatial/temporal shifts, scaling, and random noise) to create two augmented views per map, going from NN to 2N2N samples.


For a positive pair of normalized latent projections (zi,zj)(\mathbf{z}_i, \mathbf{z}_j), the InfoNCE Loss is defined as:

Li,j=logexp(sim(zi,zj)τ)k=1,ki2Nexp(sim(zi,zk)τ)L_{i, j} = - \log \frac{\exp\left( \frac{\mathrm{sim}(\mathbf{z}_i, \mathbf{z}_j)}{\tau} \right)}{\sum_{k=1, k \neq i}^{2N} \exp\left( \frac{\mathrm{sim}(\mathbf{z}_i, \mathbf{z}_k)}{\tau} \right)}

Where:

  • Cosine Similarity: sim(zi,zj)=zizjzi2zj2\mathrm{sim}(\mathbf{z}_i, \mathbf{z}_j) = \frac{\mathbf{z}_i^\top \mathbf{z}_j}{\|\mathbf{z}_i\|_2 \|\mathbf{z}_j\|_2} measures the directional alignment between vectors in hyperspace. It is a very common function to use in machine learning.
  • Temperature (τ\tau): A hyperparameter that controls the scale of penalties for hard negative pairs. Higher temperatures make the distribution softer, while lower temperatures make it sharper.
  • Denominator Sum: Computes similarity against all other 2N12N - 1 augmented maps in the batch, treating them as negative pairs. Very contrastive.

By incorporating InfoNCE loss into our total VAE loss:

Ltotal=Lrecon+βDKL+λcontrastLInfoNCEL_{\text{total}} = L_{\text{recon}} + \beta \cdot D_{\text{KL}} + \lambda_{\text{contrast}} \cdot L_{\text{InfoNCE}}

The model learns an embedding space where beatmaps with similar structural rhythms and placement properties naturally cluster together without needing manual skill axis labels!


3. The Variational Autoencoder (VAE) Model

Whew, finally done with the math. Now, let's go into the practical VAE model.


1. Data Representation & Feature Engineering

As a reminder, we have these 13 features for each hit object in a beatmap:


  • Δx\Delta x (Spatial Delta X): xixi1x_i - x_{i-1} normalized by playfield width (Δx512.0\frac{\Delta x}{512.0}).
  • Δy\Delta y (Spatial Delta Y): yiyi1y_i - y_{i-1} normalized by playfield height (Δy384.0\frac{\Delta y}{384.0}).
  • Δt\Delta t (Temporal Delta): Time gap capped at 2000 ms2000\text{ ms} and scaled (min(Δt,2000)1000.0\frac{\min(\Delta t, 2000)}{1000.0}).
  • is_circle: Binary indicator (1.01.0 for hit circle, 0.00.0 otherwise).
  • is_slider: Binary indicator (1.01.0 for slider, 0.00.0 otherwise).
  • is_spinner: Binary indicator (1.01.0 for spinner, 0.00.0 otherwise).
  • slider_velocity: Pixels per millisecond normalized (length/duration2.0\frac{\text{length} / \text{duration}}{2.0}).
  • slider_linearity: Ratio of Euclidean start-to-end distance over path length (min(1.0,dbeelinelength)\min(1.0, \frac{d_{\text{beeline}}}{\text{length}})).
  • is_bezier: Binary flag (1.01.0 if slider uses Bézier curve control points).
  • circle_size: Normalized Circle Size (CS10.0\frac{\text{CS}}{10.0}).
  • approach_rate: Normalized Approach Rate (AR10.0\frac{\text{AR}}{10.0}).
  • hp_drain_rate: Normalized HP Drain (HP10.0\frac{\text{HP}}{10.0}).
  • overall_difficulty: Normalized Overall Difficulty (OD10.0\frac{\text{OD}}{10.0}).

All of these features are min-max scaled to (0,1)(0, 1) before being fed into the VAE. This is traditional machine learning pipeline stuff, but it's important to mention.


The maps themselves were taken from an o!rdr Replay Dump [14] on Kaggle, where each beatmap had a few play examples. These play examples will be important later.


2. Model Architecture: 1D CNN Encoder-Decoder

Beatmaps exhibit strong local temporal dependencies (rhythms, streams, spatial jumps). 1D Convolutions allow the model to extract hierarchical patterns along the sequence length TT.

Input MatrixXR2000×13\mathbf{X} \in \mathbb{R}^{2000 \times 13}Raw Beatmap Sequence
CNN Encoder5× Conv1D Blocks1325613 \rightarrow 256 Channels
Latent BottleneckzN(μ,σ2)\mathbf{z} \sim \mathcal{N}(\boldsymbol{\mu}, \boldsymbol{\sigma}^2)zR128\mathbf{z} \in \mathbb{R}^{128} Latent Vector
CNN Decoder5× Conv1D + Upsample25613256 \rightarrow 13 Channels
ReconstructionX^R2000×13\mathbf{\hat{X}} \in \mathbb{R}^{2000 \times 13}Reconstructed Sequence

Encoder

The encoder processes input tensors of shape (batch_size, 13, 2000) through 5 sequential 1D convolutional blocks. Each block applies a Conv1d, BatchNorm1d, LeakyReLU(0.1), and Dropout:


  • Conv Block 1: 13 \rightarrow 32 channels, kernel 5, stride 2, padding 2
  • Conv Block 2: 32 \rightarrow 64 channels, kernel 5, stride 2, padding 2
  • Conv Block 3: 64 \rightarrow 128 channels, kernel 5, stride 2, padding 2
  • Conv Block 4: 128 \rightarrow 256 channels, kernel 5, stride 2, padding 2
  • Conv Block 5: 256 \rightarrow 256 channels, kernel 5, stride 5, padding 0

We use dynamic sequence masking to ensure that maps that have less than 2000 hit objects are still processed correctly (the model will not train on a bunch of padded zeros). After adaptive average pooling (taking the average along the sequence dimension) to pool size 16, two separate linear layers output the Gaussian mean (μ\boldsymbol{\mu}) and log-variance (logσ2\log \boldsymbol{\sigma}^2) vectors for a 128-dimensional latent space:

μ=WμPooled(H)+bμ,logσ2=Wσ2Pooled(H)+bσ2\boldsymbol{\mu} = W_\mu \cdot \text{Pooled}(\mathbf{H}) + b_\mu, \quad \log \boldsymbol{\sigma}^2 = W_{\sigma^2} \cdot \text{Pooled}(\mathbf{H}) + b_{\sigma^2}

Reparameterization Trick

Now, we use that trick from the math part. Using the predicted μ\boldsymbol{\mu} and logσ2\log \boldsymbol{\sigma}^2, the latent vector zR128\mathbf{z} \in \mathbb{R}^{128} is sampled as a continuous function of the input via standard normal noise ϵN(0,I)\boldsymbol{\epsilon} \sim \mathcal{N}(\mathbf{0}, \mathbf{I}):

z=μ+exp(12logσ2)ϵ\mathbf{z} = \boldsymbol{\mu} + \exp\left(\frac{1}{2} \log \boldsymbol{\sigma}^2\right) \odot \boldsymbol{\epsilon}

Decoder

The decoder maps z\mathbf{z} back to shape (batch_size, 13, 2000) using a linear projection layer followed by 5 upsampling blocks:

  • Linear projection: 128256×16128 \rightarrow 256 \times 16 tensor
  • Upsample Block 1: Linear interpolation to size 125 \rightarrow Conv1d(256 \rightarrow 256)
  • Upsample Block 2: Linear interpolation to size 250 \rightarrow Conv1d(256 \rightarrow 128)
  • Upsample Block 3: Linear interpolation to size 500 \rightarrow Conv1d(128 \rightarrow 64)
  • Upsample Block 4: Linear interpolation to size 1000 \rightarrow Conv1d(64 \rightarrow 32)
  • Upsample Block 5: Linear interpolation to size 2000 \rightarrow Conv1d(32 \rightarrow 13)

The output of the decoder is our reconstruction X^\mathbf{\hat{X}}, which we compare to the original input X\mathbf{X} using Mean Squared Error.


3. Contrastive VAE Loss Function

To enforce clustering of semantically similar beatmap patterns while maintaining smooth latent generation, the network is trained using a composite Contrastive VAE objective:

Ltotal=LMSE(X,X^)+βDKL ⁣(N(μ,σ2)N(0,I))+λcontrastLInfoNCE(z1,z2)L_{\text{total}} = L_{\text{MSE}}(\mathbf{X}, \mathbf{\hat{X}}) + \beta \cdot D_{\text{KL}}\!\left(\mathcal{N}(\boldsymbol{\mu}, \boldsymbol{\sigma}^2) \parallel \mathcal{N}(\mathbf{0}, \mathbf{I})\right) + \lambda_{\text{contrast}} \cdot L_{\text{InfoNCE}}(\mathbf{z}_1, \mathbf{z}_2)

Where:

  • Reconstruction Loss (LMSEL_{\text{MSE}}): Mean Squared Error between the original beatmap feature matrix X\mathbf{X} and the decoded matrix X^\mathbf{\hat{X}}.
  • KL Divergence (DKLD_{\text{KL}}): Penalizes posterior deviation from the prior N(0,I)\mathcal{N}(\mathbf{0}, \mathbf{I}), multiplied by weighting factor β\beta.
  • Contrastive Loss (LInfoNCEL_{\text{InfoNCE}}): Measures similarity between augmented view pairs (z1,z2)(\mathbf{z}_1, \mathbf{z}_2) generated via spatial/temporal jittering, scaling, and Gaussian noise.


4. Hyperparameter Tuning & Optimal Configuration

Hyperparameter tuning was conducted using Optuna Bayesian optimization to maximize reconstruction accuracy while ensuring latent space stability. The best performing hyperparameter configuration yielded a final validation loss of 0.0571:

ParameterOptimized ValueDescription
Embedding Size128Latent representation dimension (dembedd_{\text{embed}})
Learning Rate1.52e-4Adam optimizer learning rate
Dropout Rate0.2036Conv block regularization dropout probability
Weight Decay1.63e-5L2 regularization coefficient
KL Weight (β\beta)1.3929ELBO KL divergence loss scaling multiplier
Contrastive Weight (λ\lambda)0.0263InfoNCE contrastive term scaling multiplier
Temperature (τ\tau)0.1790InfoNCE cosine similarity soft-max scaling
Final Epochs21Training iterations until convergence
Best Val Loss0.0571Evaluated composite loss on validation set

While this data is...mostly irrelevant without the code, I did want to share it. I will eventually publish the code once I clean it up. I tried a LOT of different things before settling with a VAE, so...


5. VAE Conclusions

That's it for the VAE part. I did try quite a few other things, but they didn't work out as well:


  • Standard Autoencoder
  • Denoising Autoencoder
  • An LSTM-based autoencoder

Regardless, I now have a model that can - at least somewhat - accurately embed beatmaps into a smaller vector space. Oh yeah, my friend Dhruv basically built the vanilla LSTM autoencoder for me back when we worked on the beatmap generation idea. Thanks Dhruv.

Now, we go into the recommendation algorithm. Huhuhu.


4. Recommendation Model (Theory)

Again, if you want to skip this, you can click here.

Now that we have low-dimensional, content-aware VAE beatmap representations (zi\mathbf{z}_i), how do we generate personalized recommendations for an osu! player based on their actual play history?

Enter Implicit Alternating Least Squares (iALS) Matrix Factorization [15].


1. Implicit Feedback: Preference vs. Confidence

In standard recommendation systems (e.g., movie ratings), users provide explicit feedback—giving a rating rui{1,2,3,4,5}r_{ui} \in \{1, 2, 3, 4, 5\}. In osu!, however, players don't explicitly rate beatmaps. We only observe implicit feedback: play counts, retry counts, score submissions, and replay mastery scores.

We model this by splitting user-map interactions into two concepts:

  • Binary Preference (puip_{ui}): Indicates whether user uu has played beatmap ii. pui={1if rui>00if rui=0p_{ui} = \begin{cases} 1 & \text{if } r_{ui} > 0 \\ 0 & \text{if } r_{ui} = 0 \end{cases}

  • Confidence (cuic_{ui}): Measures how confident we are in that preference based on interaction magnitude (e.g., mastery score or play weight). cui=1+αmasteryuic_{ui} = 1 + \alpha \cdot \text{mastery}_{ui} Unobserved pairs (rui=0r_{ui} = 0) are assigned pui=0p_{ui} = 0 with a baseline confidence of cui=1.0c_{ui} = 1.0. A higher mastery score increases cuic_{ui}, signaling stronger positive preference.


We define the "mastery score" of the osu map arbitrarily, and I may change it in the future. For now, this is what it is:

masteryui=accuracy3max(0,1missestotalobjects)maxcombototalobjects\mathrm{mastery}_{ui} = \mathrm{accuracy}^3 * max\left(0, 1 - \frac{\mathrm{misses}}{{\mathrm{total objects}}}\right) * \frac{\mathrm{max combo}}{\mathrm{total objects}}

This implicit data is very valuable!


2. The iALS Objective Loss Function & User Vector Projection

We map each user uu to a vector xuRf\mathbf{x}_u \in \mathbb{R}^f and each beatmap ii to a vector yiRf\mathbf{y}_i \in \mathbb{R}^f (with f=64f = 64).

Originally, the matrices XX and YY are random. The steps below are used to optimize these matrices.

  • Keep YY fixed, optimize for xu\mathbf{x}_u via our loss function.
  • Keep XX fixed, optimize for yi\mathbf{y}_i via our loss function.
  • Repeat until convergence.

Unlike explicit factorization, we optimize the cost function over all N×MN \times M user-item pairs (including all unobserved pairs):

L(X,Y)=u=1Ni=1Mcui(puixuyi)2+λ(u=1Nxu22+i=1Myi22)\mathcal{L}(X, Y) = \sum_{u=1}^N \sum_{i=1}^M c_{ui} \left( p_{ui} - \mathbf{x}_u^\top \mathbf{y}_i \right)^2 + \lambda \left( \sum_{u=1}^N \|\mathbf{x}_u\|_2^2 + \sum_{i=1}^M \|\mathbf{y}_i\|_2^2 \right)

Where λ\lambda is the L2 regularization [17] hyperparameter (traditional ML concept) to prevent overfitting.


3. Alternating Optimization & The Sparse Trick

Now that we have our objective function L(X,Y)\mathcal{L}(X, Y), how do we actually find the optimal matrices XX and YY?


In matrix factorization [19], we are multiplying two unknown matrix variables together (xuyi\mathbf{x}_u^\top \mathbf{y}_i). This makes the overall loss function non-convex with respect to both XX and YY simultaneously. If you try to optimize both at the same time using basic gradient descent, optimization is slow and easily gets stuck in poor local minima.

However, notice something special:

  • If you freeze the beatmap matrix YY, the objective function becomes a simple weighted linear regression for every user vector xu\mathbf{x}_u (which is convex and has an exact analytical solution!).
  • Similarly, if you freeze the user matrix XX, solving for every beatmap vector yi\mathbf{y}_i also becomes a convex linear regression problem!

This is why it's called Alternating Least Squares (ALS): we alternate back and forth—holding YY fixed to solve for XX in closed form, then holding XX fixed to solve for YY in closed form—until the loss stabilizes.

xu=(YCuY+λI)1YCupu\mathbf{x}_u = \left( Y^\top C^u Y + \lambda I \right)^{-1} Y^\top C^u \mathbf{p}_u
yi=(XCiX+λI)1XCipi\mathbf{y}_i = \left( X^\top C^i X + \lambda I \right)^{-1} X^\top C^i \mathbf{p}_i

Now while the closed-form formula above looks simple, there is a performance issue:

CuC^u is a diagonal matrix of size M×MM \times M, where MM is the total number of beatmaps in the dataset (tens or hundreds of thousands of maps!). Trying to compute YCuYY^\top C^u Y for every user on every iteration requires O(f2M)O(f^2 M) operations. That's...really slow if you have millions of users.


Here's the trick:
Notice that Cu=I+(CuI)C^u = I + (C^u - I). We can rewrite YCuYY^\top C^u Y as:

YCuY=YY+Y(CuI)YY^\top C^u Y = Y^\top Y + Y^\top (C^u - I) Y

Now THIS is a game changer. Why?

  • YYY^\top Y (Global Baseline): Does not depend on a user. We can precompute YYY^\top Y once per iteration for all MM beatmaps in O(f2M)O(f^2 M) time and never touch it again.

  • Y(CuI)YY^\top (C^u - I) Y (Sparse User Correction): For user uu, (CuI)(C^u - I) is non-zero only for the maps the user has actually played (nun_u maps). Since a typical player has only played a small number of maps (a few hundred out of 100k+, nuMn_u \ll M), we only multiply over those nun_u played maps!


Substituting this back into the user update formula gives our fast iALS update:

xu=(YY+Ynu(CnuuI)Ynu+λI)1YCupu\mathbf{x}_u = \left( Y^\top Y + Y_{n_u}^\top (C_{n_u}^u - I) Y_{n_u} + \lambda I \right)^{-1} Y^\top C^u \mathbf{p}_u

This reduces the time complexity per user from O(f2M)O(f^2 M) down to O(f3+f2nu)O(f^3 + f^2 n_u). While f3f^3 still sounds slow, ff is only 64. Compared to O(f2M)O(f^2 M), this is a huge improvement!


5. Recommendation Model (Implementation)

Ok, blah blah blah, how does this translate into our backend implementation for serving recommendations?


1. Building the Sparse Matrix & Offline Fitting

First, we query all replay records (osu_id, map_hash, mastery_score) from our SQLite database and convert them into a sparse user-item matrix:

  • Each user and beatmap is assigned a unique integer index.
  • As mentioned earlier, confidence weights are assigned as cui=1.0+αmasteryuic_{ui} = 1.0 + \alpha \cdot \text{mastery}_{ui}.
  • We train our global AlternatingLeastSquares model with 6464 latent factors over 5050 iterations (with early stopping).

After fitting, the learned beatmap factor matrix YRM×64Y \in \mathbb{R}^{M \times 64} and integer-to-hash mapping dictionaries are saved to disk.


2. On-the-Fly User Vector Refresh (Real-Time Ingestion)

When a player enters their username on the site, we don't want to wait hours to re-train the entire global model across all users.

Instead, when our replay ingestor fetches the player's top plays, we project the player into the 64-dimensional latent space in real-time by executing the closed-form update rule directly in NumPy:

  • We extract the played map indices and calculate their mastery confidence weights.
  • We compute the sparse correction matrix Ynu(CnuuI)YnuY_{n_u}^\top (C_{n_u}^u - I) Y_{n_u} using only the maps the user played.
  • We solve the linear system (YY+Ynu(CnuuI)Ynu+λI)xu=YnuCupu(Y^\top Y + Y_{n_u}^\top (C_{n_u}^u - I) Y_{n_u} + \lambda I) \mathbf{x}_u = Y_{n_u}^\top C^u \mathbf{p}_u.

Assuming we have enough users that are already in the system, there shouldn't be too many cold-start users.

Remember the replay dataset I was talking about before? Yeah, it came in handy here.

Using the replay dataset, I was able to get approximately 300 thousand replays under 30 thousand users. Not bad for a start.

Additionally, because f=64f=64 is small, this linear system is solved in under 2 milliseconds, providing an instant user embedding.


3. Generating Recommendations with L2 Cosine Matching

Once we have the user vector xu\mathbf{x}_u, we score all candidate beatmaps in the library. To prevent maps with larger vector magnitudes from dominating recommendations, we compute L2-normalized Cosine Similarity [16]:

scoreui=xuxu2(yiyi2)\text{score}_{ui} = \frac{\mathbf{x}_u}{\|\mathbf{x}_u\|_2} \cdot \left( \frac{\mathbf{y}_i}{\|\mathbf{y}_i\|_2} \right)^\top

We then apply business filters:

  • Exclude Played Maps: Remove any map hashes already in the player's replay history.
  • Comfort Star Rating Filter: Optionally filter out recommendations whose Star Rating (SR) deviates too far from the player's average comfort level.

It should be noted that the star rating filter seems to be redundant at the moment, as the model seems to be doing a good job at recommending maps that are within the player's comfort range already! Of course, if I get feedback that that's not the case, I can simply adjust the sr_tolerance parameter in the frontend to filter out those recommendations.


4. Vectorized "Influential Play" Attribution

One cool feature on the website is showing which of the player's past plays influenced each recommendation.

Instead of iterating through every target map one-by-one, we compute this in a single vectorized batch matrix multiplication in 5ms\approx 5\text{ms}:

  • We multiply the matrix of target recommendation vectors against the transpose of the user's played map vectors (S=TP\mathbf{S} = \mathbf{T} \cdot \mathbf{P}^\top).
  • We weight the similarity by the player's mastery score on each played map.
  • For each recommendation, we return the top 3 most structurally similar played maps to render on the recommendation card!

3. System Design & Architecture

Congratulations! You now know how the models work under the hood.

Now, let's talk about the system that runs these models in production. Building a real-time machine learning web app for an active gaming community comes with distinct engineering challenges—especially when dealing with external API limits, heavy matrix computations, and asynchronous data ingestion.

Here is how I designed the backend system to handle these challenges cleanly.


3.1 Modular Object Abstractions

To keep the codebase maintainable and decoupled, I separated the system into distinct single-responsibility managers:

  • DatabaseManager: Abstracts all SQLite and PostgreSQL database connections, user profiles, map metadata, replay storage, and Redis caching layers.

  • ReplayIngestor: Handles all replay file parsing, user top-play fetching, mastery score calculations, and asynchronous background worker task dispatching.

  • BeatmapIngestor: Downloads .osu beatmap files, extracts hit object timing/spatial sequences, and passes tensors to our VAE encoder.

  • RecommendationEngine: Encapsulates global iALS model training, on-the-fly user vector updates, L2 cosine matching, and vectorized influential play attribution.


These objects are stacked on top of each other to form the system architecture.

Object Dependencies

FastAPI Server (main.py)REST Controller

Top-level API entry point. Receives HTTP requests and delegates to domain managers.

↓ Calls Primary Orchestrators
⚡ ReplayIngestorIngestion

Fetches user replays, computes mastery scores, and dispatches background tasks.

Relies On:
➔ BeatmapIngestor (for missing maps)
➔ DatabaseManager (for replay storage)
🧠 RecommendationEngineInference

Fits global iALS model, solves user vectors in ~2ms, and matches cosine scores.

Relies On:
➔ DatabaseManager (queries factors & Redis)
↓ Sub-Dependency
🎵 BeatmapIngestorFeature Extractor

Downloads .osu files, extracts hit-object timing/spatial sequences, and generates feature tensors.

Relies On:
➔ MapVAE (VAE encoder model)
➔ DatabaseManager (saves map metadata)
↓ Shared Foundation
💾 DatabaseManager (DatabaseManager.py)Shared Data Infrastructure

Provides database persistence (SQLite/PostgreSQL), user profiles, map embeddings, and Redis caching.


3.2 Asynchronous Job Queue & API Protection

Fetching multiple plays for a user involves multiple HTTP requests to the osu! API, parsing binary .osr replay files, computing hit object statistics, and running model inference.

If we executed all of this synchronously inside a FastAPI endpoint request, the HTTP thread would block for several seconds—causing browser timeouts and a terrible user experience. Furthermore, unthrottled requests to osu.ppy.sh would risk exceeding strict API rate limits and getting our application IP banned.

(To be honest, I did get rate limited a couple times during initial testing. Sorry peppy.)

To solve both throughput and API rate limiting, I built an asynchronous worker pipeline paired with a multi-tier defense:


  • Non-Blocking Job Enqueueing (Redis Queue): When a player requests recommendations or recalibration, FastAPI enqueues a job via Redis Queue (RQ) and returns a unique job_id in ~2ms. Background workers parse replays and compute vectors out-of-band while the frontend polls /jobs?job_id=....

  • OAuth Token Caching: Client Credentials access tokens are requested once and cached in memory until near-expiration.

  • Database-First Lookup: Player usernames and beatmap IDs check DatabaseManager first, querying external APIs only when records are missing locally.

  • Active Job Deduplication: If a user is already being ingested, duplicate requests check Redis for an active job key (active_job:topreplay:{uid}). Subsequent requests attach to the existing job_id instead of spawning duplicate API fetches.

  • Replay Threshold Guard: If a player already has 25\ge 25 replays cached in our local database, /user/replays immediately serves local data without hitting osu! servers.

  • Redis Payload Caching: Final recommendation payloads are cached in Redis with a 15-minute Time-To-Live (TTL), turning repeat visits into sub-millisecond cache hits. Osu has a rate limit for each individual endpoint, so we need to be careful.

Asynchronous Pipeline & API Defense Waterfall

01. Request
HTTP GET /user
02. Enqueue
FastAPI → RQ (~2ms)
03. Background Work
Worker Replay Ingestion (~3mins)
04. Polled Result
Frontend Status Ready
API Protection Waterfall Checks
1. Redis Payload Cache15m TTL (~1ms)
2. Database-First LookupDB Profile Hit
3. Active Job DeduplicationAttaches Active Job
4. Replay Threshold Guard≥25 Replays
5. External osu! API FetchThrottled Call

3.3 Recommendation Caching & Performance Optimization

Computing recommendations involves matrix operations (S=TP\mathbf{S} = \mathbf{T} \cdot \mathbf{P}^\top), solving user preference vectors (xuR64\mathbf{x}_u \in \mathbb{R}^{64}), and extracting top-3 influential play attributions for every candidate map.

While running vectorized NumPy operations takes under 5ms\approx 5\text{ms} in memory, re-computing these scores on every page navigation or UI filter tweak is completely wasteful. I implemented a multi-tiered caching architecture to maximize efficiency:


Caching Architecture & Optimizations:
  • 15-Minute Redis Payload Caching: The final serialized recommendation JSON payload (map metadata, similarity scores, and play attributions) is cached in Redis under cache:endpoint:recs:{uid}:{mods}:{version} with a 15-minute Time-To-Live (TTL). Repeat visits or page refreshes return sub-millisecond (&lt;1ms) cache hits directly from Redis without touching Python ML code.

  • User Latent Vector Persistence: Once solved via Ridge Regression, a player's latent vector xuR64\mathbf{x}_u \in \mathbb{R}^{64} is stored in Redis under cache:recs:user_vector:{uid}. Subsequent recommendation calls reuse this pre-computed vector instantly unless the user explicitly triggers a "Recalibrate" action.

  • In-Memory C-Contiguous Matrix Layouts: Item latent factor matrices (YRM×64\mathbf{Y} \in \mathbb{R}^{M \times 64}) and beatmap VAE vectors (zR128\mathbf{z} \in \mathbb{R}^{128}) are pre-loaded at server startup into contiguous C-order float32 NumPy arrays (item_factors, item_norms). This enables SIMD-accelerated dot products during L2 cosine matching (item_norms @ user_norm) with zero disk I/O.

Multi-Tier Caching & Performance Flow

Payload Cache
Redis (15m TTL)

Skips model calculation entirely; returns JSON payload quickly.

User Vector Cache
Redis / DB Vector Store

Stores solved vector xu\mathbf{x}_u; reuses user profile without re-solving matrices.

In-Memory Tensors
NumPy C-Contiguous Arrays

Item matrices Y\mathbf{Y} reside in RAM for SIMD cosine dot products (<5ms).


3.4 Containerized Infrastructure & Docker Setup

To ensure seamless deployment, reproducible environments, and single-command local setup, the backend is containerized using Docker and orchestrated via Docker Compose.

Decoupling the application into isolated containers prevents dependency conflicts between machine learning packages (e.g., PyTorch, Implicit, SciPy, NumPy) and the async web layer while allowing scaling of background worker tasks.


Docker Services Architecture:
  • FastAPI Container (web): Runs the Uvicorn ASGI server hosting all API routes. Handles lightweight requests, user vector projections, matrix multiplications, and static asset delivery.

  • Redis Container (redis): Serves as the shared message broker for Redis Queue (RQ) and acts as an in-memory cache for 15-minute recommendation payloads and OAuth tokens.

  • RQ Worker Container (worker): Executes background task workers isolated from the main web process. Consumes jobs from Redis to perform heavy binary .osr replay parsing, VAE map tensor generation, and database updates.

  • Database Container (db): Houses persistent storage for player profiles, map metadata, and trained latent factor matrices (XX and YY).


Docker Compose Container Network

web
FastAPI & Uvicorn

Exposes HTTP port 8000 & serves API

redis
In-Memory Broker

RQ Message Queue & 15m Cache

worker
RQ Worker Process

Parses replays & VAE tensors out-of-band

db
Database Storage

Persists maps, replays & latent vectors

Isolated Bridge Network • Persistent Volume Mounts • Multi-Stage Build



4. Future Works

Most of the stuff that I want to improve right now is design-related.


  • Build a stable CRON job for model retraining
  • Find better metrics to track the accuracy of the recommender
  • The other features that I mentioned in the showcase

But all in all, this is the heaviest recommendation project I've done so far. I'm glad that I got to dive into the nitty gritty details.

I'll keep a section below for updates and bug fixes. There will be a lot of them. I published this project to the web as soon as it was remotely functional, so I'm sure that there are TONS of issues with it right now. However, the goal is to build a fully functional product that can be used by others.

I think this is also the longest blog I've ever written - probably because I yapped so much about the math. Anyways, thanks for reading. I hope to see you again back here soon :D


My friend asking me to bring back my league blog

ts is NEVER coming back, sorry amane (source)



5. Bug Fixes

(08/12 - Present) Coordinates not working

The database frequently returns coordinates (0, 0) for maps. I'm unsure as to whether or not the database is simply missing that data or the backend is not properly retrieving it.


6. References

[1] Kumokoni. osu! skillset analyzer. osu-skillset-analyzer.vercel.app

[2] Kumokoni. osu! User Profile. osu.ppy.sh/users/23777414

[3] Wikipedia contributors. (2026). Variational autoencoder. In Wikipedia, The Free Encyclopedia. en.wikipedia.org/wiki/VAE

[4] Kingma, D. P., & Welling, M. (2013). Auto-encoding variational Bayes. arXiv preprint arXiv:1312.6114. arxiv.org/abs/1312.6114

[5] GeeksforGeeks. (2024). Types of Autoencoders in Deep Learning. geeksforgeeks.org/types-of-autoencoders

[6] Wikipedia contributors. (2026). Bayesian inference. In Wikipedia, The Free Encyclopedia. en.wikipedia.org/wiki/Bayesian_inference

[7] Wikipedia contributors. (2026). Maximum likelihood estimation. In Wikipedia, The Free Encyclopedia. en.wikipedia.org/wiki/MLE

[8] Wikipedia contributors. (2026). Bayes' theorem. In Wikipedia, The Free Encyclopedia. en.wikipedia.org/wiki/Bayes_theorem

[9] Wikipedia contributors. (2026). Variational inference. In Wikipedia, The Free Encyclopedia. en.wikipedia.org/wiki/Variational_inference

[10] Wikipedia contributors. (2026). Kullback–Leibler divergence. In Wikipedia, The Free Encyclopedia. en.wikipedia.org/wiki/KL_divergence

[11] Wikipedia contributors. (2026). Evidence lower bound. In Wikipedia, The Free Encyclopedia. en.wikipedia.org/wiki/ELBO

[12] Wikipedia contributors. (2026). Independent and identically distributed random variables. In Wikipedia, The Free Encyclopedia. en.wikipedia.org/wiki/i.i.d._random_variables

[13] Chen, T., Kornblith, S., Norouzi, M., & Hinton, G. (2020). A simple framework for contrastive learning of visual representations. arXiv preprint arXiv:2002.05709. arxiv.org/abs/2002.05709

[14] KP (2022). o!rdr osu standard replay dump. kaggle.com/datasets/ordr-replay-dump

[15] Hu, Y., Koren, Y., & Volinsky, C. (2008). Collaborative filtering for implicit feedback datasets. In 2008 Eighth IEEE International Conference on Data Mining (pp. 263-272). IEEE.

[16] Wikipedia contributors. (2026). Cosine similarity. In Wikipedia, The Free Encyclopedia. en.wikipedia.org/wiki/Cosine_similarity

[17] Wikipedia contributors. (2026). Ridge regression. In Wikipedia, The Free Encyclopedia. en.wikipedia.org/wiki/Ridge_regression

[18] Wikipedia contributors. (2026). Normal distribution. In Wikipedia, The Free Encyclopedia. en.wikipedia.org/wiki/Normal_distribution

[19] Koren, Y., Bell, R., & Volinsky, C. (2009). Matrix factorization techniques for recommender systems. Computer, 42(8), 30-37. doi.org/10.1109/MC.2009.263

[20] Wikipedia contributors. (2026). Mean squared error. In Wikipedia, The Free Encyclopedia. en.wikipedia.org/wiki/MSE