Это конспект-статья по итогам построения рекомендательной системы на C# - от идеи до рабочей модели на данных MovieLens. Изложено только главное, но в местах, которые обычно вызывают затруднения, старался разобрать максимально подробно
Главная цель: угадать оценку, которой ещё нет
Есть таблица: строки - пользователи, столбцы - фильмы, в ячейках - оценки (1-5).
Титаник | Рэмбо | Матрица | Ноттинг-Хилл | |
Аня | 5 | 1 | ? | 5 |
Борис | 1 | 5 | 4 | ? |
Вика | ? | 4 | 5 | ? |
Гена | 4 | ? | ? | 4 |
Большинство ячеек пустые - никто не смотрел всё. Задача рекомендательной системы: предсказать значения в пустых ячейках. Предскажем, что Борис поставит "Ноттинг-Хиллу" 2 - не рекомендуем; предскажем 5 - рекомендуем
Ключевая догадка: за оценками стоят скрытые факторы (latent factors) - например "сколько в фильме экшна" и "сколько романтики". У каждого фильма есть выраженность этих факторов, а у каждого пользователя - вкус по тем же факторам. Тогда:
оценка(пользователь, фильм) ≈ вкус_пользователя • характеристики_фильма
Это скалярное произведение двух коротких векторов. На нём держится всё.
Вектор здесь - это просто список чисел, описывающих что-то по нескольким признакам. Возьмём 2 признака: [экшн, романтика]
Титаник = [1, 5] (мало экшна, много романтики) Рэмбо = [5, 0] (много экшна, романтики нет) Аня = [1, 5] (любит романтику) Борис = [5, 1] (любит экшн)
Фильмы и люди описаны в одних координатах - это и есть скрытые факторы.
Скалярное произведение (dot product) = перемножить попарно и сложить:
Аня • Титаник = 1•1 + 5•5 = 26 - большое Аня • Рэмбо = 1•5 + 5•0 = 5 - маленькое
Больше = нравится. Чем выше значение, тем сильнее вкус пользователя совпадает со свойствами фильма

Скалярное произведение велико, когда векторы смотрят в одну сторону - то есть по каждому признаку у человека и у фильма высокие значения одновременно. Аня любит романтику (5), "Титаник" полон романтики (5), произведение большое - рекомендуем

Аня не любит экшн, "Рэмбо" - сплошной экшн - векторы расходятся - маленькое число - не рекомендуем
На самом деле, на этом почти всё, что нужно понять про любую рекомендательную систему. Дальше будут детали: откуда взять эти векторы, как получить их из реальных оценок пользователей и как научить модель подбирать их так, чтобы рекомендации становились всё точнее
В коде это одна функция:
static double DotProduct(double[] a, double[] b) { double result = 0; for (int k = 0; k < a.Length; k++) result += a[k] * b[k]; return result; }
Важный нюанс: числа вроде 26 - это не оценки по шкале 1-5. Мы придумали векторы вручную, поэтому масштаб уехал. В реальной системе всё наоборот: у нас есть оценки 1-5, а алгоритм подбирает векторы так, чтобы их скалярные произведения попадали близко к этим оценкам
Почти пустая таблица оценок
В реальности векторов вкусов у нас нет - есть только история оценок. И она почти пустая: в онлайн кинотеатре пользователь оценил сотые доли процента фильмов, матрица почти вся пустая (на 99%+ состоит из дырок).
Как хранить дырки? Есть три подхода к хранению дырок в матрице:
Первый - использовать 0 как маркер отсутствующей оценки, но тогда невозможно отличить ситуацию "пользователь не смотрел фильм" от настоящей оценки 0.
Второй - хранить всю матрицу с
null, однако в таком случае 99%+ памяти может уходить наnull.Третий - хранить только список известных оценок: в этом случае отсутствие записи означает, что пользователь не оценивал фильм.
Правильный вывод: дырка ≠ ноль. Дырка - это то, что мы предсказываем. Настоящие системы хранят только известные оценки списком
Почему нельзя вписать в пустые ячейки нули: тогда модель решит, что все непросмотренные фильмы пользователь терпеть не может (оценка 0). А он их просто не видел! "Не смотрел" и "ненавидит" - совершенно разные вещи, и путать их нельзя. Дырка - это вопрос, а не ответ "0".
record Rating(int UserId, int MovieId, double Score);
Если пользователь не оценивал фильм, мы просто не создаём для него запись. Поэтому "неизвестная оценка" означает отсутствие записи в списке. Разреженность рассчитывается как доля отсутствующих оценок:
(все ячейки − известные оценки) / все ячейки
При этом null вообще не нужен
Как модель сама подбирает векторы
Вручную задавать "экшн=5" для миллиона фильмов невозможно. Главная идея SVD-рекомендаций: найти векторы автоматически - так, чтобы их скалярные произведения совпадали с реальными оценками.
Проблема в том, что никто не знает в системе что такое "экшн" и "романтика" и откуда вообще берутся эти [1, 5]. Мы не знаем, что:
Титаник = [экшн=1, романтика=5]
и не хотим это задавать руками.
Более того, у нас вообще может не быть факторов с понятными названиями: [экшн, романтика, …]
SVD говорит:
Дайте мне только оценки пользователей фильмам. Я сам найду такие скрытые характеристики, которые помогут объяснить эти оценки
То есть вместо:
Титаник = [1, 5]
мы сначала можем получить что-нибудь вроде:
Титаник = [0.73, 0.41]
А потом обнаружить, что первый фактор примерно связан с экшеном, а второй - с романтикой.
Но алгоритм изначально понятия не имеет, что это "экшен" и "романтика".
Допустим, у нас такие оценки (это всё, что модель видит):
Фильм | Борис | Аня |
Матрица | 5 | 2 |
Терминатор | 5 | 1 |
Титаник | 2 | 5 |
Модель замечает:
Хм. Есть какая-то характеристика фильмов, которая хорошо объясняет, почему Борис ставит высокие оценки одним фильмам, а Аня - другим
Она создаёт скрытое измерение
Например, по первому скрытому фактору значения могли бы выйти такими:
Кто / что | factor 1 |
Борис | 0.9 |
Аня | 0.1 |
Матрица | 0.9 |
Терминатор | 1.0 |
Титаник | 0.1 |
И получается:
DotProduct(Борис, Матрица) ≈ большое число DotProduct (Аня, Матрица) ≈ маленькое число
А второй фактор может обнаружить романтику
Если другой паттерн оценок говорит:
Фильм | Оценка Ани |
Титаник | 5 |
Ноттинг-Хилл | 5 |
Терминатор | 1 |
то возникает второе скрытое измерение:
Кто / что | factor 2 |
Аня | 0.9 |
Борис | 0.1 |
Титаник | 1.0 |
Ноттинг-Хилл | 0.9 |
Терминатор | 0.1 |
И постепенно мы получаем что-то похожее на:
Фильм | фактор 1 | фактор 2 |
Терминатор | 1.0 | 0.1 |
Матрица | 0.9 | 0.2 |
Титаник | 0.1 | 1.0 |
Ноттинг-Хилл | 0.05 | 0.9 |
Хотя никто ни разу не написал:
Титаник = [экшен=1, романтика=5]
Но есть очень важный нюанс. Я немного упростил ситуацию, сказав: фактор 1 = экшен, фактор 2 = романтика. На самом деле не обязательно. Алгоритм вообще не знает, что факторы называются "экшн" и "романтика". Я так назвал их для удобства объяснения. SVD может получить:
фактор 1 = смесь экшена + фантастики, фактор 2 = смесь романтики + драмы
Или вообще:
factor 1 = непонятная комбинация жанров factor 2 = другая непонятная комбинация
Это нормально.
Главное для алгоритма не то, чтобы человек мог назвать фактор.
Главное:
DotProduct(user, film)
должен хорошо предсказывать оценку.
И вообще факторов может быть вообще 50, а не 2. Но пока, в иллюстративных целях, для простоты будем использовать 2 скрытых фактора
В этом и есть главная мысль SVD:
Мы не размечаем фильмы признаками. Мы даём алгоритму оценки пользователей и просим его самому подобрать скрытые векторы пользователей и фильмов так, чтобы их скалярное произведение воспроизводило эти оценки.
Алгоритм (Funk SVD):
Раздаём всем случайные векторы. Каждому пользователю - вектор из k случайных чисел (p), каждому фильму - тоже (q). k - число скрытых факторов, выбираем сами.
Предсказываем и смотрим ошибку. Для известной оценки: предсказание = Dot(p, q), ошибка = реальная − предсказание.
Чуть-чуть подкручиваем векторы, чтобы ошибка уменьшилась (градиентный спуск).
Повторяем по всем известным оценкам много раз (эпох).
Формула обновления для каждого фактора f:
oldP = p[f] -СНАЧАЛА сохраняем p[f] += lr • error • q[f] q[f] += lr • error • oldP
где
lr- learning rate (маленький шаг, например 0.01).p- вектор пользователя (от person / preferences). Его вкус по скрытым факторам.q- вектор фильма. Выраженность тех же факторов в фильме.f- индекс фактора (0, 1, …, k−1).

Симметрия обновления: пользователя двигаем в сторону фильма (· q[f]), а фильм - в сторону пользователя (· oldP). Один множитель error на оба - он задаёт силу и знак толчка: большая ошибка - большой шаг, почти угадали - почти не трогаем.
Зачем сохранять oldP? Вторая строка меняет p[f]. Третьей строке нужно исходное значение p[f], а не уже обновлённое - иначе формула будет чуть кривой. Поэтому старое значение прячем в oldP.
Пример хранения модели:
class MfModel { public double Mu; // глобальное среднее оценок public Dictionary<int, double[]> UserVectors = new(); public Dictionary<int, double[]> MovieVectors = new(); public Dictionary<int, double> UserBias = new(); public Dictionary<int, double> MovieBias = new(); public double Predict(int user, int movie) { double dot = 0; if (UserVectors.TryGetValue(user, out var p) && MovieVectors.TryGetValue(movie, out var q)) for (int f = 0; f < p.Length; f++) dot += p[f] * q[f]; return Mu + UserBias.GetValueOrDefault(user) + MovieBias.GetValueOrDefault(movie) + dot; } }
Ключ - id пользователя или фильма, значение его вектор из factorCount чисел. Для незнакомых id (cold start) скалярное произведение просто равно 0, и предсказание сводится к базовому уровню - модель не падает.
Метрика качества - RMSE (среднеквадратичная ошибка):
Насколько в среднем предсказание промахивается мимо реальной оценки. На старте (случайные векторы) RMSE большой; по мере обучения падает.
Что происходит на маленьких данных: RMSE легко уезжает почти в ноль - модель просто запоминает обучающие оценки. Но предсказания в дырках при этом ненадёжны. Это переобучение, и лечить это будем дальше.
Представьте студента-зубрилу: он выучил наизусть ответы к конкретным задачам из задачника и щёлкает их без ошибок. Но стоит спросить чуть иначе - и он плывёт. Модель ведёт себя так же: обучающие оценки она зазубрила почти до нуля ошибки, а в новых, невиданных ячейках предсказывает наугад. Это и есть переобучение.

Осталось два неясных момента: откуда в формуле обновления взялся множитель error и что вообще модель минимизирует
Функция ошибки: что именно мы минимизируем
Это ядро всего, и здесь легко запутаться. Разберём по кусочкам.
Член "точность"
Модель минимизирует сумму квадратов ошибок:
r - реальная оценка (из данных);
r̂ - предсказание модели;
r − r̂ - ошибка на одной оценке: насколько промахнулись.
Пример:
| реальная r | предсказание r̂ | ошибка r−r̂ | квадрат (r−r̂)² |
Аня - Титаник | 5 | 4,2 | +0,8 | 0,64 |
Аня - Рэмбо | 1 | 2,5 | −1,5 | 2,25 |
Σ (сигма) - "сложи по всем известным оценкам". Получается одно число: насколько модель в сумме неправа
почему возводим ошибку в квадрат?
Тут две причины:
Убрать знак. Промах +0,8 и −0,8 одинаково плохи. Без квадрата плюсы и минусы взаимно сокращались бы, и крупная ошибка спряталась бы. Квадрат всегда ≥ 0.
Сильнее наказывать крупные промахи. Ошибка 2 даёт квадрат 4, а ошибка 1 - только 1. Один жирный промах болит вчетверо, а не вдвое. Модель в первую очередь убирает грубые ошибки. 🔍 Разбор: связь "точности", RMSE и формулы обновления
Множитель "error" в обновлении пришёл из "точности". Мы нигде явно не писали Σ(r − r̂)², но формула p[f] += lr · error · q[f] - это и есть шаг вниз по этому члену. Производная (r − r̂)² по параметру даёт −2·error·(что-то). Поэтому error в обновлении стоит не случайно - он и есть градиент точности.
Полная функция ошибки (с регуляризацией)
L = Σ (r − r̂)² ← ТОЧНОСТЬ: подгонка под данные + λ • Σ ‖p‖² ┐ + λ • Σ ‖q‖² │ ШТРАФ: не раздувать + λ • Σ (b_user)² │ параметры (регуляризация) + λ • Σ (b_film)² ┘
Два члена тянут в разные стороны: "точность" - подгоняться под данные, "штраф" - держать параметры маленькими
Формулы обновления
Градиентный спуск:
По p[f]: ∂(e²)/∂p[f] = −2•e•q[f] ∂(λ•p[f]²)/∂p[f] = +2•λ•p[f] - p[f] += lr • ( e•q[f] − λ•p[f] ) По q[f]: - q[f] += lr • ( e•p[f] − λ•q[f] ) (p[f] берём старое) По b_user: ∂(e²)/∂b_user = −2•e - b_user += lr • ( e − λ•b_user ) По b_film: - b_film += lr • ( e − λ•b_film )
Двойки спрятаны в lr - поэтому в коде их нет. У каждого обучаемого параметра - свой штрафной хвостик − λ · этот_же_параметр.
Вся математика в коде сводится к следующему:
// ŷ = μ + b_user + b_film + p • q double prediction = globalAverage + userBias + movieBias; for (int f = 0; f < factorsCount; f++) { prediction += p[f] * q[f]; } // e = r - ŷ double error = rating - prediction; // SGD update for (int f = 0; f < factorsCount; f++) { double oldP = p[f]; p[f] += learningRate * (error * q[f] - lambda * p[f]); q[f] += learningRate * (error * oldP - lambda * q[f]); } userBias += learningRate * (error - lambda * userBias); movieBias += learningRate * (error - lambda * movieBias);
В функции ошибки есть штрафной член, который мы пока не объяснили, и странность: векторы стартуют около нуля, а не около среднего. Оба вопроса закрывают биасы и регуляризация.
Средний уровень и защита от зубрёжки
Почему стартуем со случайных векторов около нуля
Векторы p и q мы инициализируем маленькими случайными числами около нуля — и уже оттуда обучение их “расталкивает”. Но почему именно около нуля, а не сразу около средней оценки? Вопрос резонный, разберём.
Почему не целиться сразу в среднюю оценку?
Логичная мысль: раз все ставят в среднем ~3,5, почему бы не стартовать с предсказаний около среднего, а не около нуля?
Целиться векторами в среднее - плохо. Скалярное произведение - неподходящий инструмент для константы: чтобы p·q давало ~3,5 для каждой пары, нужно согласованно подогнать все векторы, а независимыми случайными числами так не выйдет. Плюс большой случайный старт даёт "дёрганые" градиенты.
Заставлять скалярное произведение выдавать одну и ту же среднюю оценку для всех пар - всё равно что забивать гвоздь микроскопом: инструмент не для этой задачи. "Общий уровень" проще закодировать одним числом μ, а p·q оставить для того, что он действительно умеет, - тонких личных предпочтений.
Правильно - добавить среднее отдельным слагаемым. Раскладываем предсказание на "уровень" и "взаимодействие":
r̂ = μ + b_user + b_film + p•q └──── базовый уровень ────┘ └─ взаимодействие ─┘
μ - глобальное среднее всех оценок (одно число, не обучается);
b_user - насколько человек добрее/строже среднего;
b_film - насколько фильм в среднем нравится всем;
p·q - настоящее взаимодействие: любит ли именно этот человек именно этот фильм сверх базового уровня.
Теперь p·q моделирует только отклонение от базового уровня, а среднее отклонение - это ноль. Поэтому старт векторов около нуля становится правильным. А μ обычно инициализируют средней оценкой - и модель с первого шага предсказывает всем ~3,5, а дальше учит отклонения. Именно так работает настоящий SVD в рекомендательных системах.
На живом примере. Аня обожает романтику и не выносит экшн, но боевик "Матрицу" ещё не смотрела. Что предскажет модель? Базовый уровень уже высокий: среднее μ плюс добавка за то, что "Матрицу" вообще-то любят почти все — выходит около 4. И только член p·q обязан "вспомнить", что Аня-то экшн не переносит, и утянуть оценку вниз. Биасы отвечают за "в среднем по больнице", а p·q — за "а вот конкретно этому человеку".
Регуляризация
Проблема: без ограничений векторы разрастаются, чтобы точнее подогнать обучающие данные, - это зубрёжка (переобучение).
Идея: добавить в минимизируемую ошибку штраф за большие параметры (λ · ‖p‖² и т.д.). Теперь держать параметр большим выгодно, только если он реально уменьшает ошибку сильнее, чем платит штраф. Шум гасится, выживает сигнал.
На пальцах: штраф λ·p² при дифференцировании даёт тягу −λ·p - это как пружинка, тянущая каждый параметр к нулю. Отсюда хвостик − λ · параметр в каждом обновлении (см. вывод формул выше).
Что меняется в коде: было - стало
Регуляризация добавляет к каждому обновлению один "хвостик" − λ·параметр — он на каждом шаге чуть тянет число к нулю. Сравни обновление без неё и с ней:
// было — только подгонка под данные: p[f] += lr * error * q[f]; userBias += lr * error; // стало — плюс регуляризация: p[f] += lr * (error * q[f] - lambda * p[f]); userBias += lr * (error - lambda * userBias); // └──────┬──────┘ // пружинка к нулю
λ (лямбда) - сила штрафа. Слишком большая - всё занулится (недообучение); слишком маленькая - зубрёжка. Типично λ ≈ 0.05-0.1.
Что меняется в цифрах: с регуляризацией RMSE на обучении больше не падает в ноль, а стабилизируется выше (например 0,1 вместо 0,01). И это хорошо - значит модель перестала зубрить.
Полное обновление с биасами и регуляризацией (C#):
double error = r.Score - model.Predict(r.UserId, r.MovieId); model.UserBias[r.UserId] += lr * (error - lambda * model.UserBias[r.UserId]); model.MovieBias[r.MovieId] += lr * (error - lambda * model.MovieBias[r.MovieId]); for (int f = 0; f < factorCount; f++) { double oldP = p[f]; p[f] += lr * (error * q[f] - lambda * p[f]); q[f] += lr * (error * oldP - lambda * q[f]); }
Как честно проверить модель: train и test
RMSE на обучающих данных врёт: мерить ошибку там, где учился, - как оценивать студента по задачам, ответы к которым он подсмотрел.
Именно это и случилось раньше: при обучении RMSE упал почти до нуля, и хотелось радоваться — но модель всего лишь выучила наизусть те самые оценки, по которым мы её и проверяли. Настоящее качество так не измеришь.
Решение: спрятать часть известных оценок.
известные оценки ├──► TRAIN (~80%) - на ней УЧИМСЯ └──► TEST (~20%) - её ПРЯЧЕМ, на ней ПРОВЕРЯЕМ
Модель обучается только на train и не видит test.
Test RMSE - честная оценка: насколько модель угадывает то, чего не видела.
Переобучение = разрыв между train RMSE и test RMSE.
Реализация в .NET: как делим данные
Есть тонкость: если случайно забрать в test все оценки какого-то пользователя, в train его не останется — и предсказать для него будет нечем. Поэтому сначала кладём в train по одной оценке от каждого, а потом добираем случайными до 80%:
var random = new Random(42); // фиксированный seed — воспроизводимость // по одной оценке от каждого пользователя — чтобы никого не "осиротить" var trainSet = ratings.GroupBy(r => r.UserId) .Select(g => g.OrderBy(_ => random.Next()).First()).ToList(); int target = (int)(ratings.Count * 0.8); var chosen = trainSet.ToHashSet(); trainSet.AddRange(ratings.Where(r => !chosen.Contains(r)) .OrderBy(_ => random.Next()).Take(target - trainSet.Count)); var testSet = ratings.Where(r => !trainSet.Contains(r)).ToList();
Золотое правило - никаких утечек (no leakage): test должен быть полностью невидим на обучении, включая μ и биасы. μ считаем как среднее по train, а не по всем оценкам.
Cold start: если у пользователя или фильма нет ни одной оценки в train, предсказать его нечем. Такие оценки в test пропускаем (и считаем, сколько пропустили). Это реальная проблема любой рекомендашки для новых пользователей/товаров.
RMSE на test считаем той же функцией, что и на train, но пропускаем оценки, чьих пользователя или фильма модель не видела — иначе Predict не на что опереться (а на реальных данных такое будет постоянно):
static double Rmse(List<MlRating> ratings, MfModel model) { double sumSq = 0; int used = 0; foreach (var r in ratings) { if (!model.UserVectors.ContainsKey(r.UserId) || !model.MovieVectors.ContainsKey(r.MovieId)) continue; // cold start — пропускаем double e = r.Score - model.Predict(r.UserId, r.MovieId); sumSq += e * e; used++; } return used == 0 ? 0 : Math.Sqrt(sumSq / used); }
Что показывают цифры (на игрушечных 10 оценках):
Модель | Train RMSE | Test RMSE |
без регуляризации (λ=0) | ~0 | 3,26 |
с регуляризацией (λ=0.1) | 0,09 | 2,25 |
Без регуляризации train падает в ноль (зубрёжка), а test огромный - переобучение видно глазами. Регуляризация улучшает test (3,26 - 2,25). Вывод: данные решают - на 10 оценках модель фундаментально ограничена, и лишь на реальных данных всё заиграет.
Почему на десяти оценках всё так плохо? Это как провести кривую через три точки уравнением с двадцатью коэффициентами: сквозь сами точки пройдёшь идеально, но между ними кривая уйдёт куда угодно. Параметров больше, чем данных, — модель запоминает, а не обобщает. Спасает либо больше данных, либо более жёсткая регуляризация.
На десятке оценок модель проигрывает даже тривиальному "ставь всем среднее": данных слишком мало. Пора выйти на реальный масштаб.
Реальные данные и живые рекомендации
На MovieLens (~100 000 оценок, 610 пользователей, ~9700 фильмов) сигнала достаточно: латентные факторы находят реальную структуру вкусов. Test RMSE выходит ~0,85 против baseline "предсказывай среднее" ~1,04 - модель впервые реально побеждает. И k растёт с 2 до 20-50.
От предсказания оценки к ранжированию
Рекомендация - это отсортированный список, а не одно число. Рекомендация = ранжирование непросмотренного:
Кандидаты = фильмы, которые пользователь НЕ смотрел.
Скор каждого = model.Predict(user, movie).
Отсортировать по УБЫВАНИЮ, взять топ-N.
Пример топ-10 для реального пользователя получился связным: "Casablanca", "Seven Samurai", "Rear Window" - классика/драмы. Модель уловила вкус и подобрала похожее.
Popularity bias: топ часто смещён в сторону "всеми любимой классики" - у таких фильмов огромный b_film, и он тянет их наверх у многих. Рекомендации качественные, но местами "общечеловеческие", а не глубоко личные. Это нормальное свойство MF, обученной на RMSE.
Код: топ-N по предсказанию среди непросмотренного:
var seen = ratings.Where(r => r.UserId == targetUser).Select(r => r.MovieId).ToHashSet(); foreach (var film in ratings.GroupBy(p => p.MovieId) .Where(p => !seen.Contains(p.Key) && p.Count() >= 20).Select(r => r.Key)) { films.Add((film, model.Predict(targetUser, film))); } var top = films.OrderByDescending(p => p.Value).Select(p => p.Film).Take(topN);
Результат для пользователя targetUser:
Lawrence of Arabia (1962)
Streetcar Named Desire, A (1951)
Philadelphia Story, The (1940)
Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb (1964)
There Will Be Blood (2007)
Rear Window (1954)
Sunset Blvd. (a.k.a. Sunset Boulevard) (1950)
Cool Hand Luke (1967)
Seven Samurai (Shichinin no samurai) (1954)
Casablanca (1942)
Качество на отложенных данных:
Train RMSE 0,7685 Test RMSE 0,8497 Пропущено в test (cold start): 831
Список готов — но насколько он хорош? RMSE меряет точность оценки, а не качество верхушки списка. Нужна отдельная метрика самих рекомендаций.
Хорош ли наш топ: метрика Precision@K
RMSE меряет точечную ошибку, а рекомендации - это ранжирование, и важен верх списка. Precision@K отвечает: из K рекомендаций сколько пользователю реально зашли?
Precision@K = |Hits| / K
Три множества:
Recommended (топ-K) - что порекомендовали;
Relevant - фильмы, которые пользователь оценил высоко в test;
Hits - их пересечение.
Precision@K считают не для одного человека, а усредняют по всем. Для одного пользователя это 0,0 / 0,1 / 0,2… - слишком грубо и случайно, чтобы судить о модели. Среднее по сотням людей даёт устойчивую цифру. Показ топа для одного зрителя - это наглядная витрина, а метрика - про всю аудиторию сразу.
Precision считает попадания, а "попадание" - это да/нет. Значит оценки (0,5-5,0) надо превратить в бинарную метку релевантный / нет. Проводим черту на 4,0:
оценка >= 4.0 - РЕЛЕВАНТНЫЙ (реально понравился) оценка < 4.0 - не релевантный (так себе)
Зачем: Precision меряет долю хороших рекомендаций. Если считать релевантным любой просмотренный фильм, то рекомендация фильма с оценкой 2 засчиталась бы как успех - а это провал. Поэтому берём только настоящую симпатию (4-5 звёзд). Тройка - "нормально, но не то", не в счёт. Само число 4,0 - соглашение; можно 3,5 (мягче) или 4,5 (строже).
критический момент - что исключать из кандидатов
Из кандидатов убираем только фильмы, которые пользователь уже видел в train. Фильмы из test оставляем.
Зачем? Потому что test содержит правильные ответы, которые модель не видела во время обучения. После обучения мы просим модель составить рекомендации из оставшихся фильмов и проверяем, попали ли скрытые релевантные фильмы в Top-K.
Например, если пользователь высоко оценил "Матрицу", но эта оценка попала в test, модель не знает об этом. Если после обучения "Матрица" попадает в первые 10 рекомендаций, это считается попаданием.
Если убрать test из кандидатов, модель никогда не сможет порекомендовать эти фильмы, и проверять будет нечего.
Код: средний Precision@K по всем пользователям:
foreach (var (userId, relevant) in relevantMoviesByUser) { var alreadySeen = seenMoviesByUser.GetValueOrDefault(userId, new()); var candidates = model.MovieVectors.Keys.Where(m => !alreadySeen.Contains(m)).ToList(); var topK = candidates.OrderByDescending(m => model.Predict(userId, m)).Take(topN); int hits = topK.Count(m => relevant.Contains(m)); sumPrecision += (double)hits / topN; sumRandom += (double)relevant.Count / candidates.Count; evaluated++; }
Результат:
Precision@10: 0,0161 (по 591 юзерам) Случайный baseline: 0,0019 (модель ~8,5x лучше случая)
Сравнение со случайным выбором
Для случайного baseline вероятность попасть в релевантный фильм примерно равна:
|Relevant| / |Candidates|
В нашем случае:
Precision_random ≈ 0,0019
Модель получила:
Precision@10 ≈ 0,0161
То есть:
0,0161 / 0,0019 ≈ 8,5
Получается, модель попадает в релевантные фильмы примерно в 8,5 раза чаще случайного выбора.
Настоящий SVD — и почему наш работает лучше
Funk SVD названо по имени человека - Simon Funk (псевдоним Брэндина Уэбба). В 2006 году на конкурсе Netflix Prize ($1 млн за улучшение рекомендаций на 10%) он открыто опубликовал свой метод - ровно ту факторизацию градиентным спуском, что мы построили.
Он назвал его "SVD" по аналогии: результат по форме такой же, как у классического SVD (две матрицы латентных факторов). Но это не настоящий SVD - классический требует полную матрицу и раскладывает её точно через линейную алгебру, а метод Функа обучается на разреженных данных, игнорируя дырки. Уточнение "Funk SVD" и появилось, чтобы отличать одно от другого.
Классический SVD раскладывает матрицу A = U·Σ·Vᵀ. Truncated SVD ранга k (оставили top-k сингулярных чисел) даёт лучшее приближение матрицы рангом k.
Почему он не годится для рекомендаций: классическому SVD нужна полная матрица. У нас 98% пусто - дырки надо выдумать (импутация средним). И вот расплата:
SVD минимизирует ошибку восстановления по всем ячейкам, включая выдуманные. Он тратит ёмкость на подгонку под фейковые "средние". Funk SVD учится только по наблюдаемым оценкам - поэтому выигрывает.
Демонстрация в цифрах (на одном и том же поднаборе test - иначе сравнение нечестное):
Классический SVD RMSE: 0,8741 Funk SVD RMSE (тот же набор): 0,8265
На этих данных Funk SVD оказался точнее классического SVD.
Мы убедились, что на разреженных оценках классический SVD проигрывает. Но сам по себе SVD — мощный инструмент. Чтобы почувствовать, что делают сингулярные числа и что значит "усечь ранг", отвлечёмся на чистый случай — полную матрицу без дырок.
Бонус - теорема Эккарта-Янга вживую. На маленькой матрице 4×4 ошибка приближения рангом k равна корню из суммы квадратов отброшенных сингулярных чисел:
сингулярные числа: 10,63, 8,04, 1,56, 0,06 ранг 2: ошибка = √(1,56² + 0,06²) = 1,56 ← ровно отброшенные
Сингулярные числа буквально говорят, сколько "структуры" теряешь, обрезая ранг.
Реализация в .NET:
Настоящий SVD в .NET не пишут руками — ставят пакет одной командой: dotnet add package MathNet.Numerics. Дальше M.Svd() возвращает U, S (сингулярные числа) и Vᵀ, из которых и собирают усечённое приближение (см. код ниже).
Код: восстановление из усечённого SVD (MathNet.Numerics):
static Matrix<double> Reconstruct(Svd<double> svd, int rank, int rows, int cols) { var uk = svd.U.SubMatrix(0, rows, 0, rank); // m×k var sk = Matrix<double>.Build.DiagonalOfDiagonalVector(svd.S.SubVector(0, rank)); // k×k var vk = svd.VT.SubMatrix(0, rank, 0, cols); // k×n return uk * sk * vk; // Â = m×n }
Результат — разминка 4×4 и сравнение на MovieLens:
--- G1: SVD полной матрицы 4×4 --- Сингулярные числа: 10,63, 8,04, 1,56, 0,06 ранг 1: ошибка (Frobenius) 8,188 ранг 2: ошибка (Frobenius) 1,563 ранг 3: ошибка (Frobenius) 0,06 --- G2: классический SVD vs Funk SVD --- Плотная матрица: 610 юзеров × 321 популярных фильмов Классический SVD RMSE: 0,8741 Funk SVD RMSE (тот же набор): 0,8265 (по 6826 оценкам)
Где это может пригодится в количественных финансах?
Всё, что мы построили ради фильмов это, по сути, базовый инструментарий количественных финансов. Те же три кита — SVD/факторы, честная оценка вне выборки и регуляризация, лежат в основе факторных моделей, риск-менеджмента и бэктестинга
Скрытые факторы
Возьмём ту же матрицу, но заменим содержимое: строки не пользователи, а активы; столбцы не фильмы, а дни; в ячейках не оценки, а доходности. Раскладываем её тем же SVD и сингулярные компоненты оказываются экономическими факторами: первая обычно ловит "рынок в целом" (все акции движутся вместе), следующие - секторные и стилевые
Это ровно то, что модель делала с жанрами: сама находит скрытые драйверы. На этом стоят факторные модели (Fama–French, APT, риск-модели Barra) — "доходность актива ≈ её чувствительность к нескольким скрытым факторам", буквально наша оценка ≈ p · q
Переобучение (самый ценный урок для бэктестинга)
Помните главную боль: RMSE на обучающих данных врёт, модель зубрит и проваливается на невиданном? В финансах это называется переобучением бэктеста - стратегия шикарно выглядит на истории и теряет деньги вживую. В финансах те-же принципы: обучил на прошлом, проверил на "будущем" и не подглядывать в будущее при обучении (look-ahead bias)
Навык "не верь красивой цифре на обучении — меряй честно вне выборки" тут важнее любой формулы.
И по мелочи всё переносится один-в-один: градиентный спуск - калибровка моделей и оптимизация портфеля; аномалии/фрод - большая ошибка низкорангового восстановления матрицы транзакций выдаёт подозрительную операцию; рекомендации - подбор продуктов клиенту
Заключение
На этом основная идея Funk SVD закончена. Мы берём известную оценку, считаем prediction, находим ошибку и с помощью градиентного спуска немного изменяем векторы пользователя и фильма. Повторяем этот процесс для всех оценок и затем снова проходим по ним - так постепенно модель учится.
Дальше детали реализации. Важно правильно подобрать параметры обучения. Слишком большой learningRate может заставить векторы "скакать" и мешать модели сходиться. Слишком маленький - обучение будет идти очень медленно. То же самое касается количества эпох: если остановиться слишком рано, модель не успеет выучить закономерности, а если обучать слишком долго без контроля, можно получить переобучение.

