'The PP Recommender': Autoencoded Osu! Beatmap Recommendations
Project Link: pp.bryanchan.org
(Some) source code: github.com/brrryry/pp
(I really need to add more visuals for my math stuff...)
Table of Contents
2. The Models: Math & Implementation
- ↳ 2.1 Data Processing
- ↳ 2.2 Variational Autoencoder (VAE) Math
- ↳ 2.3 VAE Model Architecture
- ↳ 2.4 Recommendation Model (Theory)
- ↳ 2.5 Recommendation Model (Implementation)
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.

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 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

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 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.

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.

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 and 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.
- - change in x position
- - change in y position
- - 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.)

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 , we create the encoder function which maps to a compressed vector ().
(The notation means that we have a function that depends on parameters . In this case, represents the weights and biases of the neural network.)
We use the decoder function to reconstruct an estimation of the original vector .
Our goal is to learn parameters and such that . In the event of continuous output space (like our map features), we can define our reconstruction loss using Mean Squared Error [20] (a typical deterministic loss function for regression tasks):
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 , we can calculate the sample mean as:
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 , what's the probability of getting our sample data? We want to choose a 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 . We will call our estimate (mu-hat).
We look at the likelihood of observing our data given the parameter (the chance that we get this data if the mean is ).
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.
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.
Now, how do we maximize ? 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 , we get:
And by setting it to 0, we solve for our estimator :
Therefore, our best estimation for the population mean is , 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 ), and using new evidence (data ) to update our beliefs (the posterior probability ).
We begin with Bayes' Theorem [8] - a way to calculate posterior probabilities.
Bayes' Theorem is defined as:
Where:
- : Hypothesis - A statement or proposition about the world
- : Data - The observed evidence
- : Posterior Probability - The probability of the hypothesis being true given the data
- : Likelihood - The probability of the observed data given the hypothesis
- : Prior Probability - The probability of the hypothesis being true before observing the data
- : 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 of our observed data (for example, finding the MLE of - a CONSTANT).
In the Bayesian framework, however, we treat the hypothesis (i.e. ) itself as a random variable. We assign it a prior probability distribution (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 (we do not say that IS 0.5 - this is the difference).
We then update our beliefs by multiplying the prior by the likelihood to get the posterior probability distribution using Bayes' Theorem.
Here's an interesting way to think about it: MLE is basically Bayes' rule with a uniform prior distribution ( = 1 for all H).
4. Inferring Posterior Distributions (Primers Over!)
We apply the Bayesian framework to infer the posterior probability distribution :
Where:
- : Posterior Probability - The probability of the latent variable given the observed data
- : Likelihood - The probability of the observed data given the latent variable
- : Prior Probability - The probability of the latent variable before observing the data
- : Evidence - The probability of the observed data
At the same time, we want to infer the posterior distribution to find the latent representation for any given data point .
(TLDR: we want to maximize the likelihood of our vector while finding a compressed form that captures the most important features in .)
To apply this to our autoencoder, we parameterize the generative model (the decoder) with parameters . Thus, our model's likelihood is and the joint distribution is (assuming a fixed prior ).
As mentioned earlier, the denominator, , is the model evidence. It represents the probability of generating our observed data under all possible latent configurations:
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 to compute the integral.
If our 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 directly, but computing that gradient requires taking an expectation over the true posterior —which is the very thing we are trying to infer in the first place (catch 22)!
Because we cannot calculate , we cannot compute the true model posterior directly using Bayes' Theorem.
How do we find (our compressed vector) AND (our decoder parameters) then??
5. Variational Inference [9]: Approximating the Unknowable
Since we can't calculate the true model posterior , 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 (called the variational posterior or the recognition model), which is parameterized by weights . In practice, this distribution is parameterized by our encoder network.
We want to make as close as possible to the true model posterior . 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 .
The KL divergence is always greater than or equal to 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 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 (which is equivalent to 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 () and decoder (), we can use the following formula:
The ELBO is a lower bound on the model evidence (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:
Now, using Bayes' Theorem, we can write as:
Substituting this back into the log ratio:
Now, let's plug this back into the KL divergence formula (expressing it as an expectation):
Since the expectation is with respect to , and does not depend on (it is a constant relative to ), we can pull out of the expectation:
Let's rearrange this equation to solve for the log-evidence :
Because the KL divergence is always non-negative (), the expectation term on the left acts as a lower bound on the log-evidence. We call this the Evidence Lower Bound, or ELBO:
The core relationship between our true log-likelihood and the ELBO is:
This is a beautiful mathematical trick. By maximizing the ELBO:
- We maximize the log-likelihood of our model generating real data .
- We implicitly minimize the KL divergence , 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 that maximize the likelihood of the training data:
This is exactly Maximum Likelihood Estimation! Because we cannot optimize directly due to the intractable integration over , 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 and , we are performing approximate MLE on our neural network parameters.
7. ELBO Loss Analysis
Let's expand the joint probability inside the ELBO definition to see how it maps to an autoencoder structure:
When training a neural network, we usually minimize a loss function, so we define the VAE loss as the negative ELBO:
Let's dissect these two distinct terms:
Reconstruction Term:
This term measures how well the decoder () reconstructs the original input from a latent vector sampled from the encoder (). Under a Gaussian assumption for continuous inputs, this expectation is equivalent to Mean Squared Error (MSE) - the distance formula.KL Regularization Term:
This term measures how much our approximate posterior deviates from the prior distribution . By choosing a simple standard normal prior, , 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 , and into the encoder.
But is sampled stochastically from our simplified posterior: .
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 () affects the sample .
To solve this, Kingma and Welling introduced the Reparameterization Trick.
Instead of sampling directly from , we sample a noise variable from a standard normal distribution:
Then, we calculate (our compressed vector) using a deterministic, differentiable formula:
Where represents element-wise multiplication. By shifting the randomness to (which doesn't depend on the encoder's parameters), the pathway from the encoder's outputs ( and ) to 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 to samples.
For a positive pair of normalized latent projections , the InfoNCE Loss is defined as:
Where:
- Cosine Similarity: measures the directional alignment between vectors in hyperspace. It is a very common function to use in machine learning.
- Temperature (): 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 augmented maps in the batch, treating them as negative pairs. Very contrastive.
By incorporating InfoNCE loss into our total VAE loss:
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:
- (Spatial Delta X): normalized by playfield width ().
- (Spatial Delta Y): normalized by playfield height ().
- (Temporal Delta): Time gap capped at and scaled ().
- is_circle: Binary indicator ( for hit circle, otherwise).
- is_slider: Binary indicator ( for slider, otherwise).
- is_spinner: Binary indicator ( for spinner, otherwise).
- slider_velocity: Pixels per millisecond normalized ().
- slider_linearity: Ratio of Euclidean start-to-end distance over path length ().
- is_bezier: Binary flag ( if slider uses Bézier curve control points).
- circle_size: Normalized Circle Size ().
- approach_rate: Normalized Approach Rate ().
- hp_drain_rate: Normalized HP Drain ().
- overall_difficulty: Normalized Overall Difficulty ().
All of these features are min-max scaled to 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 .
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 32 channels, kernel 5, stride 2, padding 2
- Conv Block 2: 32 64 channels, kernel 5, stride 2, padding 2
- Conv Block 3: 64 128 channels, kernel 5, stride 2, padding 2
- Conv Block 4: 128 256 channels, kernel 5, stride 2, padding 2
- Conv Block 5: 256 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 () and log-variance () vectors for a 128-dimensional latent space:
Reparameterization Trick
Now, we use that trick from the math part. Using the predicted and , the latent vector is sampled as a continuous function of the input via standard normal noise :
Decoder
The decoder maps back to shape (batch_size, 13, 2000) using a linear projection layer followed by 5 upsampling blocks:
- Linear projection: tensor
- Upsample Block 1: Linear interpolation to size 125 Conv1d(256 256)
- Upsample Block 2: Linear interpolation to size 250 Conv1d(256 128)
- Upsample Block 3: Linear interpolation to size 500 Conv1d(128 64)
- Upsample Block 4: Linear interpolation to size 1000 Conv1d(64 32)
- Upsample Block 5: Linear interpolation to size 2000 Conv1d(32 13)
The output of the decoder is our reconstruction , which we compare to the original input 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:
Where:
- Reconstruction Loss (): Mean Squared Error between the original beatmap feature matrix and the decoded matrix .
- KL Divergence (): Penalizes posterior deviation from the prior , multiplied by weighting factor .
- Contrastive Loss (): Measures similarity between augmented view pairs 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:
| Parameter | Optimized Value | Description |
|---|---|---|
| Embedding Size | 128 | Latent representation dimension () |
| Learning Rate | 1.52e-4 | Adam optimizer learning rate |
| Dropout Rate | 0.2036 | Conv block regularization dropout probability |
| Weight Decay | 1.63e-5 | L2 regularization coefficient |
| KL Weight () | 1.3929 | ELBO KL divergence loss scaling multiplier |
| Contrastive Weight () | 0.0263 | InfoNCE contrastive term scaling multiplier |
| Temperature () | 0.1790 | InfoNCE cosine similarity soft-max scaling |
| Final Epochs | 21 | Training iterations until convergence |
| Best Val Loss | 0.0571 | Evaluated 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 (), 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 . 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 (): Indicates whether user has played beatmap .
Confidence (): Measures how confident we are in that preference based on interaction magnitude (e.g., mastery score or play weight). Unobserved pairs () are assigned with a baseline confidence of . A higher mastery score increases , 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:
This implicit data is very valuable!
2. The iALS Objective Loss Function & User Vector Projection
We map each user to a vector and each beatmap to a vector (with ).
Originally, the matrices and are random. The steps below are used to optimize these matrices.
- Keep fixed, optimize for via our loss function.
- Keep fixed, optimize for via our loss function.
- Repeat until convergence.
Unlike explicit factorization, we optimize the cost function over all user-item pairs (including all unobserved pairs):
Where 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 , how do we actually find the optimal matrices and ?
In matrix factorization [19], we are multiplying two unknown matrix variables together (). This makes the overall loss function non-convex with respect to both and 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 , the objective function becomes a simple weighted linear regression for every user vector (which is convex and has an exact analytical solution!).
- Similarly, if you freeze the user matrix , solving for every beatmap vector also becomes a convex linear regression problem!
This is why it's called Alternating Least Squares (ALS): we alternate back and forth—holding fixed to solve for in closed form, then holding fixed to solve for in closed form—until the loss stabilizes.
Now while the closed-form formula above looks simple, there is a performance issue:
is a diagonal matrix of size , where is the total number of beatmaps in the dataset (tens or hundreds of thousands of maps!).
Trying to compute for every user on every iteration requires operations. That's...really slow if you have millions of users.
Here's the trick:
Notice that . We can rewrite as:
Now THIS is a game changer. Why?
(Global Baseline): Does not depend on a user. We can precompute once per iteration for all beatmaps in time and never touch it again.
(Sparse User Correction): For user , is non-zero only for the maps the user has actually played ( maps). Since a typical player has only played a small number of maps (a few hundred out of 100k+, ), we only multiply over those played maps!
Substituting this back into the user update formula gives our fast iALS update:
This reduces the time complexity per user from down to . While still sounds slow, is only 64. Compared to , 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 .
- We train our global
AlternatingLeastSquaresmodel with latent factors over iterations (with early stopping).
After fitting, the learned beatmap factor matrix 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 using only the maps the user played.
- We solve the linear system .
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 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 , 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]:
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 :
- We multiply the matrix of target recommendation vectors against the transpose of the user's played map vectors ().
- 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
.osubeatmap 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
Top-level API entry point. Receives HTTP requests and delegates to domain managers.
Fetches user replays, computes mastery scores, and dispatches background tasks.
Fits global iALS model, solves user vectors in ~2ms, and matches cosine scores.
Downloads .osu files, extracts hit-object timing/spatial sequences, and generates feature tensors.
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_idin ~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
DatabaseManagerfirst, 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 existingjob_idinstead of spawning duplicate API fetches.Replay Threshold Guard: If a player already has replays cached in our local database,
/user/replaysimmediately 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
3.3 Recommendation Caching & Performance Optimization
Computing recommendations involves matrix operations (), solving user preference vectors (), and extracting top-3 influential play attributions for every candidate map.
While running vectorized NumPy operations takes under 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 (<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 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 () and beatmap VAE vectors () are pre-loaded at server startup into contiguous C-order
float32NumPy 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
Skips model calculation entirely; returns JSON payload quickly.
Stores solved vector ; reuses user profile without re-solving matrices.
Item matrices 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.osrreplay parsing, VAE map tensor generation, and database updates.Database Container (
db): Houses persistent storage for player profiles, map metadata, and trained latent factor matrices ( and ).
Docker Compose Container Network
Exposes HTTP port 8000 & serves API
RQ Message Queue & 15m Cache
Parses replays & VAE tensors out-of-band
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

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