﻿# new video, familiar author.

Finding an audience for videos with no history: our VK LSVD solution, an author bridge, and a shared limit of 100.

Author: Vlad Kostoglodov. Language: en.
Archive date: 2026-01-13. Current version prepared: 2026-10-14.
Date basis: Date of my original solution notes; revisited in October 2026.
Status: ready.
[Profile and technical work JSON](https://vk0st.com/facts.json): public profile, concrete work and source references.
[Human-readable article](https://vk0st.com/writing/competition-rabbit-holes/).

Find **100 users for each completely new video**. No viewing history for the video. Each user can appear at most **100 times across the whole submission**.

That was VK RecSys Challenge LSVD 2025. Our team, Elves do RePlay, finished [third, with nDCG@100 of 0.37836](https://ods.ai/competitions/vkrecsyschallengelsvd/leaderboard/private). Our slides with Grigory Vyaznikov, [on PPT Online](https://en.ppt-online.org/1702804) and [on Gamma](https://gamma.app/docs/Copy-of-VK-RecSys-Challenge-LSVD-o2bhe9xvykwobno), compress the result into **0.33 → 0.38**.

The new video had no viewers to learn from, but its author might. That gave us a way to find candidates. The shared limit then made the last step unusual: videos had to compete for users, even after we had scored their audiences. The code archive keeps several versions of this pipeline, including the experimental branches below.

## Turn the author scores sideways

We replaced video IDs in historical interactions with author IDs. Viewers of older uploads could now supply evidence for a new upload by the same author.

![Several new-video triangles connect through one author orbit to a shared audience with partly occupied capacity rings.](https://vk0st.com/media/articles/competition-rabbit-holes-v2.webp)

The author supplies a bridge to the audience; videos share its remaining capacity.

The SANSA inference path builds a sparse user–author matrix, giving each interaction a weekly decay weight:

```text
X[u, a] = sum(exp(-0.2 * (latest_week - event_week)))
```

Repeated interactions accumulate, with recent ones contributing more. The training configuration uses `l2=250` and a target weight-matrix density of `5e-5`.

Retrieval runs down a **column** of the score matrix: we need users for an author. SANSA stores the author relationships in two factors, `W1` and `W2`. One experimental branch reconstructs the target authors' columns and takes their absolute values before multiplying by user history:

```text
B_batch = abs(W1 @ W2[:, target_authors])
R_batch = X @ B_batch
```

Negative author-to-author links become positive contributions. This differs from taking `abs(R_batch)` after scoring, when contributions have already added or cancelled. In a toy sum, `+2` and `-3` give `abs(2 - 3) = 1` after scoring, but `2 + 3 = 5` if each link becomes positive first. The branch uses the magnitude of a negative relationship as evidence. It changes the scoring rule, unlike an implementation optimization that preserves weights and scores.

The script processes batches of 1,000 authors and retains up to **10,000 users per author**. It reconstructs only those authors' columns, multiplies by the history, and converts the result to CSC for column access. `argpartition` selects the top users before the selected subset is sorted. This avoids reconstructing the whole author-to-author matrix or fully sorting every column's user scores.

## Carry an audience to the new videos

SANSA's output was one candidate source. Others used interaction counts over recent slices, including one and five weeks. The slice called “last day” uses the final seventh of a week's rows.

VSTAN also retrieves in reverse. Its notebook queries with a virtual session containing just the author and returns matching user sessions. The base similarity is `1 / sqrt(number of distinct authors in the retained user history)`, favouring more concentrated histories among the matches before optional reweighting.

We merged per-source shortlists, removed duplicate user–author pairs, and padded thin author pools with active users. Joining those pairs to the author's target videos produced user–video candidates.

Suppose an author has two new uploads. This join carries the same candidate audience to both. They can receive different rankings once video features enter the pipeline, but they will still draw from a shared pool of users during allocation.

## An author's audience and a user's history

The author shortlist gives us people to score. Features then describe both the audience they belong to and their own relationship with the author.

The repeat-consumption idea was inspired by Amazon's [Buy It Again: Modeling Repeat Purchase Recommendations](https://www.amazon.science/publications/buy-it-again-modeling-repeat-purchase-recommendations). Our RCP feature describes the **author's audience as a whole**. Let `n(u,a)` count non-disliked positive interaction records:

```text
RCP[a] = count(users with n(u,a) >= 2)
         / count(users with n(u,a) >= 1)
```

RCP is shared by every candidate user for that author. The numerator counts users with at least two positive interaction records, which need not involve distinct videos. It measures a historical repeat fraction, rather than a calibrated probability of watching the next upload.

Coverage varies by **user–author pair**: it divides the number of distinct videos the user engaged with by the author's distinct videos in the historical sample. This separates a user who engaged with much of the author's work from one who touched only one video. Time spent relative to video duration, likes, and recency add detail to that pair's history. An author-level repeat fraction cannot make this distinction on its own.

Several features have both a value and a rank within the video's candidate list. For example, a hypothetical candidate with ten likes has an absolute count; being first among this video's candidates adds a relative position. The ranker sees both.

## Give video embeddings an address

Our two hypothetical uploads share an author, but each has its own video embedding. We used those embeddings to connect a new upload to a user's history of similar videos.

A FAISS residual quantizer with **three levels and 256 codewords per level** gives each video an address such as `(cluster0, cluster1, cluster2)`. Schematically, the codewords add up to an approximation of the embedding:

```text
embedding ≈ codebook0[cluster0]
          + codebook1[cluster1]
          + codebook2[cluster2]
```

Each level encodes the residual left by earlier levels. The tuple becomes a set of discrete keys for joining the new video to historical interaction aggregates, without requiring viewing history for the video itself.

We group interactions by `(user, cluster0)` and by `(user, cluster0, cluster1)`, collecting time spent, distinct videos, active weeks and recency. The short prefix pools a broader history; the longer one narrows it. A broad group may have enough observations for a useful statistic where the more specific group is sparse. The ranker gets both resolutions.

The CatBoost scripts treat `cluster0` and `cluster1` as categorical features. Their numerical order does not represent distance between videos.

The quantizer helper points to [MiniOneRec's FAISS RQ implementation](https://github.com/AkaliKong/MiniOneRec/blob/main/rq/rqkmeans_faiss.py). The feature path in the notebook writes raw RQ codes; optional Sinkhorn balancing belongs to a separate helper path.

## Let models vote through their ranks

We used LightGBM rankers and CatBoost ranker/classifier paths to score the candidates. The CatBoost scripts retain positives and sample ten negatives per positive. The archived feature-importance output also includes `sasrec_scores` and `sasrec_author_score_rank`, although the SASRec training code lives outside this archive.

For blending, each model ranks users within each video. Ranks let a classifier and a ranker vote on the same scale without assuming their raw outputs are comparable. One set of experiments sums weighted `1 / log2(rank + 1)` contributions. Later versions use:

```text
vote(rank) = exp(-0.4 * (rank - 1))
blend = sum(model_votes)
```

The first three positions contribute about `1`, `0.67`, and `0.45`. The exponential recipe puts more emphasis on the head than the logarithmic one: agreement among a model's favourite users carries most of its influence.

These later versions re-rank pairs from an existing submission, changing the order within an already selected audience. The archive records different blends without isolating their individual score gains.

## Spend the shared user budget

Our hypothetical author's two uploads can now have different rankings. Neither can independently take its favourite 100 users: a user's slots may already have gone to the other upload or to videos from other authors.

The allocation code keeps up to **400 candidates per video**, sorts pairs globally by blended score, and walks them greedily:

```text
for video, user in pairs_sorted_by_score:
    if video_count[video] == 100: continue
    if user_count[user] == 100: continue
    if (video, user) already assigned: continue
    assign(video, user)
    increment both counts
```

Every accepted pair spends one slot on each side. The pool of 400 gives a video alternatives when a preferred user is full, but early assignments can block later ones. The greedy pass depends on score order and does not guarantee a global optimum. Even two videos with identical audience rankings could end up with different assigned users because their decisions share this budget.

Separate routines fill short lists and completely missing videos from active users with capacity left. They cycle through the fallback pool and give added pairs very low scores, keeping them below model-selected pairs. A completely absent video needs its own pass: it has no group in the current recommendation table for a group-length check to find. Checks cover list lengths, duplicate pairs and the user cap.

Later, on January 24, I flew to Moscow overnight to talk about the solution. Two hours of sleep, a talk, and then back to Vnukovo for the return flight. Meeting other recsys people was lovely. So was hearing that some of them read my channel — possibly for the silly jokes.
