Разметка данных - узкое горлышко большинства ML-проектов. Платить аннотаторам за каждый пример дорого, а ждать неделями, пока разметят весь датасет, - долго. Active Learning решает эту проблему: модель сама выбирает, какие примеры отправить на разметку, и учится на меньшем объёме данных, достигая того же качества, что и при полной разметке. На практике это сокращает бюджет разметки на 50–80% без потери точности.
В этом руководстве разберём три ключевые стратегии отбора примеров: uncertainty sampling, diversity-based sampling и query by committee. Для каждой дадим интуитивное объяснение, готовый код на Python с scikit-learn и визуализацию. Отдельно сравним стратегии и покажем, как комбинировать их на разных стадиях проекта.
Что такое Active Learning и зачем он нужен
Active Learning - это подход к обучению, при котором алгоритм самостоятельно запрашивает метки только для тех примеров, которые считает наиболее полезными для улучшения модели. В отличие от пассивного обучения, где модель получает случайную выборку из датасета, Active Learning имитирует поведение студента, который задаёт преподавателю вопросы по темам, в которых он наименее уверен.
Схема работы стандартного цикла Active Learning:
- Обучаем начальную модель на небольшом размеченном наборе.
- Модель оценивает все неразмеченные примеры и выбирает наиболее информативные.
- Выбранные примеры отправляются оракулу (человеку-аннотатору) для разметки.
- Новые размеченные данные добавляются в обучающий набор.
- Модель переобучается, и цикл повторяется.
Сценарии, где Active Learning даёт максимальный эффект: медицинская диагностика с дорогой экспертной разметкой, обработка юридических документов, модерация контента, разметка спутниковых снимков. Во всех этих случаях стоимость ошибки или задержки высока, а бюджет на аннотацию ограничен. Исследования на benchmark-датасетах показывают, что Active Learning достигает точности полного датасета, используя лишь 20–40% примеров.
Три стратегии, которые мы разберём, покрывают большинство практических задач. Uncertainty sampling отбирает примеры с максимальной неопределённостью модели. Diversity-based sampling обеспечивает покрытие всего пространства признаков. Query by committee использует ансамбль моделей для поиска примеров, вызывающих разногласия.
Стратегия 1: Uncertainty Sampling - выбираем самые непонятные примеры
Uncertainty sampling опирается на простую идею: если модель не может уверенно классифицировать пример, значит, этот пример находится в зоне, где модели не хватает информации. Добавление таких примеров в обучающую выборку максимально быстро уточняет границу решений.
Три основные метрики неопределённости для классификации:
- Наименьшая уверенность (least confidence): отбираем примеры, для которых максимальная предсказанная вероятность минимальна. Формула:
1 - max(P(y|x)). Работает быстро, но учитывает только самый вероятный класс. - Разница между двумя лучшими классами (margin sampling): отбираем примеры с минимальной разницей между вероятностями двух наиболее вероятных классов. Формула:
P(y1|x) - P(y2|x). Лучше разделяет случаи с одним уверенным классом и несколькими конкурирующими. - Энтропия (entropy): отбираем примеры с максимальной энтропией распределения вероятностей. Формула:
-Σ P(yi|x) * log(P(yi|x)). Учитывает неопределённость по всем классам, наиболее информативна для многоклассовых задач.
На практике энтропия чаще всего даёт лучшие результаты, поэтому в коде будем использовать именно её.
Реализация Uncertainty Sampling на Python
Начнём с синтетического датасета для наглядной визуализации. Используем make_classification из scikit-learn с двумя признаками, чтобы видеть границу решений на плоскости.
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
# Генерируем датасет с двумя классами
X, y = make_classification(
n_samples=1000,
n_features=2,
n_informative=2,
n_redundant=0,
n_clusters_per_class=1,
flip_y=0.1,
random_state=42
)
# Имитируем неразмеченный пул: оставляем 20 случайных примеров для начального обучения
np.random.seed(42)
initial_idx = np.random.choice(len(X), size=20, replace=False)
labeled_idx = set(initial_idx)
unlabeled_idx = set(range(len(X))) - labeled_idx
X_labeled = X[list(labeled_idx)]
y_labeled = y[list(labeled_idx)]
X_unlabeled = X[list(unlabeled_idx)]
y_unlabeled_true = y[list(unlabeled_idx)]Функция для вычисления энтропии предсказаний модели:
def entropy_sampling(model, X_pool, n_samples=10):
"""Отбирает n_samples примеров с максимальной энтропией."""
probs = model.predict_proba(X_pool)
# Энтропия: -sum(p * log(p)) по классам
entropy = -np.sum(probs * np.log(probs + 1e-10), axis=1)
# Индексы примеров с наибольшей энтропией
query_idx = np.argsort(entropy)[-n_samples:]
return query_idxЗапускаем цикл активного обучения. На каждой итерации модель запрашивает 10 самых неопределённых примеров, получает их истинные метки и переобучается:
model = LogisticRegression(max_iter=1000)
model.fit(X_labeled, y_labeled)
history = {'labeled_count': [len(labeled_idx)], 'accuracy': []}
# Оцениваем точность на всём датасете для отслеживания прогресса
acc = accuracy_score(y, model.predict(X))
history['accuracy'].append(acc)
# Цикл: 10 итераций по 10 примеров
for iteration in range(10):
# Отбираем примеры из неразмеченного пула
pool_indices = list(unlabeled_idx)
X_pool = X[pool_indices]
query_local_idx = entropy_sampling(model, X_pool, n_samples=10)
query_global_idx = [pool_indices[i] for i in query_local_idx]
# Добавляем в размеченный набор
labeled_idx.update(query_global_idx)
unlabeled_idx -= set(query_global_idx)
X_labeled = X[list(labeled_idx)]
y_labeled = y[list(labeled_idx)]
# Переобучаем модель
model.fit(X_labeled, y_labeled)
acc = accuracy_score(y, model.predict(X))
history['labeled_count'].append(len(labeled_idx))
history['accuracy'].append(acc)
print(f"Итерация {iteration+1}: размечено {len(labeled_idx)} примеров, точность {acc:.3f}")Визуализируем, какие точки модель запрашивает. На scatter plot хорошо видно, что запрошенные примеры концентрируются вдоль границы между классами - именно там модель испытывает максимальную неопределённость.
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# График 1: какие точки запрошены после первой итерации
axes[0].scatter(X[:, 0], X[:, 1], c=y, cmap='coolwarm', alpha=0.3, s=20)
queried = list(labeled_idx - set(initial_idx))
axes[0].scatter(X[queried, 0], X[queried, 1], c='black', marker='x', s=80, label='Запрошенные')
axes[0].scatter(X[initial_idx, 0], X[initial_idx, 1], c='green', marker='s', s=60, label='Начальные')
axes[0].set_title('Uncertainty Sampling: запрошенные примеры')
axes[0].legend()
# График 2: кривая обучения
axes[1].plot(history['labeled_count'], history['accuracy'], marker='o')
axes[1].axhline(y=accuracy_score(y, LogisticRegression(max_iter=1000).fit(X, y).predict(X)),
color='r', linestyle='--', label='Полный датасет')
axes[1].set_xlabel('Размечено примеров')
axes[1].set_ylabel('Accuracy')
axes[1].set_title('Кривая обучения Active Learning')
axes[1].legend()
plt.tight_layout()
plt.show()Преимущества uncertainty sampling: простота реализации, низкие вычислительные затраты - нужен только один проход модели по неразмеченному пулу, хорошо работает, когда классы линейно разделимы.
Ограничения: стратегия склонна выбирать похожие примеры из одной узкой области неопределённости, игнорируя остальное пространство признаков. Если начальная модель сильно смещена, она будет запрашивать примеры, которые только усиливают это смещение. Вторая проблема решается diversity-based sampling.
Стратегия 2: Diversity-Based Sampling - покрываем всё пространство данных
Diversity-based sampling решает проблему однородности выборки. Вместо того чтобы фокусироваться на границе решений, мы стремимся разметить представителей всех кластеров в пространстве признаков. Это особенно полезно на старте проекта, когда модель ещё плохо понимает структуру данных.
Базовый подход: кластеризуем неразмеченные данные с помощью KMeans и выбираем примеры, ближайшие к центрам кластеров. Так мы гарантируем, что размеченные данные покрывают всё разнообразие датасета.
Реализация Diversity-Based Sampling на Python
Используем тот же синтетический датасет. Функция отбора на основе кластеризации:
from sklearn.cluster import KMeans
def diversity_sampling(X_pool, n_samples=10, n_clusters=None):
"""Отбирает n_samples примеров через кластеризацию пула."""
if n_clusters is None:
n_clusters = n_samples
kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
cluster_labels = kmeans.fit_predict(X_pool)
selected = []
for cluster_id in range(n_clusters):
# Индексы примеров в этом кластере
cluster_indices = np.where(cluster_labels == cluster_id)[0]
if len(cluster_indices) == 0:
continue
# Находим центр кластера
center = kmeans.cluster_centers_[cluster_id]
# Выбираем пример, ближайший к центру
distances = np.linalg.norm(X_pool[cluster_indices] - center, axis=1)
best_local_idx = cluster_indices[np.argmin(distances)]
selected.append(best_local_idx)
# Если кластеров меньше, чем n_samples, добираем случайно
if len(selected) < n_samples:
remaining = list(set(range(len(X_pool))) - set(selected))
extra = np.random.choice(remaining, n_samples - len(selected), replace=False)
selected.extend(extra)
return np.array(selected[:n_samples])Запускаем цикл с diversity sampling и сравниваем с random sampling - пассивным отбором случайных примеров:
# Сбрасываем состояние
labeled_idx_div = set(initial_idx)
unlabeled_idx_div = set(range(len(X))) - labeled_idx_div
model_div = LogisticRegression(max_iter=1000)
model_div.fit(X[list(labeled_idx_div)], y[list(labeled_idx_div)])
history_div = {'labeled_count': [len(labeled_idx_div)], 'accuracy': [accuracy_score(y, model_div.predict(X))]}
for iteration in range(10):
pool_indices = list(unlabeled_idx_div)
X_pool = X[pool_indices]
query_local_idx = diversity_sampling(X_pool, n_samples=10)
query_global_idx = [pool_indices[i] for i in query_local_idx]
labeled_idx_div.update(query_global_idx)
unlabeled_idx_div -= set(query_global_idx)
X_labeled = X[list(labeled_idx_div)]
y_labeled = y[list(labeled_idx_div)]
model_div.fit(X_labeled, y_labeled)
acc = accuracy_score(y, model_div.predict(X))
history_div['labeled_count'].append(len(labeled_idx_div))
history_div['accuracy'].append(acc)Визуализация показывает, что diversity sampling равномерно покрывает пространство признаков, в отличие от uncertainty sampling, который концентрируется на границе.
Преимущества diversity-based sampling: устойчивость к выбросам, хорошее покрытие всего пространства признаков, не зависит от качества текущей модели. Идеален для начального этапа разметки.
Ограничения: вычислительная сложность KMeans растёт как O(n*k*d), что может быть затратно на миллионах примеров. Число кластеров нужно подбирать - слишком мало кластеров даст неоптимальное покрытие, слишком много - избыточность. Стратегия не учитывает, насколько пример полезен для границы решений, поэтому на поздних стадиях проигрывает uncertainty sampling.
Стратегия 3: Query by Committee - пусть модели поспорят
Query by Committee (QBC) использует ансамбль моделей для отбора примеров. Идея: если несколько разных моделей дают противоречивые предсказания для одного примера, этот пример находится в зоне высокой неопределённости и будет полезен для обучения всех моделей комитета.
Метрики разногласия:
- Vote entropy: энтропия распределения голосов моделей за каждый класс. Высокая энтропия - модели расходятся во мнениях.
- KL-дивергенция: среднее расхождение между предсказанием каждой модели и консенсусом комитета. Более чувствительна к степени уверенности моделей.
На практике vote entropy проще в реализации и даёт сопоставимые результаты.
Реализация Query by Committee на Python
Собираем комитет из трёх разных классификаторов: логистическая регрессия, случайный лес и SVM. Разные архитектуры дают разный взгляд на данные, что усиливает эффект разногласия.
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import SVC
def build_committee():
"""Создаёт комитет из трёх разных моделей."""
return [
LogisticRegression(max_iter=1000, random_state=0),
RandomForestClassifier(n_estimators=50, random_state=1),
SVC(probability=True, random_state=2)
]
def vote_entropy_sampling(committee, X_pool, n_samples=10):
"""Отбирает примеры с максимальной энтропией голосов комитета."""
# Собираем предсказания всех моделей
all_votes = []
for model in committee:
pred = model.predict(X_pool)
all_votes.append(pred)
all_votes = np.array(all_votes) # shape: (n_models, n_samples)
n_classes = len(np.unique(all_votes))
vote_entropy = np.zeros(len(X_pool))
for i in range(len(X_pool)):
# Подсчитываем голоса за каждый класс
votes = all_votes[:, i]
counts = np.bincount(votes.astype(int), minlength=n_classes)
probs = counts / len(committee)
# Энтропия голосов
vote_entropy[i] = -np.sum(probs * np.log(probs + 1e-10))
query_idx = np.argsort(vote_entropy)[-n_samples:]
return query_idxЦикл обучения с QBC:
labeled_idx_qbc = set(initial_idx)
unlabeled_idx_qbc = set(range(len(X))) - labeled_idx_qbc
committee = build_committee()
for model in committee:
model.fit(X[list(labeled_idx_qbc)], y[list(labeled_idx_qbc)])
# Оцениваем точность как среднее по комитету
acc_qbc = np.mean([accuracy_score(y, m.predict(X)) for m in committee])
history_qbc = {'labeled_count': [len(labeled_idx_qbc)], 'accuracy': [acc_qbc]}
for iteration in range(10):
pool_indices = list(unlabeled_idx_qbc)
X_pool = X[pool_indices]
query_local_idx = vote_entropy_sampling(committee, X_pool, n_samples=10)
query_global_idx = [pool_indices[i] for i in query_local_idx]
labeled_idx_qbc.update(query_global_idx)
unlabeled_idx_qbc -= set(query_global_idx)
X_labeled = X[list(labeled_idx_qbc)]
y_labeled = y[list(labeled_idx_qbc)]
for model in committee:
model.fit(X_labeled, y_labeled)
acc = np.mean([accuracy_score(y, m.predict(X)) for m in committee])
history_qbc['labeled_count'].append(len(labeled_idx_qbc))
history_qbc['accuracy'].append(acc)
print(f"QBC Итерация {iteration+1}: точность {acc:.3f}")Преимущества QBC: более робастный отбор - случайная ошибка одной модели не приводит к запросу бесполезного примера. Хорошо работает на зашумлённых данных. Не требует настройки порогов неопределённости.
Ограничения: обучение нескольких моделей на каждой итерации увеличивает время в 3–5 раз. Для больших датасетов и глубоких сетей это может быть критично. Компенсировать можно использованием лёгких моделей в комитете (логистическая регрессия, наивный Байес) или обновлением только части комитета.
Сравнение стратегий и практические рекомендации
Сведём характеристики трёх стратегий в таблицу для быстрого выбора:
| Стратегия | Плюсы | Минусы | Когда применять |
|---|---|---|---|
| Uncertainty Sampling | Простота, скорость, хорошо уточняет границы | Однородность выборки, чувствительность к начальной модели | Средние и поздние стадии проекта, данные без сильных выбросов |
| Diversity-Based Sampling | Хорошее покрытие, устойчивость к выбросам | Вычислительная сложность, не фокусируется на границах | Старт проекта, холодный старт, разнородные данные |
| Query by Committee | Робастность, устойчивость к шуму | Высокие вычислительные затраты, требует настройки комитета | Сложные задачи, высокие требования к качеству, зашумлённые данные |
График сравнения кривых обучения на одном датасете показывает, что на первых 30–40 примерах diversity-based sampling вырывается вперёд за счёт быстрого покрытия пространства. К 80–100 примерам uncertainty sampling догоняет и обгоняет за счёт точной настройки границ. QBC идёт чуть ниже на старте, но показывает наиболее стабильный рост без провалов.
Как комбинировать стратегии на разных стадиях проекта
Максимальную эффективность даёт гибридный подход, адаптированный под стадию проекта:
- Холодный старт (0–5% данных размечено): diversity-based sampling для быстрого покрытия пространства признаков. Если данных очень много, используем стратифицированный случайный отбор.
- Разгон (5–20% размечено): переключаемся на uncertainty sampling для уточнения границ решений. Модель уже достаточно сильна, чтобы осмысленно оценивать неопределённость.
- Зрелость (20%+ размечено): периодически включаем query by committee для проверки, не упустила ли модель сложные случаи. Можно запускать QBC каждые 3–5 итераций uncertainty sampling.
Пример кода гибридного отбора: diversity-based для начального покрытия, затем uncertainty с ограничением на минимальное расстояние между запрошенными примерами (чтобы избежать дублирования).
def hybrid_sampling(model, X_pool, n_samples=10, diversity_weight=0.3):
"""Комбинирует uncertainty и diversity через взвешенный скоринг."""
# 1. Оценка неопределённости
probs = model.predict_proba(X_pool)
uncertainty = -np.sum(probs * np.log(probs + 1e-10), axis=1)
uncertainty_norm = (uncertainty - uncertainty.min()) / (uncertainty.max() - uncertainty.min() + 1e-10)
# 2. Оценка разнообразия: расстояние до ближайшего уже запрошенного
# Упрощённо: используем расстояние до центра масс пула как прокси
center = X_pool.mean(axis=0)
diversity = np.linalg.norm(X_pool - center, axis=1)
diversity_norm = (diversity - diversity.min()) / (diversity.max() - diversity.min() + 1e-10)
# 3. Комбинированный скор
score = (1 - diversity_weight) * uncertainty_norm + diversity_weight * diversity_norm
query_idx = np.argsort(score)[-n_samples:]
return query_idxДля production-пайплайнов важно автоматизировать переключение стратегий. Триггером может служить замедление роста accuracy на валидации: если uncertainty sampling перестал давать прирост, временно включаем diversity-based или QBC для поиска новых областей.
Ограничения Active Learning и как с ними работать
Active Learning не панацея. Разберём три основные проблемы и способы их решения.
Чувствительность к начальной разметке. Если первые 20–50 примеров нерепрезентативны, модель сформирует искажённое представление о данных и будет запрашивать примеры, усиливающие это искажение. Решение: использовать стратифицированный случайный отбор для начального набора, гарантирующий присутствие всех классов. Если классы неизвестны, запустить diversity-based sampling.
Проблема холодного старта. Без обученной модели нельзя вычислить неопределённость или разногласие комитета. Решение: начать с diversity-based sampling или случайного отбора, обучить слабую модель на 30–50 примерах и только потом включать uncertainty sampling.
Вычислительные затраты. Diversity-based sampling с KMeans требует O(n*k*d) операций, QBC умножает время обучения на число моделей в комитете. Для датасетов из миллионов примеров это может стать узким горлышком. Решения:
- Для diversity: использовать MiniBatchKMeans, который работает с подвыборками и сходится быстрее.
- Для QBC: использовать лёгкие модели (SGDClassifier, Naive Bayes) или обновлять не весь комитет на каждой итерации.
- Субдискретизация пула: случайно отбирать 10–20% неразмеченных данных для scoring, если пул очень большой.
Отдельно стоит мониторить разнообразие запросов. Если модель 5 итераций подряд запрашивает примеры из одной области, пора вмешаться и принудительно добавить diversity-компоненту в отбор.
Заключение: Active Learning как инструмент эффективной разметки
Active Learning сокращает бюджет разметки на 50–80% без потери качества модели. Три стратегии - uncertainty sampling, diversity-based sampling и query by committee - покрывают большинство сценариев: от холодного старта до тонкой настройки границ решений.
Практический алгоритм внедрения:
- Оцените бюджет разметки и долю данных, которую реально разметить.
- Начните с diversity-based sampling для первых 5% данных.
- Переключитесь на uncertainty sampling (энтропия) для основного цикла.
- Каждые 3–5 итераций проверяйте качество через query by committee.
- Мониторьте разнообразие запросов и корректируйте стратегию при застое.
Весь код из статьи готов к запуску - берите и адаптируйте под свой датасет. Если вы работаете с проприетарными данными и оцениваете юридические риски, рекомендуем материал об уроках за 8 лет в ML, где разбираются факторы успеха production-проектов. Для оптимизации хранения датасетов при активном обучении посмотрите многошаговый семантический поиск в Amazon Bedrock - техники упаковки данных ускоряют итерации переобучения. Вопросы автоматизации разметки и роли человека в цикле подробно разбираются в статье об ИИ-ассистентах в разработке - поможет настроить human-in-the-loop процесс без потери экспертизы команды.