Ранг, SVD и низкоранговые приближения
Как понять, сколько информации на самом деле хранит матрица, разложить её на «слои» по важности и сжать в десятки раз — математика, на которой стоят LoRA и MLA от DeepSeek.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Вы хотите дообучить Qwen на своих данных. В модели сотни матриц размера порядка , в каждой 16.8 млн чисел. Обновлять их все — дорого: кроме самих весов, нужна память под их градиенты (поправки к весам, модуль 1.2) и вспомогательные числа оптимизатора (модуль 1.4) — по нескольку чисел на каждый вес. LoRALoRA (низкоранговая адаптация)EN low-rank adaptation (LoRA) предлагает другое: не трогать большую матрицу , а добавить к ней произведение двух узких матриц () и (). В них всего чисел — в 256 раз меньше. И это работает почти так же хорошо, как полное дообучение.
DeepSeek в 2024 году применила ту же идею к памяти. Во время генерации модель держит в памяти векторы ключей и значений для всех уже прочитанных токенов — это KV-кэшKV-кэшEN KV cache, и на длинных текстах он занимает гигабайты. Механизм многоголовое латентное вниманиемногоголовое латентное внимание (MLA)EN multi-head latent attention (MLA)Предложено DeepSeek в DeepSeek-V2 (2024). хранит для каждого токена в каждом слое не 32 768 чисел ключей и значений, а сжатый вектор из 512 чисел (плюс 64 числа на позиционную часть ключа). Почему так можно? Потому что у этих матриц низкий ранг — информации в них гораздо меньше, чем чисел. В этом уроке мы научимся эту информацию измерять (ранг), видеть (SVD) и сжимать (низкоранговое приближение).
Ранг: сколько в матрице настоящей информации
Заголовок раздела «Ранг: сколько в матрице настоящей информации»Вспомните проекцию из прошлого урока: матрица сплющивает всю плоскость в линию. На входе два измерения, на выходе одно. Рангранг матрицыEN matrix rankЧисло линейно независимых строк (столбцов). Матрица B·A, где B размера d×r, а A — r×k, имеет ранг не больше r. матрицы — это размерность того, что остаётся на выходе: сколько независимых направлений матрица способна выдать. У проекции ранг 1, у поворота ранг 2 — он ничего не теряет.
Есть эквивалентное определение через строки: ранг — это число линейно независимых строк, то есть таких, которые нельзя получить линейной комбинацией остальных. (Удивительный факт: для столбцов получается то же самое число.)
В вторая строка — это первая, умноженная на 2, третья — первая, умноженная на 3. Новой информации в них нет: ранг равен 1. В третья строка — сумма первых двух, а первые две независимы: ранг 2. Обе матрицы хранят по 9 чисел, но «по-настоящему» в них одно и два направления.
import numpy as np
M1 = np.array([[1, 2, 3], [2, 4, 6], [3, 6, 9]])M2 = np.array([[1, 0, 1], [0, 1, 1], [1, 1, 2]])print(np.linalg.matrix_rank(M1), np.linalg.matrix_rank(M2)) # 1 2Матрицы ранга 1 — внешние произведения
Заголовок раздела «Матрицы ранга 1 — внешние произведения»Самый наглядный пример матрицы ранга 1 знает каждый школьник — это таблица умножения. Её элемент равен , то есть вся таблица — внешнее произведениевнешнее произведениеEN outer productСтолбец на строку — u vᵀ, матрица ранга 1. В китайских текстах «внешним произведением» иногда называют векторное произведение (cross product), смотрите по контексту. столбца на такую же строку: .
Это общее правило: любая матрица ранга 1 — это столбец, умноженный на строку, . Чтобы её хранить, нужно чисел вместо . А матрица ранга — это сумма таких слоёв, и для неё хватает чисел.
Узкое горлышко
Заголовок раздела «Узкое горлышко»Важнейшее свойство для нашего курса: ранг произведения не больше ранга каждого множителя. Пусть имеет форму , а — , причём маленькое. Любой вектор, проходя через , сначала сжимается в -мерное пространство, а потом разворачивается обратно. Через «горлышко» ширины не пролезет больше независимых направлений, поэтому .
flowchart LR X["x<br/>4096 чисел"] --> A["A: 8 × 4096<br/>сжать"] --> Z["z<br/>8 чисел"] --> B["B: 4096 × 8<br/>развернуть"] --> Y["y = BAx<br/>4096 чисел"]
| Матрица | Полная, чисел | Ранг , чисел | Экономия |
|---|---|---|---|
| , | 16 777 216 | 65 536 | в 256 раз |
| , | 16 777 216 | 524 288 | в 32 раза |
| , | 4 194 304 | 40 960 | в 102 раза |
Квадратная матрица полного ранга (ранг равен её размеру: у поворота ранг 2 — полный, а у выше ранг 1 из возможных 3 — неполный) ничего не теряет: по выходу можно восстановить вход. Для этого у неё есть обратная матрица , которая отменяет преобразование: . Матрица неполного ранга часть информации необратимо выбрасывает — как проекция. Но если выбрасывается только несущественное, это и есть сжатие. Осталось научиться находить, что существенно. Для этого есть SVD.
SVD: рентген матрицы
Заголовок раздела «SVD: рентген матрицы»Сингулярное разложениесингулярное разложение (SVD)EN singular value decomposition (SVD)A = UΣVᵀ — любую матрицу можно разложить на поворот, растяжение вдоль осей и ещё один поворот. Эквивалентно сумме слоёв ранга 1 σᵢuᵢvᵢᵀ. (Singular Value Decomposition) утверждает: любую матрицу размера — квадратную или нет — можно записать как
- и — ортогональные матрицы: их столбцы единичные и попарно перпендикулярны (ортогональные (перпендикулярные) векторыортогональные (перпендикулярные) векторыEN orthogonal vectorsВекторы, скалярное произведение которых равно нулю. Ортогональная матрица — та, у которой столбцы единичные и попарно ортогональны. из урока 1.1.1). Геометрически это повороты (иногда с отражением), длины они не меняют.
- (заглавная «сигма», здесь это просто имя матрицы, а не знак суммы) — диагональная матрица: все её числа равны нулю, кроме стоящих на главной диагонали, из левого верхнего угла в правый нижний. Например, растягивает ось втрое, а ось сжимает вдвое — как растяжение из прошлого урока. На диагонали стоят неотрицательные числа . Это сингулярные числасингулярное числоEN singular valueКоэффициенты растяжения σ₁ ≥ σ₂ ≥ … ≥ 0 из SVD. Сколько из них ненулевые — таков ранг матрицы. — коэффициенты растяжения.
Читаем справа налево, как в уроке о композиции: повернуть → растянуть вдоль осей → повернуть. Любое линейное преобразование, каким бы запутанным оно ни выглядело, устроено так. Единичная окружность превращается в эллипс, его полуоси равны и .
A = np.array([[2.0, 1.0], [0.0, 1.0]])U, S, Vt = np.linalg.svd(A) # Vt — это уже транспонированная Vprint(S) # [2.28824561 0.87403205] — во сколько раз растягиваются оси# np.diag(S) строит диагональную матрицу Σ из вектора S: [[2.288, 0], [0, 0.874]]print(np.allclose(U @ np.diag(S) @ Vt, A)) # True — разложение точноеSVD как сумма слоёв
Заголовок раздела «SVD как сумма слоёв»Перемножим по столбцам и получим ещё одну, самую полезную форму:
Читаем: — -й столбец , — -я строка , а — их внешнее произведение («столбец на строку» из прошлого урока), то есть матрица ранга 1. Матрица — это сумма слоёв ранга 1 (как таблица умножения), упорядоченных по важности. Первый слой с самым большим несёт главную «тему» матрицы, следующие добавляют всё более мелкие детали. Число ненулевых сингулярных чисел и есть ранг. Проверим на нашей матрице :
layer1 = S[0] * np.outer(U[:, 0], Vt[0]) # σ₁·u₁·v₁ᵀ; np.outer — «столбец на строку»layer2 = S[1] * np.outer(U[:, 1], Vt[1]) # σ₂·u₂·v₂ᵀprint(layer1.round(3)) # [[1.894 1.171] # [0.447 0.276]] — уже похоже на Aprint((layer1 + layer2).round(3)) # [[2. 1.] # [0. 1.]] — два слоя вместе дают A точноПервый слой в одиночку уже близок к в верхней строке, где стоят самые большие числа. Второй слой с меньшим — поправка, в основном к нижней строке.
Две нормы матрицы удобно выражаются через сингулярные числа:
- норма Фробениусанорма ФробениусаEN Frobenius normКорень из суммы квадратов всех элементов матрицы — «L2-норма матрицы, вытянутой в вектор». Равна √(Σ σᵢ²). — корень из суммы квадратов всех элементов, как L2-норма вектора. Она равна . Для нашей : , и — то же самое число;
- спектральная нормаспектральная нормаEN spectral normНаибольшее сингулярное число σ₁ — во сколько раз матрица максимально может растянуть вектор. — во сколько раз матрица максимально растягивает вектор. Она понадобится, когда речь пойдёт об устойчивости обучения.
Квадрат сингулярного числа называют его «энергией»: доля от суммы всех показывает, какую часть матрицы сохраняют первые слоёв. Для нашей : , , вместе 6 (ровно ). Значит, первый слой несёт энергии матрицы, второй — оставшиеся 13%.
Низкоранговое приближение
Заголовок раздела «Низкоранговое приближение»Раз слои упорядочены по важности, напрашивается сжатие: оставить первых слоёв и выбросить остальные:
Это низкоранговое приближениенизкоранговое приближениеEN low-rank approximationЗамена матрицы ближайшей матрицей ранга r. По теореме Эккарта — Янга лучшая получается, если оставить r старших слагаемых SVD.. Теорема Эккарта — Янга (1936) говорит, что лучше сделать нельзя: среди всех матриц ранга именно ближе всего к . Ошибка известна заранее:
Читаем: «ошибка — корень из суммы квадратов отброшенных сингулярных чисел». Проверим на нашей матрице из двух
слоёв. Оставим один слой () — отброшено только , и ошибка .
Удобнее смотреть на относительную ошибку — ту же ошибку, делённую на «размер» самой матрицы :
. Не путайте её с долей энергии: относительная ошибка — это корень из потерянной доли
энергии, . Ту же величину считает функция rel_err в коде ниже.
Значит, всё решает спектр — то, как быстро падают сингулярные числа. Проверим на двух матрицах : «картинке» из 30 размытых пятен и белом шуме.
import numpy as np
def low_rank(A, r): # full_matrices=False — «экономный» режим: U и Vt без лишних столбцов и строк U, S, Vt = np.linalg.svd(A, full_matrices=False) # S уже отсортированы по убыванию # первые r столбцов U, первые r чисел S, первые r строк Vt (срезы из урока 0.3.1) return U[:, :r] @ np.diag(S[:r]) @ Vt[:r] # r старших «слоёв» ранга 1
def rel_err(A, B): return np.linalg.norm(A - B) / np.linalg.norm(A) # норма Фробениуса по умолчанию
# Рисуем «картинку» 100×100. Как именно получаются пятна, для урока неважно:# важно лишь, что картинка гладкая, с плавными переходами, — как настоящая фотография.rng = np.random.default_rng(0)x = np.linspace(-1, 1, 100)X, Y = np.meshgrid(x, x) # координаты всех 100×100 точекimg = np.zeros((100, 100))for _ in range(30): # 30 размытых вытянутых «пятен» под разными углами x0, y0, a = rng.uniform(-1, 1), rng.uniform(-1, 1), rng.uniform(0, np.pi) u = (X - x0) * np.cos(a) + (Y - y0) * np.sin(a) v = -(X - x0) * np.sin(a) + (Y - y0) * np.cos(a) img += np.exp(-(u / 0.4) ** 2 - (v / 0.08) ** 2)noise = rng.standard_normal((100, 100)) # белый шум того же размера
for r in [1, 2, 5, 10, 20, 40]: print(f"r = {r:2d} чисел: {r * 200:4d} из 10000 ошибка картинки: {rel_err(img, low_rank(img, r)):.3f} ошибка шума: {rel_err(noise, low_rank(noise, r)):.3f}")r = 1 чисел: 200 из 10000 ошибка картинки: 0.573 ошибка шума: 0.981r = 2 чисел: 400 из 10000 ошибка картинки: 0.408 ошибка шума: 0.963r = 5 чисел: 1000 из 10000 ошибка картинки: 0.216 ошибка шума: 0.911r = 10 чисел: 2000 из 10000 ошибка картинки: 0.078 ошибка шума: 0.830r = 20 чисел: 4000 из 10000 ошибка картинки: 0.008 ошибка шума: 0.683r = 40 чисел: 8000 из 10000 ошибка картинки: 0.000 ошибка шума: 0.435Картинку при мы храним в 40% чисел с ошибкой меньше 1%, а шум и при теряет 43%. Формально у
картинки ранг 65 (np.linalg.matrix_rank так и скажет), но эффективный ранг около 20: остальные слои — пыль.
Вывод, который стоит запомнить:
Структура сжимается, шум — нет. Если в данных есть закономерности, энергия собирается в немногих сингулярных числах. У случайной матрицы все направления равноправны, и выбросить нечего.
А что с весами нейросетей?
Заголовок раздела «А что с весами нейросетей?»Обученные матрицы весов — не чистый шум, но и не гладкая картинка: их спектр обычно падает, но не до нуля. Гораздо лучше сжимаются изменения весов при дообучении. Авторы LoRA (Hu et al., 2021) предположили, что обновление при адаптации модели к новой задаче имеет низкий «внутренний ранг», и подтвердили это экспериментами: для GPT-3 175B LoRA сократила число обучаемых параметров в 10 000 раз, а потребность в памяти GPU — втрое, а качество осталось на уровне полного дообучения. Отсюда и форма LoRA:
Читаем: новые веса — это старые плюс добавка («дельта» — изменение, урок 1.0.3), а добавка записана как произведение узкой (высотой и шириной всего ) на узкую . Запись значит « намного меньше обоих размеров матрицы». Это ровно «горлышко» из начала урока.
Вот как SVD превращает готовую матрицу в такую пару узких множителей (в PyTorch; о нём — заметка в уроке 1.1.2):
import torch
torch.manual_seed(0)d_out, d_in, r = 1024, 1024, 32# Сначала изготовим «веса» с известным спектром. QR — ещё одно разложение матрицы; здесь оно нужно только# как рецепт получить случайную ортогональную матрицу (случайный «поворот»). Вникать в него не нужно.U, _ = torch.linalg.qr(torch.randn(d_out, d_out)) # случайные ортогональные U и VV, _ = torch.linalg.qr(torch.randn(d_in, d_in))s = 1.0 / torch.arange(1, d_in + 1) ** 1.5 # быстро убывающий спектрW = U @ torch.diag(s) @ V.T # «веса слоя» 1024×1024
U_, S_, Vh_ = torch.linalg.svd(W) # SVD в PyTorchB = U_[:, :r] * S_[:r] # (1024, 32): столбцы uᵢ, умноженные на σᵢA = Vh_[:r] # (32, 1024): строки vᵢᵀerr = torch.linalg.norm(W - B @ A) / torch.linalg.norm(W)print(B.shape, A.shape)print(f"чисел: {W.numel()} → {B.numel() + A.numel()} (в {W.numel() / (B.numel() + A.numel()):.0f} раз меньше)")print(f"относительная ошибка: {err:.4f}")torch.Size([1024, 32]) torch.Size([32, 1024])чисел: 1048576 → 65536 (в 16 раз меньше)относительная ошибка: 0.0198Одна идея — четыре применения
Заголовок раздела «Одна идея — четыре применения»| Где | Что раскладывают | Зачем |
|---|---|---|
| LoRA (урок 6.2.1) | добавку к весам | учить в сотни раз меньше параметров |
| MLA, DeepSeek (урок 5.2.2) | проекции ключей и значений через латент из 512 чисел | хранить в KV-кэше в десятки раз меньше |
| PCA (урок 2.3.2) | матрицу данных: проекция на | сжать признаки, нарисовать эмбеддинги на плоскости |
| Muon (урок 5.4.4) | шаг оптимизатора | выровнять все направления обновления |
Собственные числа — коротко
Заголовок раздела «Собственные числа — коротко»Есть направления, которые матрица не поворачивает, а только растягивает:
Такой называют собственным векторомсобственный векторEN eigenvectorНенулевой вектор v, который матрица только растягивает, не поворачивая — Av = λv., а — собственным числомсобственное числоEN eigenvalueЧисло λ, для которого Av = λv при некотором ненулевом v. Показывает, во сколько раз матрица растягивает «свои» направления.. Пример: для вектор переходит в — растяжение в 3 раза, . Вектор переходит в себя, . А у поворота на 90° вещественных собственных векторов нет вовсе: он поворачивает каждый вектор.
Собственные числа отвечают на вопрос: что будет, если применять матрицу снова и снова? Возьмём :
| 1 | 2 | 5 | 10 | |
|---|---|---|---|---|
Вектор быстро разворачивается вдоль собственного направления и растёт как . Отсюда проблема глубоких сетей и рекуррентных сетей (рекуррентная нейросетьрекуррентная нейросетьEN recurrent neural network (RNN), они обрабатывают текст по одному токену, пропуская сигнал через одну и ту же матрицу на каждом шаге): если сигнал много раз проходит через матрицы, направления с взрываются (), а с затухают (). Эту проблему вы встретите в уроках 3.4.1 и 3.4.3.
Связь с SVD (можно пропустить при первом чтении): сингулярные числа — это корни из собственных чисел матрицы . Для симметричной матрицы (она не меняется при транспонировании) с положительными собственными числами, как наша , SVD и разложение по собственным векторам совпадают: . На собственных векторах матрицы ковариации — таблицы, которая показывает, как признаки данных меняются вместе, — построен метод PCA (урок 2.3.2).
Практика
Заголовок раздела «Практика»Реализуйте низкоранговое приближение через SVD, относительную ошибку, выбор ранга по доле энергии и коэффициент сжатия. Тесты проверят, что ваша ошибка совпадает с предсказанием теоремы Эккарта — Янга.
import numpy as np
def low_rank(A, r):
"""Лучшее приближение матрицы A матрицей ранга r.
Возьмите SVD: U, S, Vt = np.linalg.svd(A, full_matrices=False)
и оставьте r старших слагаемых: U[:, :r] · diag(S[:r]) · Vt[:r].
"""
# TODO: ваш код
...
def relative_error(A, B):
"""Относительная ошибка ‖A − B‖ / ‖A‖ в норме Фробениуса (np.linalg.norm)."""
# TODO: ваш код
...
def rank_for_energy(S, energy=0.9):
"""Минимальный ранг r, при котором доля «энергии» sum(S[:r]**2) / sum(S**2) ≥ energy.
S — сингулярные числа по убыванию (как их возвращает np.linalg.svd).
"""
# TODO: ваш код
...
def compression_ratio(m, n, r):
"""Во сколько раз меньше чисел нужно хранить: m·n против r·(m + n)."""
# TODO: ваш код
...
# Проверьте себя кнопкой «Запустить»:
rng = np.random.default_rng(0)
A = rng.standard_normal((60, 5)) @ rng.standard_normal((5, 40)) # матрица 60×40 ранга 5
A += 0.01 * rng.standard_normal((60, 40)) # плюс немного шума
for r in [1, 2, 5, 10]:
print(r, relative_error(A, low_rank(A, r)))
print(rank_for_energy(np.linalg.svd(A, compute_uv=False), 0.99)) # 5 или меньше
print(compression_ratio(4096, 4096, 8)) # 256.0
Задание для Jupyter (по желанию). Загрузите любую фотографию: img = plt.imread("photo.jpg").mean(axis=2) даст
чёрно-белую матрицу. Сожмите её с и покажите результаты через plt.imshow(..., cmap="gray"). С какого
вы перестаёте замечать разницу? Сколько процентов чисел при этом хранится? Постройте график сингулярных чисел
фотографии в логарифмическом масштабе (plt.semilogy).
Проверь себя
Заголовок раздела «Проверь себя»- Ранг — число независимых направлений в матрице. Матрица ранга — сумма внешних произведений и хранится в числах.
- Произведение через узкое горлышко имеет ранг не больше — на этом стоят LoRA и MLA.
- SVD — поворот, растяжение, поворот; или сумма слоёв по убыванию важности.
- Лучшее приближение ранга — первые слоёв SVD, ошибка . Структура сжимается, шум — нет.
- Собственные векторы матрица только растягивает; при многократном применении взрывается, затухает.
Дальше: модуль 1.2 — производные. Узнаем, как измерить, насколько сильно loss реагирует на каждый параметр модели.