Проблема: когда разметка данных стоит как Bentley
Вы собрали терабайты сырых данных. Фотографии, тексты, сигналы. Мечтаете обучить модель. Но есть нюанс - каждый размеченный пример вытягивает из бюджета сотни рублей. А нужно десятки тысяч. Классический supervised learning в 2026 году все еще упирается в эту стену. Вы либо платите фрилансерам до умопомрачения, либо пытаетесь автоматизировать разметку и получаете иллюзию смысла в данных.
Цифра на 17.04.2026: разметка одного изображения для медицинской диагностики стоит от 300 до 5000 рублей в зависимости от сложности. Для промышленного CV проектов бюджет на разметку регулярно превышает миллион.
Полу-обучение (semi-supervised learning) обещает решение. Но большинство методов либо слишком сложны для production, либо требуют тонкой настройки, которая сводит на всю экономию. Тут появляется GMVAE - Gaussian Mixture Variational Autoencoder. Не самый новый, но чертовски эффективный инструмент, который за последние два года получил второе дыхание благодаря улучшениям в стабилизации обучения.
GMVAE: ваш секретный ингредиент
Представьте обычный вариационный автоэнкодер (VAE). Он учится сжимать данные в латентное пространство, где каждая точка - это сжатое представление исходного примера. Теперь добавьте к этому гауссову смесь (Gaussian Mixture). Вместо одного облака точек в латентном пространстве у вас появляется несколько кластеров. Каждый кластер интуитивно стремится представлять отдельный класс данных.
Как GMVAE работает под капотом
Архитектура умнее, чем кажется. Энкодер преобразует входные данные x в параметры распределения в латентном пространстве z. Но здесь появляется скрытая категориальная переменная y, которая указывает, к какому компоненту смеси (читай: классу) относится пример. Декодер пытается восстановить x из z. Магия в функции потерь: она включает не только реконструкцию и KL-дивергенцию для z, но и регуляризацию для распределения по y.
Почему это лучше, чем просто обучить VAE, а потом сделать кластеризацию? Потому что обучение совместное. Кластеризация и реконструкция оптимизируются вместе, заставляя модель находить такие латентные представления, которые одновременно хороши для восстановления данных и для разделения на семантические группы. Это близко к идеям self-supervised learning, но с более явной структурой.
Пошаговый план: от данных до модели
Теория - это скучно. Давайте к коду. Возьмем EMNIST (Extended MNIST) - классический датасет рукописных букв и цифр. Представим, что у нас есть доступ ко всем изображениям, но метки есть только для 1% данных. Наша задача - добиться точности, сравнимой с обучением на 100% размеченных данных.
1Шаг 1: Готовим данные и среду
Используем PyTorch 2.3 (актуально на 17.04.2026) и torchvision. Первое решение - как разделить данные. Нельзя просто взять первые 1% примеров с метками - распределение классов может быть нарушено. Используем стратифицированную выборку.
import torch
from torchvision import datasets, transforms
from torch.utils.data import DataLoader, Subset
import numpy as np
from sklearn.model_selection import train_test_split
# Преобразования для EMNIST
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Lambda(lambda x: x.view(-1)) # Вытягиваем в вектор
])
# Загрузка полного датасета
full_dataset = datasets.EMNIST(root='./data', split='byclass',
train=True, download=True, transform=transform)
# Стратифицированная выборка 1% данных с метками
targets = full_dataset.targets.numpy()
indices = np.arange(len(targets))
train_idx, labeled_idx = train_test_split(indices, test_size=0.01,
stratify=targets, random_state=42)
# Создаем размеченный и неразмеченный датасеты
unlabeled_dataset = Subset(full_dataset, train_idx)
labeled_dataset = Subset(full_dataset, labeled_idx)
# Для теста используем стандартный тестовый набор
test_dataset = datasets.EMNIST(root='./data', split='byclass',
train=False, download=True, transform=transform)
print(f"Неразмеченных примеров: {len(unlabeled_dataset)}")
print(f"Размеченных примеров: {len(labeled_dataset)}")
print(f"Тестовых примеров: {len(test_dataset)}")Ошибка новичка: брать размеченные данные без стратификации. Если в 1% выборке не окажется, скажем, цифры '5', модель никогда не научится ее распознавать. В production это гарантированный провал.
2Шаг 2: Строим архитектуру GMVAE
Здесь нужна аккуратность. Слишком простая сеть не уловит паттерны, слишком сложная переобучится на скудных размеченных данных. Я предлагаю архитектуру с латентной размерностью 32 и 62 компонентами смеси (по количеству классов в EMNIST byclass).
import torch.nn as nn
import torch.nn.functional as F
class GMVAE(nn.Module):
def __init__(self, input_dim=784, latent_dim=32, n_components=62):
super().__init__()
self.latent_dim = latent_dim
self.n_components = n_components
# Энкодер
self.encoder = nn.Sequential(
nn.Linear(input_dim, 512),
nn.ReLU(),
nn.Linear(512, 256),
nn.ReLU(),
nn.Linear(256, 128),
nn.ReLU(),
)
# Слой для параметров латентного распределения
self.fc_mu = nn.Linear(128, latent_dim)
self.fc_logvar = nn.Linear(128, latent_dim)
# Слой для предсказания вероятностей компонентов смеси
self.fc_pi = nn.Linear(128, n_components)
# Декодер
self.decoder = nn.Sequential(
nn.Linear(latent_dim, 128),
nn.ReLU(),
nn.Linear(128, 256),
nn.ReLU(),
nn.Linear(256, 512),
nn.ReLU(),
nn.Linear(512, input_dim),
nn.Sigmoid()
)
def encode(self, x):
h = self.encoder(x)
mu = self.fc_mu(h)
logvar = self.fc_logvar(h)
pi_logits = self.fc_pi(h)
return mu, logvar, pi_logits
def reparameterize(self, mu, logvar):
std = torch.exp(0.5 * logvar)
eps = torch.randn_like(std)
return mu + eps * std
def decode(self, z):
return self.decoder(z)
def forward(self, x):
mu, logvar, pi_logits = self.encode(x)
z = self.reparameterize(mu, logvar)
x_recon = self.decode(z)
return x_recon, mu, logvar, pi_logits, zОбратите внимание на fc_pi. Этот слой выдает логиты для категориального распределения по компонентам смеси. Во время обучения без учителя модель учится присваивать примеры к компонентам на основе их семантического сходства.
3Шаг 3: Обучение с акцентом на неразмеченные данные
Самое интересное - функция потерь. Она состоит из трех частей: реконструкция (MSE или BCE), KL-дивергенция для латентной переменной z, и KL-дивергенция для категориальной переменной y. Важно правильно взвесить эти компоненты.
def gmvae_loss(x, x_recon, mu, logvar, pi_logits, beta=1.0, gamma=1.0):
# Реконструкция
recon_loss = F.binary_cross_entropy(x_recon, x, reduction='sum')
# KL-дивергенция для z (аналогично VAE)
kl_loss = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp())
# KL-дивергенция для pi (распределение по компонентам смеси)
# Предполагаем априорное равномерное распределение
pi_probs = F.softmax(pi_logits, dim=-1)
uniform_prior = torch.ones_like(pi_probs) / pi_probs.size(-1)
pi_kl_loss = F.kl_div(pi_probs.log(), uniform_prior, reduction='sum')
total_loss = recon_loss + beta * kl_loss + gamma * pi_kl_loss
return total_loss, recon_loss, kl_loss, pi_kl_lossПараметры beta и gamma - это ваши ручки настройки. Начните с beta=1.0, gamma=0.1. Gamma контролирует "жесткость" кластеризации. Слишком высокое значение заставит модель равномерно распределять примеры по кластерам, слишком низкое - коллапсировать в несколько компонентов.
4Шаг 4: Дообучение классификатора
После предобучения GMVAE, у вас есть мощный энкодер, который преобразует изображения в структурированные латентные векторы. На этом этапе можно заморозить веса GMVAE и обучить простой классификатор (например, линейный слой) на размеченных данных. Но есть хитрее способ.
# Используем энкодер GMVAE как фичейзер
classifier = nn.Sequential(
nn.Linear(32, 128), # 32 - размерность латентного пространства
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(128, 62) # 62 класса в EMNIST byclass
)
# Замораживаем веса GMVAE
for param in gmvae.parameters():
param.requires_grad = False
# Обучаем только классификатор на размеченных данных
optimizer = torch.optim.Adam(classifier.parameters(), lr=1e-3)
for epoch in range(50):
for batch, (data, target) in enumerate(labeled_loader):
with torch.no_grad():
mu, _, _ = gmvae.encode(data) # Используем только mu как фичи
output = classifier(mu)
loss = F.cross_entropy(output, target)
optimizer.zero_grad()
loss.backward()
optimizer.step()Почему используем только mu, а не весь латентный вектор? Потому что mu - это детерминированное представление входных данных, в то время как полный латентный вектор z включает случайный шум (через reparameterization trick). Для классификации детерминированное представление обычно работает лучше.
Нюансы, которые взорвут ваш эксперимент
Реализовать GMVAE - полдела. Заставить его работать стабильно - искусство. Вот что редко пишут в туториалах:
- Инициализация весов для fc_pi. Если инициализировать этот слой нулями, все компоненты смеси будут иметь равные вероятности в начале. Это хорошо. Но лучше добавить небольшой шум, чтобы избежать симметрии.
- Температура для softmax в pi. Вместо обычного softmax используйте softmax с температурой: softmax(pi_logits / tau). Начинайте с tau=1.0 и постепенно уменьшайте до 0.5 во время обучения. Это "закаливает" распределение, делая присвоение к кластерам более уверенным.
- Баланс между размеченными и неразмеченными батчами. Когда вы обучаете классификатор, смешивайте размеченные и неразмеченные данные в одном батче, но вычисляйте loss только для размеченных. Неразмеченные данные проходят через модель и влияют на градиенты через unsupervised loss, что действует как регуляризация. Это похоже на трюк из парадокса fine-tuning.
Предупреждение: Не пытайтесь использовать GMVAE, если ваши данные не кластеризуемы. Если изображения в датасете - это случайный шум или между классами нет визуального различия (например, классификация по эмоциям в тексте без контекста), метод не сработает. Он полагается на то, что неразмеченные данные имеют внутреннюю структуру.
Частые ошибки и как их избежать
| Ошибка | Симптом | Решение |
|---|---|---|
| Коллапс кластера | Один компонент смеси захватывает >90% данных, accuracy низкая | Увеличить gamma в loss, добавить регуляризацию на минимальный размер кластера |
| Переобучение на размеченные данные | Отличная точность на train, ужасная на test | Заморозить GMVAE при дообучении классификатора, использовать сильный dropout |
| Нестабильные градиенты | Loss скачет, иногда становится NaN | Клиппинг градиентов, уменьшить learning rate, использовать AdamW вместо Adam |
FAQ по GMVAE
Сколько размеченных данных достаточно? Зависит от сложности задачи. Для EMNIST - 1% (около 600 примеров) дает ~70% accuracy. Для CIFAR-10 нужно уже 5-10%. Для медицинских изображений может потребоваться 20%, но это все равно в 5 раз меньше, чем при fully-supervised подходе.
GMVAE или контрастивный self-supervised learning? GMVAE дает явные кластеры, что удобно для интерпретации. Контрастивные методы (SimCLR, BYOL) часто показывают лучшие линейные оценки, но требуют больше вычислительных ресурсов и сложнее в отладке. Выбор зависит от ваших целей. Если нужно быстро прототипировать - GMVAE. Если гонитесь за state-of-the-art и есть GPU ферма - контрастивное обучение.
Как интегрировать GMVAE в production пайплайн? Обученный энкодер можно экспортировать в ONNX формат и использовать для извлечения признаков в реальном времени. Классификатор работает поверх этих признаков. Мониторьте "уверенность" модели через entropy распределения pi. Высокая энтропия (модель не уверена, к какому кластеру отнести пример) - сигнал для human-in-the-loop. Это экономит время на разметке данных для сложных случаев.
Что дальше? Неочевидный совет
GMVAE - не панацея. Это инструмент, который особенно хорошо работает, когда у вас есть много неразмеченных данных и вы примерно представляете количество классов. Но самый интересный тренд 2026 года - гибридные подходы. Представьте: GMVAE для начальной кластеризации, затем маленькая языковая модель (например, Gemma 3 4B) для генерации псевдометок на основе кластеров, и наконец - тонкая настройка классификатора на смеси реальных и псевдо-размеченных данных.
И последнее: никогда не доверяйте слепо кластерам, найденным моделью. Визуализируйте латентное пространство с помощью UMAP или t-SNE. Посмотрите, какие примеры попали в один кластер. Иногда модель находит закономерности, которые вы не ожидали (например, объединяет все изображения с зеленым фоном). Иногда это баг. Иногда - фича. Разница лишь в том, можно ли это монетизировать.