В этой статье с нуля соберём рекомендательную систему в.NET на данных MovieLens — от самой идеи до работающей модели. Разберём, как устроен Funk SVD, откуда берутся скрытые векторы, как модель обучается и как из предсказаний получить реальные рекомендации.
Постараюсь не перегружать статью лишней теорией, но важные моменты разберём подробнее.
Главная цель: угадать оценку, которой ещё нет
Представим обычную таблицу оценок: строки — пользователи, столбцы — фильмы, а в ячейках стоят оценки от 1 до 5.
Титаник | Рэмбо | Матрица | Ноттинг‑Хилл | |
Аня | 5 | 1 |
| 5 |
Борис | 1 | 5 | 4 |
|
Вика |
| 4 | 5 |
|
Гена | 4 |
|
| 4 |
Большинство ячеек здесь пустые, потому что никто не смотрит все фильмы подряд. Наша задача — предсказать, какую оценку пользователь поставил бы фильму, который ещё не оценивал.
Если модель предполагает, что Борис поставит «Ноттинг‑Хиллу» 2, рекомендовать фильм не стоит. Если предсказывает 5 — фильм можно показать ему среди рекомендаций.
Основная идея SVD в том, что за оценками стоят скрытые факторы (latent factors) — например сколько в фильме экшна и сколько романтики. У каждого фильма есть выраженность этих факторов, а у каждого пользователя — вкус по тем же факторам. Если описать и фильм, и пользователя в одних координатах, их можно сравнить:
оценка(пользователь, фильм) ≈ вкус_пользователя • характеристики_фильма
Это скалярное произведение двух коротких векторов. На нём держится всё.
Возьмём два фактора и запишем их в виде векторов:
Титаник = [1, 5] Рэмбо = [5, 0] Аня = [1, 5] Борис = [5, 1]
У «Титаника» небольшое значение по экшну и большое по романтике. У «Рэмбо» — наоборот. У Ани предпочтения ближе к «Титанику», у Бориса — к «Рэмбо».
Сравнить два таких вектора можно обычным скалярным произведением:
Аня · Титаник = 1×1 + 5×5 = 26 Аня · Рэмбо = 1×5 + 5×0 = 5
В первом случае значение больше, потому что предпочтения Ани лучше совпадают с характеристиками «Титаника».
Именно это скалярное произведение используется дальше в модели для получения оценки.
26 — это не оценка фильма. Мы сами задали векторы [1, 5] и [1, 5], поэтому получили 26. Реальные оценки находятся в диапазоне от 1 до 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; }
Почти пустая таблица оценок
В реальности у нас нет ни векторов вкусов, ни характеристик фильмов. Есть только история оценок. И она обычно очень разреженная, пользователь оценил лишь небольшую часть всех доступных фильмов, поэтому большая часть матрицы остаётся пустой.
Как хранить дырки? Есть три подхода к хранению дырок в матрице:
Первый — использовать 0 как маркер отсутствующей оценки, но тогда невозможно отличить ситуацию «пользователь не смотрел фильм» от настоящей оценки 0.
Второй — хранить всю матрицу с
null, в больших каталогах заполненной оказывается лишь небольшая доля матрицы, а более 99% ячеек могут оставаться пустыми, в таком случае 99%+ памяти может уходить наnull.Третий — хранить только список известных оценок: в этом случае отсутствие записи означает, что пользователь не оценивал фильм.
Дырка ≠ ноль. Если пользователь не оценивал фильм, это не означает, что он поставил ему 0. Это просто неизвестное значение, которое мы как раз и хотим предсказать. Поэтому на практике хранят только известные оценки, а отсутствие записи означает, что оценки пока нет.
Почему нельзя вписать в пустые ячейки нули: тогда модель решит, что все непросмотренные фильмы пользователь терпеть не может (оценка 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]
Здесь я немного упростил объяснение. Я назвал первый фактор экшеном, а второй романтикой, чтобы было проще представить, что происходит. Но алгоритм этих названий не знает.
Для него это просто два набора чисел, которые помогают предсказывать оценки. SVD может получить, например:
фактор 1 = смесь экшена + фантастики, фактор 2 = смесь романтики + драмы
А может получиться и что‑то, что человеку вообще сложно интерпретировать:
factor 1 = непонятная комбинация жанров factor 2 = другая непонятная комбинация
И это нормально.
Алгоритму не важно, сможем ли мы дать этим факторам понятные названия. Главное:
DotProduct(user, film)
должен хорошо предсказывать оценку.
Факторов при этом может быть не два, а, например, 50. Но пока для простоты будем работать только с двумя, чтобы всё можно было представить на плоскости
Если свести всё к одному правилу:
Мы не размечаем фильмы признаками. Мы даём алгоритму оценки пользователей и просим его самому подобрать скрытые векторы пользователей и фильмов так, чтобы их скалярное произведение воспроизводило эти оценки.
Алгоритм Funk SVD
Выше мы уже увидели, что должно получиться: у каждого пользователя и фильма появляются свои векторы. Теперь разберём сам процесс обучения.
В начале каждому пользователю и каждому фильму задаём случайный вектор из k чисел. Для пользователя это p, для фильма — q. Значение k задаём сами — это количество скрытых факторов.
Дальше берём одну из известных оценок. Для неё считаем Dot(p, q) и сравниваем полученное значение с реальной оценкой пользователя. Полученная разница используется при следующем обновлении p и q.
Векторы немного изменяются в зависимости от ошибки. Если предсказание сильно отличается от оценки, изменение будет больше. Если модель почти угадала, векторы изменятся меньше. Такие обновления выполняются для всех известных оценок, а затем весь набор проходится ещё раз. Один такой проход называется эпохой.
Формула обновления для каждого фактора f:
oldP = p[f] -СНАЧАЛА сохраняем p[f] += lr • error • q[f] q[f] += lr • error • oldP
Здесь lr — learning rate, то есть размер шага, например 0.01.
p — вектор пользователя. Он описывает его предпочтения по скрытым факторам. q — вектор фильма с выраженностью тех же факторов. f — индекс фактора: 0, 1, …, k-1.
Перед изменением p[f] сохраняем его значение в oldP. Оно понадобится при обновлении q[f], поскольку для этого нужен исходный компонент вектора пользователя.

После получения error изменяем p[f] и q[f]. p[f] сдвигаем в сторону q[f], а q[f] — в сторону сохранённого oldP. Величина изменения зависит от error.
Модель хранит глобальное среднее, векторы пользователей и фильмов и их bias:
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; } }
Predict складывает Mu, bias пользователя, bias фильма и скалярное произведение векторов.
Дальше предсказание сравнивается с фактической оценкой. Разность между ними — error. По нему корректируются параметры модели, после чего берётся следующая оценка.
Для оценки модели используем RMSE (среднеквадратичная ошибка):
где N — количество оценок.
В начале обучения векторы случайные, поэтому предсказания далеки от фактических оценок. По мере корректировки параметров RMSE должна уменьшаться.
На маленьком количестве данных RMSE может довольно быстро уйти почти в ноль. Это не обязательно означает, что модель хорошо работает. Она может просто подстроиться под оценки, на которых обучалась. Для пустых ячеек от этого пользы нет, и предсказания на новых данных могут оказаться плохими.
Представим студента, который готовился только по конкретным задачам из задачника. Их он решает без ошибок, но с новой формулировкой уже возникают проблемы.
Если ошибка на train почти нулевая, а на новых данных остаётся высокой, модель переобучилась.

Функция ошибки: что именно минимизируем
Модель минимизирует сумму квадратов ошибок:
r — реальная оценка из данных;
r̂ — предсказание модели;
r − r̂ — ошибка на одной оценке.
Например:
| реальная r | предсказание r̂ | ошибка r−r̂ | квадрат (r−r̂)² |
Аня — Титаник | 5 | 4,2 | +0,8 | 0,64 |
Аня — Рэмбо | 1 | 2,5 | −1,5 | 2,25 |
Для всех известных оценок рассчитывается (r − r̂)², после чего значения складываются. Полученная сумма используется как функция ошибки модели.
Квадрат ошибки нужен по двум причинам.
Первая — знак ошибки не учитывается:
(+0,8)² = (−0,8)² = 0,64
Положительные и отрицательные ошибки не компенсируют друг друга.
Вторая — крупные ошибки получают больший вес:
1² = 12² = 4
Поэтому ошибка 2 влияет на функцию ошибки в четыре раза сильнее, чем ошибка 1.
Теперь становится понятно, откуда в формуле обновления появился error. Мы отдельно выписали функцию ошибки Σ(r − r̂)², а затем берём её производную по каждому параметру. В результате в производной появляется множитель error, поэтому он и присутствует в формулах обновления p и q.
Чем больше ошибка, тем сильнее нужно изменить параметры. Если модель почти угадала, изменение получается небольшим.
Полная функция ошибки (с регуляризацией)
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, возникает вопрос: почему бы сразу не начать с предсказаний около 3,5, а не около нуля?
Использовать для этого скалярное произведение p·q не стоит. Оно должно описывать взаимодействие пользователя и фильма, а не общий уровень оценок. Чтобы p·q давало примерно 3,5 для всех пар, векторы пришлось бы специально согласовывать между собой. При случайной инициализации этого не произойдёт.
Общий уровень лучше задать отдельно. Тогда p·q можно оставить для той задачи, для которой оно и используется — описания различий между конкретными пользователями и фильмами.
Предсказание раскладывается на несколько составляющих:
r̂ = μ + b_user + b_film + p•q └──── базовый уровень ────┘ └─ взаимодействие ─┘
μ — глобальное среднее всех оценок. Это базовый уровень, общий для всей выборки.
b_user — отклонение конкретного пользователя от этого уровня. Если пользователь обычно ставит оценки выше среднего, его b_user будет положительным. Если ниже — отрицательным.
b_film — отклонение фильма от общего уровня. Фильм, который в среднем нравится пользователям больше, будет иметь положительный b_film.
p·q — взаимодействие пользователя и фильма. Здесь уже учитывается, насколько скрытые предпочтения пользователя совпадают со скрытыми характеристиками фильма.
Теперь p·q моделирует только отклонение от базового уровня, а среднее отклонение — это ноль. Поэтому старт векторов около нуля становится правильным. А μ обычно инициализируют средней оценкой — и модель с первого шага предсказывает всем ~3,5, а дальше учит отклонения. Именно так работает настоящий SVD в рекомендательных системах.
На живом примере. Аня обожает романтику и не выносит экшн, но боевик «Матрицу» ещё не смотрела. Что предскажет модель? Базовый уровень уже высокий: среднее μ плюс добавка за то, что «Матрицу» вообще‑то любят почти все — выходит около 4. И только член p·q обязан «вспомнить», что Аня‑то экшн не переносит, и утянуть оценку вниз. Биасы отвечают за «в среднем по больнице», а p·q — за «а вот конкретно этому человеку».
Регуляризация
Без регуляризации модель может слишком сильно подстроиться под оценки из train. Для этого ей достаточно увеличивать значения векторов и тем самым уменьшать ошибку на известных оценках. На обучающих данных результат становится лучше, но на новых оценках — нет. Это и есть переобучение.
Почему штраф приводит к −λ · p в обновлении? Потому что производная λ · p² по p равна 2λ · 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]); }
Проверка модели на отложенных данных
RMSE на обучающих данных не показывает, насколько хорошо модель работает на новых оценках: эти данные она уже видела во время обучения. При первом обучении RMSE на train упал почти до нуля. Но модель обучалась на этих же оценках, и такой результат показывает только ошибку на знакомых ей данных. Для оценки новых данных нужен отдельный набор.
Делим оценки на две части:
известные оценки ├──► TRAIN (~80%) - на ней УЧИМСЯ └──► TEST (~20%) - её ПРЯЧЕМ, на ней ПРОВЕРЯЕМ
В обучении используется только train. Test оставляем для последующей проверки. Если train RMSE заметно меньше test RMSE, модель переобучилась.
Реализация в.NET
Eсли случайно забрать в 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();
При расчёте μ и bias используем только train.
Иногда пользователь или фильм остаётся только в test. В train для него нет данных, а значит нет и обученных параметров. Такие случаи пропускаем при расчёте RMSE.
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); }
Результат на десяти оценках
Модель | Train RMSE | Test RMSE |
без регуляризации (λ=0) | ~0 | 3,26 |
с регуляризацией (λ=0.1) | 0,09 | 2,25 |
Без регуляризации train RMSE падает почти до нуля, но test RMSE остаётся высоким — модель сильно переобучается. Регуляризация улучшает test (3,26 — 2,25). При десяти оценках на пользователя информации слишком мало
При большом числе параметров и небольшом количестве данных модель может хорошо подогнать обучающие оценки, но ошибка на новых данных при этом останется высокой.
На наборе из десяти оценок RMSE оказался хуже простого прогноза средним значением.
MovieLens
MovieLens содержит около 100 000 оценок от 610 пользователей для примерно 9700 фильмов.
На этих данных Test RMSE получается около 0,85. Для сравнения, у прогноза средним значением — около 1,04. Факторизация даёт меньшую ошибку. При увеличении k от 2 до 20–50 результат становится лучше.
От предсказания оценки к ранжированию
Рекомендация = ранжирование непросмотренного:
Кандидаты = фильмы, которые пользователь не смотрел.
Скор каждого = model.Predict(user, movie).
Отсортировать по убыванию, взять топ‑N.
В топ-10 вышли «Casablanca», «Seven Samurai», «Rear Window» и другие классические фильмы и драмы — список выглядит вполне закономерно для этого пользователя.
Popularity bias: В топе много популярных классических фильмов. Причина в том, что bias фильма влияет на прогноз для всех пользователей, а высоко оценённые популярные фильмы получают преимущество.
Код: топ‑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 здесь недостаточно, она оценивает ошибку предсказания, а нам важно качество самого Top-K.
Метрика Precision@K
RMSE оценивает точность предсказанных оценок. Для рекомендаций важнее то, какие фильмы оказались в первых позициях. Precision@K показывает, сколько из первых K рекомендаций действительно понравилось пользователю.
Precision@K = |Hits| / K
Три множества:
Recommended (топ‑K) — что порекомендовали;
Relevant — фильмы, которые пользователь оценил высоко в test;
Hits — их пересечение.
Precision@K считают не для одного человека, а усредняют по всем. Для одного пользователя это 0,0 / 0,1 / 0,2… — слишком грубо и случайно, чтобы судить о модели. Среднее по сотням людей даёт устойчивую цифру. Один Top-K показывает результат для конкретного пользователя, а средний Precision@K — результат по всей выборке.
Проводим черту на 4,0:
оценка >= 4.0 - РЕЛЕВАНТНЫЙ (реально понравился) оценка < 4.0 - не релевантный (так себе)
Precision должен показывать, насколько рекомендации действительно хорошие.
Формируем список кандидатов
Из кандидатов убираем только фильмы, которые пользователь уже видел в train. Фильмы из test оставляем. В Top-K должны попадать фильмы, которые пользователь не видел при обучении, но высоко оценил в отложенной части test.
Сравниваем Top‑K с фильмами, которые пользователь высоко оценил в 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++; }
Для 591 пользователя:
Precision@10 = 0,0161
Для случайного выбора вероятность попадания в релевантный фильм:
Precision_random = |Relevant| / |Candidates|
Precision_random = 0,0019
Отношение:
0,0161 / 0,0019 ≈ 8,5
То есть значение метрики модели в 8,5 раза выше случайного выбора
Funk SVD vs классический SVD
Название Funk SVD связано с Simon Funk — псевдонимом Брэндина Уэбба. В 2006 году он показал этот подход на Netflix Prize. По алгоритму это практически то, что мы разобрали выше: латентные векторы и градиентный спуск.
Название SVD здесь связано скорее с формой результата. В классическом SVD матрица раскладывается как A = U·Σ·Vᵀ, а здесь мы тоже получаем два набора латентных факторов. Но способ построения другой: Funk SVD работает непосредственно с известными оценками и не требует заполнять всю матрицу.
Для классического SVD нужна полная матрица. В нашем случае около 98% ячеек пустые. Если заполнить их средним значением, обычный SVD будет работать уже и с этими заполненными ячейками.
В результате средние значения становятся частью данных, по которым считается приближение. Модель тратит часть своей ёмкости на то, чтобы воспроизвести эти искусственно добавленные значения. Funk SVD этого не делает — он обновляет факторы только по тем оценкам, которые действительно есть
Для сравнения используем один и тот же набор test:
Классический SVD RMSE: 0,8741 Funk SVD RMSE (тот же набор): 0,8265
На этих данных Funk SVD оказался точнее классического SVD.
Рассмотрим классический SVD на полной матрице без пропусков.
Для матрицы 4×4 сингулярные числа:
10,63; 8,04; 1,56; 0,06
Ошибка приближения рангом k равна корню из суммы квадратов отброшенных сингулярных чисел.
Для ранга 2:
ошибка = √(1,56² + 0,06²) ≈ 1,56
Сингулярные числа показывают, какая часть структуры матрицы остаётся при каждом значении ранга.
Реализация в .NET
Для классического SVD используется MathNet.Numerics. Метод Svd() возвращает U, S и VT. Из первых k компонент этих матриц восстанавливается усечённое приближение.
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:
Сингулярные числа: 10,63; 8,04; 1,56; 0,06
Ранг 1: ошибка Фробениуса 8,188
Ранг 2: ошибка Фробениуса 1,563
Ранг 3: ошибка Фробениуса 0,06
Сравнение классического SVD и Funk SVD выполнено на одном наборе данных:
610 пользователей;
321 популярных фильмов;
6826 оценок.
Классический SVD: RMSE 0,8741
Funk SVD: RMSE 0,8265
Применение в количественных финансах
Подход из примера с фильмами имеет прямые аналоги в количественных финансах. Честная оценка вне выборки и регуляризация, лежат в основе факторных моделей, риск‑менеджмента и бэктестинга
Скрытые факторы
Вместо пользователей и фильмов можно рассмотреть активы и дни, а вместо оценок положить в матрицу доходность. SVD остаётся тем же самым — меняются только данные. И оказывается, что его компоненты можно интерпретировать как факторы рынка. Первая компонента обычно соответствует движению рынка в целом, следующие могут отражать отраслевые или стилевые различия
В рекомендательной системе аналогичную роль играли скрытые предпочтения пользователей. В финансовых данных факторы описывают другие зависимости, но принцип тот же. На этом построены факторные модели Fama–French, APT и риск-модели Barra: доходность или риск актива связывается с его чувствительностью к нескольким факторам
Переобучение (самый ценный урок для бэктестинга)
Низкий RMSE на обучающих данных ещё не означает, что модель хорошо работает на новых данных. Модель может почти идеально подогнать train, но при этом плохо предсказывать оценки, которых во время обучения не видела. В бэктестинге происходит то же самое: стратегия может идеально подстроиться под историю и оказаться бесполезной на новых данных. Поэтому обучение проводят на прошлом, а результат проверяют на последующем периоде, не допуская заглядывания в будущее
Не верь красивой цифре на обучении — меряй честно вне выборки. В бэктестинге важно проверять стратегию на данных, которых она не видела при обучении.
И по мелочи всё переносится один в один: градиентный спуск — калибровка моделей и оптимизация портфеля; аномалии/фрод — большая ошибка низкорангового восстановления матрицы транзакций выдаёт подозрительную операцию; рекомендации — подбор продуктов клиенту
Заключение
Вся механика Funk SVD в итоге сводится к одному повторяющемуся циклу. Берём оценку, считаем prediction, сравниваем его с реальной оценкой и немного поправляем векторы пользователя и фильма. Потом берём следующую оценку и повторяем то же самое. Несколько таких проходов позволяют модели постепенно подстроить параметры.
Все остальное детали реализации. Важно правильно подобрать параметры обучения. Слишком большой learningRate может заставить векторы «скакать» и мешать модели сходиться. Слишком маленький — обучение будет идти очень медленно. То же самое касается количества эпох: если остановиться слишком рано, модель не успеет выучить закономерности, а если обучать слишком долго без контроля, можно получить переобучение.

