
Сейчас в открытом доступе мало крупных датасетов сервисов коротких видео, но это уникальный формат для рекомендательных алгоритмов. В отличие от музыки или длинных видео они не могут потребляться в фоновом режиме, а каждый показанный ролик получает от пользователя реакцию. Даже если он не оставит лайк, досмотр видео до конца или пропуск уже считаются обратной связью. Именно поэтому мы выложили в открытый доступ датасет VK-LSVD. С его помощью инженеры и ученые смогут развивать и совершенствовать рекомендательные алгоритмы.
Что именно внутри: сигналы, контекст показа и контентные эмбеддинги
VK-LSVD — это полгода активности в сервисе коротких роликов (январь–июнь 2025): свыше 40 млрд событий, ~10 млн анонимных пользователей и ~19,6 млн роликов. Если сложить время просмотра роликов из датасета, получится ~858 млрд секунд, это чуть больше 27 000 лет экранного времени. Датасет содержит ~1,17 млрд отметок «Нравится», ~11,86 млн «Не интересно», ~84,6 млн переходов к авторам, ~481 млн открытий комментариев и ~262,7 млн расшариваний.
Вся эта телеметрия идёт вместе с контекстом: Place (откуда пришли — лента, поиск, профиль и т.д., 24 варианта), Platform (Android, iOS, Web, Smart TV и пр., 11 вариантов) и Agent (конкретный клиент/браузер — VK App, Chrome, Safari и т.д., 29 вариантов). Для сохранения конфиденциальности все данные обезличены.
У каждого клипа есть длительность 5–180 с, анонимный ID автора и 64-мерный контентный эмбеддинг, собранный из визуала, звука и текста. При построении закладывалась возможность понижения размерности эмбедда. Для этого на контентных признаках введен порядок: чем меньше индекс контеного признака, тем он более информативный и менее шумный. Это решение помогает находить баланс между скоростью и точностью.
Сам набор решает несколько задач. Во-первых, он позволяет начинающим специалистам получить опыт работы с реальными большими данными, реализовать и тестировать бэйзлайны. Во-вторых, позволяет систематически сравнивать разные алгоритмы, когда отличия в результатах объясняются не разной разметкой и генерацией обучающей выборки, а архитектурой и фичами. В-третьих, можно учить гибриды «контент + поведение» и проверять, действительно ли контентные признаки помогают в некоторых сценариях.
Как работать с VK-LSVD
Датасет организован по неделям и временным срезам: обучающая часть train/week_00…week_24.parquet, валидация validation/week_25.parquet. Порядок строк внутри файлов хронологический, повторных показов одной и той же пары user–item нет. Для категориальных полей и идентификаторов используются целочисленные значения для обезличивания. Схема полей во взаимодействиях включает user_id, item_id, контекст показа place, platform, agent, отклики like, dislike, share, bookmark, click_on_author, open_comments и timespent в секундах 0–255. Пользовательская мета: возраст 18–70, пол, укрупненный регион и ранг популярности для сэмплинга. По клипам: автор, длительность (секунды), ранг популярности и отдельный файл контентных эмбеддингов item_embeddings.npz с 64-мерными векторами float16, у которых компоненты специально упорядочены.
Скачиваем и читаем:
# pip install polars numpy huggingface_hub pyarrow from huggingface_hub import hf_hub_download import polars as pl import numpy as np from pathlib import Path # берём готовый небольшой срез для экспериментов SUBSAMPLE = "up0.001_ip0.001" # можно заменить на другой из README EMB_DIM = 32 # берём префикс эмбеддинга (1..64) # готовим список файлов этого среза train_files = [f"subsamples/{SUBSAMPLE}/train/week_{i:02}.parquet" for i in range(25)] val_files = [f"subsamples/{SUBSAMPLE}/validation/week_25.parquet"] meta_files = [ "metadata/users_metadata.parquet", "metadata/items_metadata.parquet", "metadata/item_embeddings.npz", ] # скачиваем в локальную папку LOCAL_DIR = Path("VK-LSVD") for file in train_files + val_files + meta_files: hf_hub_download( repo_id="deepvk/VK-LSVD", repo_type="dataset", filename=file, local_dir=str(LOCAL_DIR), ) # читаем train лениво и собираем потоково train = pl.concat([pl.scan_parquet(LOCAL_DIR / f) for f in train_files]).collect(engine="streaming") val = pl.read_parquet(LOCAL_DIR / val_files[0]) # проверим инварианты набора assert train.select(["user_id", "item_id"]).unique().height == train.height, "Повторяющиеся user-item в train" assert val.select(["user_id", "item_id"]).unique().height == val.height, "Повторяющиеся user-item в val" print(train.head()) print(train.schema) # увидите uint32/uint8/bool по описанию
Если нужно идти не через подсэмпл, а через весь датасет, меняем пути на interactions/train/week_XX.parquet и interactions/validation/week_25.parquet.
Подключаем метаданные пользователей и клипов:
users_meta = pl.read_parquet(LOCAL_DIR / "metadata/users_metadata.parquet") items_meta = pl.read_parquet(LOCAL_DIR / "metadata/items_metadata.parquet") # оставляем только тех пользователей/клипы, что встречаются в train train_users = train.select("user_id").unique() train_items = train.select("item_id").unique() users_meta = users_meta.join(train_users, on="user_id", how="inner") items_meta = items_meta.join(train_items, on="item_id", how="inner") print(users_meta.head(), items_meta.head())
Контентные эмбеддинги
Эмбеддинги обучены только на кон��енте, без коллаборативных сигналов. Компоненты упорядочены, т.е можно выбрать первые n координат и ускориться с минимальной потерей качества. Пример чтения контентных эмбеддингов ниже:
npz = np.load(LOCAL_DIR / "metadata/item_embeddings.npz") item_ids = npz["item_id"] # uint32 emb_full = npz["embedding"] # (N, 64) float16 emb = emb_full[:, :EMB_DIM] # используем префикс # синхронизируем порядок с items_meta keep = np.isin(item_ids, items_meta.select("item_id").to_numpy().ravel()) item_ids = item_ids[keep] emb = emb[keep] # добавим эмбеддинг к метаданным клипов items_meta = items_meta.join( pl.DataFrame({"item_id": item_ids, "embedding": list(emb)}), on="item_id", how="inner", ) print(items_meta.select(["item_id", "duration", "embedding"]).head())
Конфигурируемые подмножества
Есть готовые нарезки по пользователям/айтемам и утилита, чтобы собрать свой срез под бюджет и железо. Логика такая: urX — X-доля случайных пользователей, ipX — X-доля самых популярных айтемов по train_interactions_rank, меньше ранг — больше взаимодействий, отрицательное X берёт низ распределения (-0.9 → нижние 90%). Аналогично можно отобрать непопулярных пользователей через их ранг.
def get_sample(entries: pl.LazyFrame | pl.DataFrame, split_column: str, fraction: float) -> pl.LazyFrame: lf = entries.lazy() if isinstance(entries, pl.DataFrame) else entries if fraction >= 0: q = lf.select(pl.col(split_column).quantile(fraction, interpolation="midpoint")).collect().item() return lf.filter(pl.col(split_column) <= q) else: q = lf.select(pl.col(split_column).quantile(1 + fraction, interpolation="midpoint")).collect().item() return lf.filter(pl.col(split_column) >= q) # пример: 1% случайных пользователей + 1% самых популярных айтемов users_lf = pl.scan_parquet(LOCAL_DIR / "metadata/users_metadata.parquet") items_lf = pl.scan_parquet(LOCAL_DIR / "metadata/items_metadata.parquet") users_sample = get_sample(users_lf, "user_id", 0.01).select("user_id") items_sample = get_sample(items_lf, "train_interactions_rank", 0.01).select("item_id") interactions_val = pl.scan_parquet(LOCAL_DIR / "subsamples" / SUBSAMPLE / "validation" / "week_25.parquet") interactions_sample = ( interactions_val .join(users_sample, on="user_id", how="inner", maintain_order=True) .join(items_sample, on="item_id", how="inner", maintain_order=True) ).collect(engine="streaming") print(interactions_sample.head(), interactions_sample.height)
Чтобы собрать дно по популярности на 90% и по пользователям, и по айтемам (up-0.9_ip-0.9), заменяем выборки на get_sample(..., "train_interactions_rank", -0.9) и делаем join по тем же ключам.
Мини-шаблон пайплайна
С учетом глобального временного разреза работаем в хронологическом порядке: любые признаки из train, проверка гипотез на validation. Базовый скелет для воспроизводимых экспериментов выглядит так: загрузить train-недели лениво и собрать потоково; оставить нужную долю пользователей/айтемов; подсоединить мету и префикс эмбеддингов нужной длины; сгенерировать целевую переменную под задачу; зафиксировать параметры и seed; сохранить использованный список недель и размерность эмбеддинга.
# фильтрация train по выбранной подвыборке пользователей/айтемов train_lf = pl.concat([pl.scan_parquet(LOCAL_DIR / f) for f in train_files]) train_small = ( train_lf .join(users_sample, on="user_id", how="inner", maintain_order=True) .join(items_sample, on="item_id", how="inner", maintain_order=True) ).collect(engine="streaming") # цели под разные задачи y_cls = train_small.select(pl.col("like").cast(pl.Int8).alias("label_like")) # бинарная классификация y_reg = train_small.select(pl.col("timespent").cast(pl.Int16).alias("y_timespent")) # регрессия по времени # добавим длительность клипа и контентный префикс train_with_items = train_small.join(items_meta.select(["item_id", "duration", "embedding"]), on="item_id", how="left") print(train_with_items.head()) # сохраняем подготовленный срез на диск OUT = Path("prepared") OUT.mkdir(exist_ok=True) train_with_items.write_parquet(OUT / "train_ready.parquet") val.write_parquet(OUT / "val_raw.parquet") # логи эксперимента config = { "subsample": SUBSAMPLE, "train_weeks": list(range(25)), "val_week": 25, "embedding_prefix": EMB_DIM, "joins": ["users_metadata", "items_metadata", "item_embeddings"], "targets": ["like(binary)", "timespent(regression)"], } print(config)
На этом месте уже есть воспроизводимый вход для любых архитектур: классические табличные модели, двухбашенные с retrieval, ранжирование по implicit-сигналам, всё это подключается поверх train_ready.parquet. Можно использовать для воспроизводимых тестов различных моделей с разными нарезками (urX/ipX/upX), и выбранной размерности эмбеддинга эмбеддинга.
Следующий шаг
Дальше по плану расширение датасета и публичные бенчмарки. Сейчас проходит открытое соревнование на базе VK-LSVD. Если хочется стартовать быстро, берите репозиторий, собирайте бейзлайн и фиксируйте протокол воспроизводимости и обучайте классные модельки.
