19745
Все о Data Science, машинном обучении и искусственном интеллекте: от базовой теории до cutting-edge исследований и LLM. MAX - https://max.ru/devsp Личный блог - @just_genych По вопросам рекламы или разработки - @g_abashkin РКН: https://vk.cc/cJPGXD
Автоматическая настройка аугментации для GBDT через обратную связь от дрейфа остаточных ошибок в production
Стандартные детекторы дрейфа смотрят на распределение фич или предсказаний, но пропускают важный сигнал — изменение структуры остаточных ошибок. В production, когда ground truth поступает с задержкой, остатки могут указать на сдвиг раньше, чем классические методы, и это открывает путь к адаптивной аугментации без переобучения модели.
Почему residuals чувствительнее фич
При сдвиге распределения дерево часто попадает в локальную область, где предсказания застывают. Дрейф по фичам может оставаться незамеченным (например, из-за корреляции), но остатки на fresh батчах сразу показывают систематическое смещение. Используйте KS-тест между остатками текущего среза и эталонными (с валидации). Порог 0.1-0.2 на практике работает лучше, чем PSI для предсказаний — меньше ложных срабатываний.
Адаптивная аугментация через градиент по residuals
После детекции дрейфа запускается мини-оптимизация: параметры аугментации (например, масштаб гауссовского шума) подбираются под текущий срез данных. Формула проста — noise_scale = min(0.5, ks_stat * 2). Это не случайная augmentation, а инженерный трюк: дерево, обученное на зашумлённых точках в зоне дрейфа, быстрее выходит из локального минимума. В production для GBDT с init_model реакция занимает менее секунды.
residuals_current = y_true - y_pred
ks_stat, _ = ks_2samp(residuals_current, reference_residuals)
if ks_stat > 0.1:
noise_scale = min(0.5, ks_stat * 2)
X_aug = current_X + np.random.normal(0, noise_scale, current_X.shape)
model.fit(X_aug, y_true, init_model=model)
Distributed SHAP-агрегация для real-time интерпретации предсказаний GBDT в сервинге с гарантированной консистентностью
Когда GBDT-модель висит под 100k RPS в продакшене, интерпретация каждого предсказания — не опция, а необходимость. Особенно если регулятор или compliance просят объяснить, почему клик засчитали именно этому юзеру. Но стандартный SHAP для GBDT — это O(T * 2^max_depth). Для глубины дерева 10 — 1024 комбинации на одно дерево. В real-time это не взлетит.
Я часто вижу, как команды кладут SHAP-векторы в Kafka, а потом Spark их аггрегирует. И тут начинается классическая проблема: временные метки расходятся, значения теряются, и вместо объяснения получается каша. Особенно когда на разных worker-ах модель чуть по-разному обновляется.
Архитектура агрегации
Мы пошли другим путём. Каждый worker считает локальный SHAP через TreeSHAP (сложность O(T * D²), для глубоких деревьев всё ещё дорого, но терпимо, если резать глубину). Для real-time используем приближение: берём глобальный baseline (среднее предсказание по train), и worker отправляет в Redis Cluster не весь вектор, а агрегат: feature_name → (sum_shap, count, глобальный timestamp).
Проблема консистентности
Главная проблема — консистентность. Если два worker-а видят модель в разном состоянии, их SHAP-ы несопоставимы. Тут в игру входят Hybrid Logical Clocks (HLC). Они дают causal consistency: мы знаем, какое событие произошло раньше, и можем детерминированно смержить данные без конфликтов. Никаких векторных часов вручную — HLC встроен в Redis Cluster через CRDT.
Детали агрегатора
На стороне Reducer (у нас Flink) мы считаем: avg_shap[i] = sum_shap[i] / count[i], но только если временные метки всех worker-ов для этого фичи сходятся в пределах окна. Если расходятся — дропаем точку. Вот пример кода агрегатора:
// Псевдокод агрегации в Flink
select feature_name,
sum(shap_value) / count(*) as avg_shap,
max(timestamp) as ts
from shap_stream
group by feature_name, tumble(ts, interval '1' minute)
having count(distinct worker_id) = expected_worker_count;
Локальная LLM на Windows 11: среда, модель и развёртывание
Это третья статья из цикла, посвящённого созданию системы круглосуточной ситуационной осведомлённости. Материал является подготовительным этапом перед запуском такой системы.
В статье рассматривается пошаговое развёртывание локальной большой языковой модели на Windows 11. Описывается выбор среды выполнения и конкретной модели для инференса. Приводится последовательность действий для установки и настройки всех необходимых компонентов.
Автор делится практическим опытом запуска LLM на персональном компьютере под управлением Windows 11. Инструкция нацелена на пользователей, желающих получить работающий локальный чат-бот без обращения к облачным сервисам.
Читать статью полностью
Граница Дженсена-Шеннона для обнаружения дрейфа эмбеддингов без лейблов
В production-рекомендательных системах скрытый дрейф часто остается незамеченным до падения бизнес-метрик. Когда лейблы недоступны из-за privacy ограничений или задержки накопления ground truth, единственный сигнал — изменение распределения эмбеддингов. Типичная ошибка: визуально сравнивать UMAP или PCA проекции, хотя JSD дает численную, статистически обоснованную метрику.
Почему JSD, а не KL или MMD
JSD симметрична, ограничена [0, log(2)] и имеет интерпретируемый порог. Для эмбеддингов размерностью 128+ значение JSD > 0.01-0.03 после нормировки на размерность — надежный индикатор дрейфа. В отличие от KL, JSD не требует выбора референсного распределения, а в отличие от MMD — имеет понятную шкалу. На практике JSD на батчах эмбеддингов из разных временных окон хорошо коррелирует с последующим падением offline-метрик.
KNN-based оценка без плотности
Прямая оценка JSD через KDE на 256-мерных эмбеддингах — ошибка: curse of dimensionality убивает KDE. Рабочий подход — использовать энтропийную оценку через расстояния до k-го соседа:
import numpy as np
from sklearn.neighbors import NearestNeighbors
def jsd_knn(X, Y, k=5):
n, d = X.shape
m = Y.shape[0]
Z = np.vstack([X, Y])
# Энтропия смеси
nbrs_mix = NearestNeighbors(n_neighbors=k+1).fit(Z)
dist_mix = nbrs_mix.kneighbors(Z, return_distance=True)[0][:, -1]
H_mix = np.log(n+m) - np.log(k) + d * np.mean(np.log(np.maximum(dist_mix, 1e-10)))
# Энтропия X
nbrs_X = NearestNeighbors(n_neighbors=k+1).fit(X)
dist_X = nbrs_X.kneighbors(X, return_distance=True)[0][:, -1]
H_X = np.log(n) - np.log(k) + d * np.mean(np.log(np.maximum(dist_X, 1e-10)))
# Энтропия Y
nbrs_Y = NearestNeighbors(n_neighbors=k+1).fit(Y)
dist_Y = nbrs_Y.kneighbors(Y, return_distance=True)[0][:, -1]
H_Y = np.log(m) - np.log(k) + d * np.mean(np.log(np.maximum(dist_Y, 1e-10)))
jsd = H_mix - 0.5 * (H_X + H_Y)
return max(0.0, jsd)
# Пример на 128d эмбеддингах
X_old = np.random.randn(10000, 128)
X_new = X_old + np.random.randn(10000, 128) * 0.15
print(jsd_knn(X_old, X_new)) # ~0.008 — норма
X_drifted = X_old + np.random.randn(10000, 128) * 0.4
print(jsd_knn(X_old, X_drifted)) # ~0.04 — дрейф
МТС и НИУ ВШЭ открыли набор на третий поток магистратуры по ИИ.
Набор идет по программе «Исследования и предпринимательство в искусственном интеллекте». Для студентов предусмотрено 30 оплачиваемых мест от компании.
Программу обновили с учетом того, как меняется рынок ИИ. Теперь в ней больше внимания уделят генеративному искусственному интеллекту, интеллектуальным агентам, проектированию ML-систем, большим языковым моделям, видеоаналитике и распознаванию речи. Обучение построено на реальных кейсах МТС Web Services.
Лучшие студенты могут получить приглашение на стажировку или оффер от МТС Web Services во время обучения. Заявки от желающих принимают по ссылке.
Adaptive Stochastic Quantile-Based Bucketing: решение для streaming feature encoding
Когда cardinality признака растёт в реальном времени, статические бакеты ломаются. Новые значения попадают в неизвестные категории, старые бакеты перестают отражать распределение, и модель начинает тупить. Переобучать каждый раз — дорого и медленно.
Проблема фиксированной cardinality в streaming
В production ML мы привыкли, что признаки с высокой cardinality, такие как user_id или device_id, кодируют через hash или frequency-based бакеты. Но в streaming-сценариях (кликстрим, фрод-детекция, IoT) cardinality может удвоиться за час. Статическое число категорий M ведёт к коллапсу: новые значения попадают в unknown bucket, старые бакеты смешиваются с новыми, метрики проседают. OneHotEncoder(fixed M) тут бесполезен. Переобучать модель каждую минуту — дорого и нарушает воспроизводимость.
Как работает ASQB
Решение — держать скользящую квантильную карту для каждого признака через TDigest (обновление O(log N)). Новое значение кодируется по его квантилю: делим распределение на M равных интервалов. Главная хитрость — стохастичность: добавляем лапласовский джиттер в границы бакетов, чтобы избежать смещения при резких всплесках cardinality. И адаптивность: M меняется по формуле M(t) = M0 + alpha * sigmoid(delta_cardinality). Если cardinality скакнула, бакетов становится больше — и наоборот.
from tdigest import TDigest
import numpy as np
class ASQBEncoder:
def __init__(self, M0=10, alpha=1.0, decay=0.9):
self.td = TDigest()
self.M = M0
self.alpha = alpha
def update(self, value):
self.td.update(value)
new_card = len(set(self.td.percentile([0, 100])))
delta_card = new_card - self.M
self.M = int(self.M + self.alpha * (1 / (1 + np.exp(-delta_card))))
jitter = np.random.laplace(0, 0.05 * self.M)
self.M = max(2, int(self.M + jitter))
def encode(self, value):
q = self.td.percentile_of(value) / 100.0
bucket = min(int(q * self.M), self.M - 1)
return bucket
decay в TDigest критичен. Без него старые данные перевешивают, и адаптивность M теряет смысл. Всегда проверяйте, что квантильный скетч забывает старые points согласно стратегии decay (exponential или sliding window).
Как превратить ChatGPT.com в Codex (но без лимитов)
Codex это прекрасно, но лимиты все меньше, а цена все выше. Под капотом просто gpt-5.5. Один диалог слева пишет код, справа другой проверял результат, третий держал контекст, а я в это время мог накидать очередь следующих шагов и уйти пить чай. Если задача закончилась — пусть прилетит сообщение в Telegram.
Так появился экспериментальный Chrome-плагин ChatGPT Multi Pane на GitHub.
Источник
Адаптивная кластеризация редких категорий: центроиды, которые учатся в потоке
Обычная кластеризация — K-Means, DBSCAN — живёт в статике: собрал данные, обучил, заморозил. В streaming-пайплайнах с редкими категориями (аномалии в IoT, новые сегменты пользователей, нетипичные события) это разваливается. Появляется новый редкий кластер — модель либо относит его к шуму, либо требует полного переобучения. Оба варианта плохи, если данных много, а кластеров мало и они возникают редко. Хранить всю историю нельзя, а моделировать динамику кластеров без дампа — ключевой trade-off.
Адаптивное обновление центроидов
Решение в экспоненциальном сглаживании с контролем дрейфа. Храним центроиды C₁...Cₖ и счётчики n₁...nₖ (эффективная память с decay γ). Когда приходит батч точек для каждой ищем ближайший центроид. Если расстояние меньше порога (например 0.3) обновляем: Cᵢ_new = (nᵢ * Cᵢ + β * x) / (nᵢ + β), где β — learn rate (0.1-0.3). Если расстояние больше порога и точка не шум — создаём новый центроид с малой начальной памятью (n = β), которая укрепляется при повторном подтверждении.
Динамический порог и подавление шума
Чтобы не переобучаться на шум: каждые T батчей применяем decay — nᵢ умножаем на 0.95. Порог создания кластера делаем динамическим: среднее k-е расстояние плюс 2 сигмы. На практике это даёт раннее обнаружение редких событий: например в мониторинге аномалий, где аномалия — редкий стабильный кластер, а не выброс.
def update_centroids(X_batch, centroids, counts, threshold=0.3, beta=0.2, gamma=0.99):
for x in X_batch:
distances = np.linalg.norm(centroids - x, axis=1)
min_dist_idx = np.argmin(distances)
if distances[min_dist_idx] < threshold:
counts[min_dist_idx] *= gamma + beta
centroids[min_dist_idx] += beta * (x - centroids[min_dist_idx]) / counts[min_dist_idx]
else:
centroids = np.vstack([centroids, x])
counts = np.append(counts, beta)
Когда дрифт признаков перестает быть просто метрикой
Мы привыкли: упали метрики — смотрим PSI или KS — видим дрифт — идем переобучать модель. Но сам дрифт не говорит, почему модель реагирует. В production-рекомендательных системах с high-cardinality признаками статистические тесты часто дают ложные срабатывания или пропускают реальные сдвиги, скрытые за мультимодальностью. Типичная ошибка: считать дрифт по одному признаку без учета влияния на выход модели.
Контрфактуальные сэмплы как детектор влияния
Идея: вместо сравнения распределений синтезировать контрфактуальные сэмплы для каждого признака. Вы обучаете генеративную модель (VAE или диффузионную) на референсном distribution A и генерируете сэмплы, которые были бы реальны без дрифта. Затем сравниваете предсказания на реальных данных и на синтетических контрфактуалах. Если разница значимая — дрифт подтвержден, и вы фиксируете его влияние на выход модели, а не просто сдвиг распределения.
Пример на production-рекомендательной системе: модель обучена на distribution A, на новых данных (distribution B) AUC падает на 5%. Для признака "время сессии" вы генерируете VAE на distribution A, получаете контрфактуальные сэмплы и считаете drift_score как |pred_real — pred_cf|. При score = 0.7 вы знаете, что в пиковые часы рекомендации стали нерелевантны. Решение: не переобучать всю модель, а изменить препроцессинг (например, бинаризовать временной интервал) или обновить политику отбора кандидатов.
generator = VAE(input_dim=1, latent_dim=8)
generator.fit(X_train['age'])
cf_age = generator.sample(n=1000)
pred_real = model.predict(X_test['age'])
pred_cf = model.predict(cf_age)
drift_score = np.abs(pred_real - pred_cf).mean()
if drift_score > epsilon: print("дрифт по возрасту")
Как сжать embedding-таблицы в 4 раза без loss-aware pruning? Кватернионные представления для high-cardinality фич
Работа с категориальными признаками, имеющими миллионы уникальных значений (user_id, video_id, search_query), создает классическую проблему: embedding-таблицы занимают больше памяти, чем сами веса модели. Стандартный подход — loss-aware pruning — часто приводит к необратимой потере информации для редких категорий. Элегантная альтернатива — кватернионные представления, которые снижают память в 4 раза без отбрасывания данных.
Базовая идея
Вместо full-rank вектора размерности d для каждой категории хранится всего 4 компоненты — кватернион (a, b, c, d). Выходной вектор восстанавливается через кватернионное умножение на learnable вес. Категория описывается вращением и гомотетией в 4D пространстве, что дает сильную геометрическую регуляризацию.
Реализация в production
Пример lookup для PyTorch-like inference:
class QuaternionEmbedding(nn.Module):
def __init__(self, num_ids, dim=64):
super().__init__()
self.emb = nn.Embedding(num_ids, 4)
self.w = nn.Parameter(torch.randn(4, dim // 4))
def forward(self, ids):
q = self.emb(ids)
q = q / (q.norm(dim=-1, keepdim=True) + 1e-8)
return torch.matmul(q, self.w)
🤯 Девушка получила оффер в OpenAI и поделилась своим опытом поиска работы
Внутри статьи она подробно расписывает этапы собеседований, лайфхаки и делится учебными ресурсами, которые ей помогли.
Плюс девушка великодушно оставила ссылки на свой Notion с полезными заметками по математике и LLM.
✖️ xCode Journal
Поиск и компенсация невыявленных триггеров дрейфа через subset-анализ Фубини
Классический мониторинг дрейфа данных часто пропускает скрытые триггеры, когда признаки взаимодействуют комбинаторно. Представьте: каждый признак по отдельности стабилен, но их совместное распределение дрейфует — это "троянский конь" для моделей.
Почему одномерные тесты не работают
Стандартные тесты (PSI, KS, AD) проверяют одномерные распределения. Но дрейф может возникать только в определённых подпространствах признаков. Например, в модели кредитного скоринга возраст и доход по отдельности стабильны, но среди клиентов с высоким доходом и возрастом больше 50 лет резко меняется поведение. Типичная ошибка: считать, что отсутствие дрейфа на уровне отдельных признаков гарантирует стабильность модели. В production это приводит к неожиданному падению метрик без видимых причин.
Subset-анализ Фубини
Идея из интегральной геометрии: вместо проверки всего пространства признаков проецируем распределение на все возможные подмножества признаков малой размерности (например, все пары или тройки). Для каждого подмножества вычисляем метрику дрейфа (например, Wasserstein distance). Если в каком-то подпространстве метрика аномально высока — это невыявленный триггер. Для пары "возраст и доход" дрейф может проявиться только в их сумме или произведении.
Пример кода на Python:
from itertools import combinations
import numpy as np
from scipy.stats import wasserstein_distance
def fubini_drift_detection(X_ref, X_prod, subset_size=2):
triggers = []
features = X_ref.shape[1]
for subset in combinations(range(features), subset_size):
ref_sub = X_ref[:, subset]
prod_sub = X_prod[:, subset]
proj_ref = np.sum(ref_sub, axis=1)
proj_prod = np.sum(prod_sub, axis=1)
w_dist = wasserstein_distance(proj_ref, proj_prod)
if w_dist > 0.1:
triggers.append((subset, w_dist))
return triggers
😁 Пункта про стоимость и требуемые характеристики к железу не хватает
✖️ xCode Journal
Online-детекция коллизий признаков при TDA-трансформации
Когда используешь топологический анализ данных (TDA) в production, быстро натыкаешься на проблему: персистентные диаграммы и другие TDA-признаки очень чувствительны к дрейфу распределения. Появляется новый режим данных - и количество топологических дыр или их значимость могут не совпасть с эталоном. Если это не отловить, downstream-модель просто развалится, а метрики начнут сыпаться без очевидных причин вроде feature drift.
Архитектура детекции
Суть подхода простая. У нас stream-данные, которые проходят TDA-трансформацию, например через Vietoris-Rips complex с фиксированным max_edge_length. После трансформации мы сравниваем статистику - гистограмму lifetime'ов, распределение Betti-чисел - с эталонным профилем, полученным на валидационном сете. Если расстояние между персистентными диаграммами, скажем Wasserstein distance, превышает порог, значит коллизия признаков. Это сигнал остановить пайплайн или адаптивно переобучить мета-модель.
Примерно так это выглядит в streaming-режиме на псевдокоде с Giotto-TDA:
import giotto_tda as gt
import numpy as np
from scipy.stats import wasserstein_distance
ref_diagram = ... # shape (n_points, 3) — [birth, death, dimension]
def detect_collision(stream_batch, ref_diag, threshold=0.1):
tda_transformer = gt.diagrams.VietorisRipsPersistence(max_edge_length=5.0)
batch_diag = tda_transformer.fit_transform(stream_batch)
w_dist = wasserstein_distance(batch_diag[0][:,0], ref_diag[:,0],
batch_diag[0][:,1], ref_diag[:,1])
return w_dist > threshold
max_edge_length в TDA-трансформере или переключаемся на запасную модель. Это дает trade-off между latency детекции и частотой false positives.
Attribution Maps для бустинга в real-time: как не упасть в latency
Интерпретируемость в продакшене красива ровно до тех пор, пока ты не считаешь SHAP на batch из тысячи объектов и не видишь latency за десятки миллисекунд. При 10k+ RPS и бюджете времени меньше 10 мс пакетный SHAP или LIME просто не влезают. Главная ошибка — пытаться посчитать полные атрибуции для каждого запроса, не думая о компромиссах между точностью и скоростью.
Три подхода к online-атрибуции
Первый — TreeSHAP. Самый точный, но по сложности O(T*D*L). Для CatBoost с 500 деревьями и глубиной 8 это уже 100-200 мкс на объект. Можно кэшировать path-dependent градиенты, но это всё ещё тяжело.
Второй — Fast SHAP approximation через expected gradients или Gradient SHAP. Работает за O(T*D) — на порядок быстрее. Теряешь в точности, но для большинства production задач разница не принципиальна.
Третий — surrogate LIME. Строишь линейную модель на лету вокруг запроса на выборке из 100-200 объектов. Время O(k*T*D), можно распараллелить по строкам.
Как контролировать latency
Самый надёжный способ — адаптивный таймаут:
class OnlineAttributor:
def __init__(self, model, latency_budget_ms=5):
self.model = model
self.budget = latency_budget_ms / 1000
async def get_attribution(self, features):
shap_values = await asyncio.to_thread(
self._tree_shap, features, timeout=self.budget
)
if shap_values is None:
shap_values = await asyncio.to_thread(
self._global_importance, features
)
return shap_values
predict с pred_contrib=True — он дешевле и точнее.
Почему дорогая LLM дороже: экономика инференса, которую видно в твоём 5-часовом лимите
Каждый, кто работал с Claude или ChatGPT, сталкивался с лимитами и задавался вопросом, как один запрос может съесть 10% от лимита. Автор потратил неделю на изучение того, что отображают эти лимиты, и написал третью статью из серии «А как вообще работают современные LLM». После её прочтения вы узнаете, что скрыто за 5-часовым лимитом Claude и других LLM, из каких примитивов состоят лимиты и какая физика вычислений за этим стоит. Для тех, кто работает с моделями по API, материал особенно полезен.
Читать на Habr
Большие языковые модели используют в поддержке клиентов. Но одно дело — чат-бот, который отвечает общими и прописанными фразами, и совсем другое — система, которая действительно работает с документами компании и может находить точные ответы.
✅ На открытом уроке разберём, как устроены современные решения на базе LLM, почему они не просто генерируют текст, а используют знания компании для подготовки ответов, и как такие инструменты помогают ускорять обработку обращений.
На практических примерах покажем, как документы превращаются в базу знаний для ИИ, как происходит поиск информации по запросу клиента и каким образом LLM помогает оператору поддержки готовить ответы. Также поговорим о том, как подобные решения применяются в бизнесе уже сегодня.
Урок пройдёт 6 июля в 20:00 МСК в преддверии старта курса «LLM-инженер». Это возможность познакомиться с современным подходом к созданию интеллектуальных сервисов, задать вопросы эксперту и понять, как внедрять подобные решения в реальные процессы компании.
➡️ Регистрация открыта: https://vk.cc/cZhu8X
Реклама. ООО «Отус онлайн-образование», ОГРН 1177746618576, www.otus.ru
Streaming-адаптация контекстных бандитов с мета-обучением на сдвигах распределения в recommendation serving
Реальные рекомендательные сервисы живут в условиях distribution shift — пользовательские паттерны, тренды и поведение ботов постоянно меняются. Классические контекстные бандиты вроде LinUCB или NeuralUCB требуют полного ретренинга на исторических данных, и в стриминговой среде это превращается в дорогой и нестабильный процесс. Основная ошибка — полагаться на статичные модели, которые не справляются с non-stationary окружением, где online-learning через SGD страдает от дрейфа градиентов.
Почему это critical для production ML
В реальных системах — новостные ленты, e-commerce, реклама — распределение наград и контекстов дрейфует каждые несколько часов. Полный ретренинг LinUCB на всем датасете каждые 10 минут непозволительно дорог: latency растет, метрики проседают. Online-обновления через stochastic gradient descent могут стабилизироваться, но после резкого сдвига (например, алгоритмической смены аудитории) градиенты рассинхронизируются, и CTR резко падает. Вместо этого нужна быстрая адаптация с минимальным числом шагов — мета-обучение здесь дает explicit преимущество.
Архитектура: MAML-style мета-обучение на потоке данных
Подход заключается в комбинировании прошлых сдвигов как отдельных tasks. Каждый новый батч с shift-зашумленными данными — это отдельная задача. Мета-сеть (например, RNN) кодирует историю контекстов и наград, а для адаптации используется один градиентный шаг на стриминговых данных с регуляризацией на предыдущие сдвиги. Вот упрощенный код на JAX:
def meta_update(theta, batch_context, rewards, alpha=0.01):
inner_grad = grad(loss_function)(theta, batch_context, rewards)
theta_adapted = theta - alpha * inner_grad
meta_grad = grad(loss_function)(theta_adapted, batch_context, rewards)
return theta - 0.1 * meta_grad
Когда GBDT молча убивает качество: детекция дрейфа на уровне дерева
Ловили такое: модель на продакшене внезапно начинает выдавать аномалии, а метрики ещё зелёные? Концептуальный дрейф в GBDT подкрадывается незаметно. Распределения признаков сдвигаются, и те самые "умные" границы разбиения становятся источником ошибок. Классический мониторинг F1 или AUC срабатывает как CHECK ENGINE — когда уже всё горит. Но есть способ для тех, кто копает глубже: смотреть статистики разбиения деревьев прямо в serving-пайплайне.
Идея: дрейф на уровне узлов дерева
GBDT принимает решения через цепочку бинарных разбиений (split point). Каждое дерево фиксирует конкретные границы на обучении. В онлайне мы считаем:
- сдвиг среднего значения в каждом узле относительно обучения;
- изменение дисперсии выборки, которая попадает в узел;
- резкое падение количества наблюдений на листьях (volume drop).
Реализация в serving-пайплайне
Добавляем в serving-пайплайн сбор статистик по каждому дереву (среднее, std, объём выборки). На каждом батче считаем агрегаты и сравниваем с эталоном через Hellinger distance или CUSUM. Находим "больные" деревья — те, где дрейф превышает порог.
def detect_tree_drift(model, X_online, threshold=3):
leaf_indices = model.predict(X_online, pred_leaf=True)
drift_scores = []
for tree_id in range(leaf_indices.shape[1]):
freq = np.bincount(leaf_indices[:, tree_id], minlength=model.num_leaves())
train_freq = model._Booster.dump_model()['tree_info'][tree_id]['leaf_freq']
h = np.sqrt(np.sum((np.sqrt(freq/sum(freq)) - np.sqrt(train_freq))**2))
drift_scores.append(h)
bad_trees = np.where(np.array(drift_scores) > threshold)[0]
return bad_trees
Эффективное управление memory footprint в serving через on-the-fly разреженное квантование attention-карт в трансформерах
В production часто упираешься в memory footprint при работе с трансформерами — BERT, GPT, T5. Основной пожиратель памяти — attention-карты после softmax, особенно на длинных последовательностях. Стандартные решения (pruning, сжатие) требуют предобучения. Но есть метод, работающий на лету прямо в serving: разреженное квантование attention-карт. Главная ошибка — думать, что для экономии памяти нужен дорогой ретренинг.
Идея и экономия памяти
После softmax attention-карты содержат множество значений, близких к нулю. Можно отбросить все ниже порога (sparse), а оставшиеся квантовать в int8. Это выполняется on-the-fly, без переобучения. Типичная карта [batch=1, heads=16, seq_len=2048] в float32 весит 256 MB на запрос. После отбрасывания 90% значений и квантования в int8 получаем около 6.4 MB. Нагрузка на GPU падает, latency почти не растет при корректной реализации.
Пример production-ready кода
Реализуйте разреженное квантование как часть пайплайна:
def sparse_quant_attention(Q, K, V, threshold=0.01):
attn = torch.matmul(Q, K.transpose(-2, -1))
mask = attn > threshold
attn_sparse = attn * mask
scale = 127.0 / (attn_sparse.max() - attn_sparse.min() + 1e-8)
attn_quant = (attn_sparse * scale).to(torch.int8)
attn_deq = attn_quant.float() / scale
return torch.matmul(attn_deq, V)
Memory-Aware Balanced Gradient Dropping for Distributed Training Under GPU Memory Constraints
Вы запускаете distributed training на двух картах, модель чуть больше обычной, и OOM на ровном месте. Синхронизация градиентов между устройствами съедает память быстрее, чем вы успеваете залогировать потери. Пробуете топ-K сжатие или рандомный dropout градиентов, но качество начинает плавать — важные обновления теряются, особенно на редких фичах. Ошибка: думать, что сжатие градиентов всегда решает проблему памяти без потерь.
Почему random dropping не работает в production
Рандомное отбрасывание градиентов в DDP нарушает сходимость на разреженных признаках — в рекомендательных системах или NLP с редко встречающимися токенами вы просто не дотягиваете до конвергенции. Практический совет: заменяйте random на memory-aware balanced gradient dropping. Он не выкидывает градиенты рандомно, а урезает их с учетом бюджета памяти и того, насколько каждый градиент критичен для текущей итерации. Типичная ошибка: применять top-K ко всем слоям одинаково — это убивает градиенты на глубоких слоях, где они уже малы по норме.
Как это работает инженерно
Сначала оценивается важность каждого градиента по его вкладу в loss — через второй момент (adaptive normalization). Слои с резкими выбросами (spikes) получают приоритет. Потом фиксированный бюджет (например, 30% всех градиентов) распределяется между воркерами не поровну, а с учетом RTT между устройствами и фрагментации GPU, чтобы никто не простаивал и не отсылал пустые тензоры. Вместо хранения полного тензора используется кольцевой буфер на оставшиеся 30%. Остальное отбрасывается, но частота обновления ключевых параметров остается выше порога.
# Псевдокод для memory-aware dropping
importance = compute_importance_by_second_moment(gradients)
budget_per_worker = allocate_budget_by_rtt(importance, memory_fragmentation)
buffer = ring_buffer(top_k_by_importance(gradients, budget_per_worker))
communicate(buffer)
Гетерогенная байесовская оптимизация с учётом стохастической задержки пайплайнов
Когда production-пайплайн висит на внешних сервисах, а время выполнения каждой конфигурации — случайная величина, классическая BO отваливается. Модели загружаются с непредсказуемой задержкой, данные приходят рывками, а игнорирование стохастической задержки артефактов ведёт к bias: BO начинает выбирать быстрые, но неоптимальные конфигурации, которые успевают вернуться раньше.
Явное моделирование задержки
Вместо единого GP используем два: один для метрики, второй — для логарифма времени завершения. Это позволяет явно оценивать, сколько ждать артефакт от конкретного типа модели. Гетерогенность реализуется через разные ядра: например, для тяжёлых NLP-моделей с latency ~5 мин ядро RBF с length-scale ~1 мин, для лёгких — Matern с меньшим масштабом. В MLOps это даёт стабильную оценку времени, независимо от текущей загрузки GPU-нод.
Асинхронное обновление с параллелизмом
Когда артефакт не готов, BO запускает следующую конфигурацию, ограничивая параллелизм через пул воркеров (скажем, 4 процесса, чтобы не уронить prod). После завершения каждого запуска метрика обновляется с весами, обратно пропорциональными задержке: w_i = exp(-t_i / tau). Это штрафует медленные конфигурации меньше, чем пропуск данных, и не теряет информацию даже от 10-минутных запусков. trade-off: приходится решать, какой tau выбрать — слишком большой уменьшит ли bias, слишком маленький начнёт игнорировать долгие, но ценные точки.
Типичная ошибка и практический совет
Ошибка: ожидать, что BO сходится так же быстро, как на синтетике, при latency в 5-15 раз выше медианы. На практике bias в сторону быстрых конфигураций убивает поиск за 20-30 итераций. Совет: в production всегда логируйте не только метрику и конфигурацию, но и timestamp начала и конца. Инкрементально обновляйте GP времени через scipy.optimize каждые 10 запусков — это улучшает предсказание latency на 15-20% без переобучения всего пайплайна. Источники: Shahriari et al. «Taking the Human Out of the Loop», Snoek et al. «Practical Bayesian Optimization», Klein et al. «Fast Bayesian Optimization».
Вывод: Явное моделирование стохастической задержки артефактов через гетерогенные GP и асинхронное обновление с весами по времени — единственный способ избежать bias в BO при production-запусках с непредсказуемой latency.
Автоматическое разрешение конфликта градиентов в MTL: Hessian-aware projection
Многозадачное обучение в production рекомендательных системах — это не про теорию, а про constant борьбу с interference между целями. Улучшаешь watch time — падает like rate. PCGrad и GradVac режут конфликты векторно, но игнорируют кривизну ландшафта: после их проекции loss второй задачи может вырасти на следующем шаге, особенно при невыпуклом ландшафте.
Что не так с существующими подходами
PCGrad просто проецирует градиент одной задачи на нуль-пространство градиента другой. Это работает, но только если ландшафт локально линеен. В реальности loss-функции рекомендательных моделей — например, бинарная кросс-энтропия для CTR и регрессия MSE для времени просмотра — имеют разную кривизну. Результат: чистое снижение конфликта на одном шаге не гарантирует сходимость обеих задач. Например, на YouTube DCN с двумя тасками GradVac дает прирост AUC на 0.2% на основном таргете, но retention падает из-за скрытого interference.
Hessian-aware projection: идея и реализация
HAP решает это квадратичным приближением потери задачи B: ищем проекцию градиента A, которая минимизирует loss A, не повышая и не уменьшая loss B. Формально это сводится к линейному ограничению с гессианом \( H_B \): \( g_A'^T H_B g_A' \le 0 \). В online-режиме используем Fisher Information Matrix как диагональную аппроксимацию гессиана — это практично для прода, хоть и снижает точность.
Ключевой код упрощенной версии:
def hessian_aware_projection(grad_A, grad_B, hessian_diag_B, epsilon=1e-8):
g_dot = (grad_A * hessian_diag_B * grad_B).sum()
gB_norm_sq = (grad_B * hessian_diag_B * grad_B).sum() + epsilon
if g_dot > 0:
projection = grad_A - (g_dot / gB_norm_sq) * (grad_B * hessian_diag_B)
return projection
return grad_A
Почему ваша GNN «слепнет» на разреженных узлах и как это исправить
Многоуровневые GNN в production сталкиваются с жестким искажением обучения: плотные кластеры графа доминируют над градиентным потоком, оставляя разреженные узлы необученными. Это не просто вопрос точности — это системный сдвиг, который ломает рекомендации для cold-start, аномалии в транзакциях и эмбеддинги для редких сущностей.
Корень дисбаланса: неоднородность градиентов
Плотные узлы с высокой степенью связи дают сильные, стабильные градиенты за счет агрегации большого числа соседей. Разреженные узлы генерируют слабые, зашумленные обновления, которые на глубоких слоях затухают. Static loss weighting не работает: граф динамичен, плотность меняется со временем (виральные пики, сезонность). Градиентное отсечение по норме уничтожает редкие сигналы. Модель переобучается на «модных» кластерах и теряет важные паттерны — от аномалий до нишевых групп.
Динамическая ребалансировка градиентов: production-подход
Решение — модулировать градиенты на уровне узлов, а не глобально. Рассматриваю градиенты как распределение: на каждом шаге оцениваю плотность окрестности (degree-нормализация) и корректирую обновления. Для разреженных узлов — усиление сигнала, для плотных — контролируемое подавление, избегая перекоса. Пример агрессивной аппроксимации:
# После backward(), до optimizer.step()
for name, param in model.named_parameters():
if 'weight' in name:
degree = torch.sum(adj, dim=1)
weight_factor = 1.0 / (degree + 1).float()
weight_factor = weight_factor / weight_factor.mean()
param.grad *= weight_factor[:batch_size].unsqueeze(1).unsqueeze(1)
Adversarial Feature Masking для поиска Data Leakage в скользящем окне валидации
Стандартное скользящее окно режет temporal leakage, но не ловит information leakage — когда признаки несут информацию из будущего через лаги, агрегаты или lookahead-паттерны. Главная ошибка: полагаться на визуальный анализ feature importance или корреляции, которые маскируют тонкие утечки.
Как работает adversarial feature masking
Обучаем модель-атакующий, которая маскирует случайный набор фич и максимизирует потери на валидации. Если после маскировки метрика (AUC, MAE, logloss) падает аномально сильно — это индикатор leaky признаков. В production это дополнительный слой проверки: adversarial-компонент намеренно ломает временные корреляции, которые модель могла «подглядеть» через случайные шумы.
Production-реализация
Встраиваете adversarial-проверку как cron-задачу в CI/CD пайплайн переобучения. Пример из фрод-мониторинга: train_time_based_model каждый день с TimeSeriesSplit, затем adversarial autoencoder маскирует 20% фич и измеряет разницу в ROC-AUC. Если разрыв >2% — останавливаете деплой и проверяете признаки на lookahead bias через rolling window validatation с shift.
Типичная ошибка
Использовать feature importance из XGBoost или SHAP для детекции leakage — они показывают вклад фичи на полных данных, а не на masked-срезах. Adversarial маска намеренно создает пропуски в корреляционной структуре, выявляя скрытые зависимости от будущего. Если модель резко деградирует без конкретного признака — он почти наверняка содержит future info.
Практический совет
Добавьте проверку на variance метрики при множественных маскировках: высокое std — повод насторожиться. В финтехе это ловит фичи вроде «средняя сумма за последние 30 дней», где агрегат включает target из будущего при скользящем окне. Алгоритм:
import numpy as np
from sklearn.model_selection import TimeSeriesSplit
def adversarial_leakage_check(X, y, model, mask_ratio=0.2):
scores = []
tscv = TimeSeriesSplit(n_splits=5)
for train_idx, test_idx in tscv.split(X):
X_train, X_test = X[train_idx], X[test_idx]
mask = np.random.binomial(1, 1-mask_ratio, X_train.shape)
X_masked = X_train * mask
model.fit(X_masked, y[train_idx])
score_full = model.score(X_test, y[test_idx])
scores.append(score_full)
return np.std(scores) # высокое std — повод насторожиться
Динамическое согласование Batch Normalization: как не «сломать» модель при дрейфе данных
Batch Normalization реально ускоряет сходимость на обучении, но в production он превращается в скрытую ловушку при нестационарном дрейфе. Самая частая ошибка — доверять замороженным скользящим средним, даже когда распределение данных уже изменилось.
Почему стандартный BN ломается при дрейфе
На обучении BN считает mean и var по текущему батчу. На инференсе использует замороженные скользящие средние, накопленные в процессе тренировки. Пока данные стабильны — все работает. Как только distribution начинает дрейфовать (non-stationary drift), эти фиксированные статистики перестают соответствовать реальным данным. Внутренние представления сдвигаются — метрики падают, а вы долго ищете причину. Пример из практики: модель детекции аномалий на временных рядах после ретрайна на новом режиме «запомнила» его в BN-слоях. Когда на инференсе данные вернулись к старому паттерну, статистики BN стали невалидными — false positive rate пополз вверх, хотя сама модель не переобучалась.
Адаптивное обновление на инференсе
Вместо жесткой фиксации скользящих средних можно внедрить динамическую корректировку прямо на инференсе. Идея: измеряем дрейф через normalized deviation (Z-score) и регулируем скорость обновления. Чем сильнее расхождение текущего батча с накопленными статистиками, тем быстрее адаптируем BN. Пример на PyTorch: заменяем фиксированный momentum на drift_factor, который увеличивается при большом отклонении (batch_mean - running_mean) / std.
class AdaptiveBatchNorm(nn.BatchNorm1d):
def __init__(self, num_features, eps=1e-5, momentum=0.1, drift_factor=0.5):
super().__init__(num_features, eps)
self.base_momentum = momentum
self.drift_factor = drift_factor
def forward(self, x):
if self.training:
return super().forward(x)
with torch.no_grad():
batch_mean = x.mean(dim=(0,))
batch_var = x.var(dim=(0,), unbiased=False)
drift = torch.abs((batch_mean - self.running_mean) / (torch.sqrt(self.running_var + self.eps) + 1e-8))
adaptive_momentum = self.base_momentum * (1 + self.drift_factor * drift.mean())
self.running_mean = (1 - adaptive_momentum) * self.running_mean + adaptive_momentum * batch_mean
self.running_var = (1 - adaptive_momentum) * self.running_var + adaptive_momentum * batch_var
return super().forward(x)
В профильных сообществах расходится интересное приглашение на крупный ML-ивент. Проверить, куда зовут комьюнити, можно одной командой в консоли.
python3 -c "Читать полностью…
import base64, time, sys
cap = '\n\n⠀⠀⠀⠀⠀⢀⣤⣴⣶⣶⣿⣿⣦⣤⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n⠀⠀⠀⢀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n⠀⠀⢀⣿⠟⠉⠉⠉⠙⠛⠿⠟⠋⠉⠉⠉⠙⢿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n⠀⣠⢼⡇⠀⠀⠀⣀⡀⠀⠀⠀⠀⣀⡀⠀⠀⠈⣷⣤⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀\n⣼⠁⠸⣇⠀⠀⢸⣿⣷⠀⠀⠀⢸⣿⣿⠀⠀⢠⡟⠀⢻⠀⠀⣀⣀⣀⣀⠀⠀⠀\n⠸⣦⣰⣿⣦⡀⠈⠉⠁⢠⡀⣄⠀⠉⠁⢀⣠⣿⣧⣠⠞⢠⣿⠟⠛⠛⠻⣿⣆⠀\n⠀⠀⢹⣿⣿⡏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⡿⠁⠀⣿⣏⠀⣸⣷⠀⠸⣿⡆\n⠀⠀⠀⠹⣿⣇⠀⠛⠿⢿⣿⣿⠿⠟⠀⢠⣿⣿⣧⣄⠀⠘⠿⣿⠿⠋⠀⠀⣿⣇\n⠀⠀⠀⠀⠈⠛⠷⢄⣀⠀⠀⠀⢀⣀⣴⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠀⠀⠀⣹⣿\n⠀⠀⠀⠀⠀⠀⠀⢀⣿⣿⣿⡿⢁⡀⠀⠙⠿⣿⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⣿⡏\n⠀⠀⠀⠀⠀⠀⠀⣾⣿⣿⡟⣵⣿⣿⣷⣦⠀⠹⣿⣿⣿⣿⣿⡆⠀⠀⠀⢀⣿⡇\n⠀⠀⠀⠀⠀⠀⢸⣿⣿⡟⢰⣿⣿⣿⣿⣿⣷⡀⣿⣿⣿⣿⣿⣇⠀⠀⠀⣸⣿⠁\n⠀⠀⠀⠀⠀⢀⣿⣿⡟⠀⢸⣿⣿⡿⣿⣿⣿⣷⢸⣿⣿⣿⣿⡿⠀⠀⢠⣿⠇⠀\n⠀⠀⠀⠀⠀⠘⢿⡟⠀⠀⢸⠿⢿⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣤⣴⡿⠋⠀⠀\n⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠿⠿⣿⣿⠿⠿⠟⠉⠉⠋⠉⠀⠀⠀⠀\n > Обнаружен зашифрованный пакет...'
print(cap)
time.sleep(1)
sys.stdout.write(' > Декодирование')
sys.stdout.flush()
for _ in range(5):
time.sleep(0.4)
sys.stdout.write('.')
sys.stdout.flush()
encrypted = '0J/RgNC40LPQu9Cw0YjQsNC10Lwg0L3QsCBUdXJibyBNTCBDb25mCjE4INC40Y7Qu9GPLCDQlNCaINCh0LXRgNC/INC4INCc0L7Qu9C+0YI='
decrypted = base64.b64decode(encrypted).decode('utf-8')
print('\n\n ' + decrypted.replace('\n', '\n ').replace('Â', ''))
print('\\n > Обезьянка подмигнула и ушла готовиться.')
"
Мониторинг и отладка "тихого" дрейфа токенов в LLM-пайплайнах: частотный анализ эмбеддингов без ground truth
LLM-пайплайн внезапно начинает выдавать странные ответы, хотя accuracy не падает и latency не растет. Стандартные метрики молчат, а responses становятся откровенно левыми. Часто виновник — "тихий" дрейф токенов: незаметное смещение распределения эмбеддингов без ground truth. Причины: обновили модель, сменили токенизатор, или входные данные тихо уехали в сторону.
Спектральный анализ эмбеддингов через FFT
Один из рабочих подходов — спектральный анализ без разметки, только статистика. Собираю эмбеддинги из production логов (каждый запрос — вектор 768 или 1024 размерности). Снижаю размерность до 50–100 компонент через PCA — не ради визуализации, а чтобы убрать шум и оставить главное. Для каждой компоненты считаю FFT — получаю спектр мощности. Мониторю сдвиг пиковых частот: например, смотрю отношение энергии в низких частотах (0.1–0.3 Гц) к высоким. В стабильном состоянии спектр держит паттерн: 60% энергии на низких частотах. После обновления пик смещается на 0.5 Гц — это сигнал.
import numpy as np
from scipy.fft import fft
def detect_drift(embeddings_batch, baseline_spectrum, threshold=0.15):
avg_embed = np.mean(embeddings_batch, axis=0)
spectrum = np.abs(fft(avg_embed))[:len(avg_embed)//2]
spectrum = spectrum / np.sum(spectrum)
diff = np.abs(spectrum - baseline_spectrum)
drift_score = np.max(diff)
return drift_score > threshold
Adaptive Gradient Thresholding: почему фиксированный gradient clipping убивает deep RecSys при дрейфе фидбэка
Когда user feedback distribution резко меняется — вирусный пост, сбой в logging pipeline или сезонный скачок — deep recommendation модель получает аномальные градиенты. Стандартный gradient clipping с порогом 1.0 либо режет все градиенты, замедляя сходимость, либо пропускает выбросы, и loss улетает в стратосферу. Проблема в том, что порог один на все параметры и не адаптируется к текущей статистике.
Как работает adaptive gradient thresholding
Идея: для каждого параметра (или слоя) ведем скользящие среднюю и стандартное отклонение нормы градиента. Порог клиппинга — это mean + k * std. Если норма градиента выше порога, режем до порога. Это не тормозит нормальные градиенты, но изолирует аномалии.
Пример на PyTorch:
class AdaptiveGradientClipping:
def __init__(self, model, k=4.0, alpha=0.99):
self.k = k
self.alpha = alpha
self.running_mean = {}
self.running_std = {}
def step(self):
for name, param in model.named_parameters():
if param.grad is None:
continue
g_norm = param.grad.norm().item()
if name not in self.running_mean:
self.running_mean[name] = g_norm
self.running_std[name] = g_norm
continue
self.running_mean[name] = self.alpha * self.running_mean[name] + (1 - self.alpha) * g_norm
self.running_std[name] = self.alpha * self.running_std[name] + (1 - self.alpha) * abs(g_norm - self.running_mean[name])
threshold = self.running_mean[name] + self.k * self.running_std[name]
if g_norm > threshold:
param.grad.mul_(threshold / (g_norm + 1e-8))
В шортсах ИИ-демки выглядят роскошно. Тык-тык — и всё готово.
А как пробуешь сделать так же, всё разваливается на втором запросе. Подписок уже на пять сервисов, токены улетают пачками, счёт капает, а результата так и нет. И непонятно: это ты что-то делаешь не так или инструмент просто сырой?
На ТАТАР САН про это и говорят: что работает, на что не стоит сливать бюджет и токены и как выстроить процесс, который не отвалится через неделю.
📍26-27 июня, ИТ-парк им. Башира Рамеева, Казань
Что внутри:
🔵 Кейсы от практиков из Сбера, МТС, Альфа-Инвестиций и других — архитектура, ошибки, что реально пошло в прод;
🔵 Пять треков: трансформация ролей, найм, дизайн, инженерные практики;
🔵 Хедлайнеры — Вячеслав Дубынин (МГУ) и Владимир Пирожков (Сбер);
🔵 В спикерах — те, кто сам затаскивал ИИ в продакшн. К ним можно подойти и спросить конкретно по своей задаче, посоветоваться и унести практические советы в свою компанию;
🔵 Нетворкинг, конкурс «Королева кода» и афтерпати под «Не могу остановиться» КАССЕТЫ.
Отвечаем, ты уже слил на подписки и токены больше, чем стоит вход:
⚡️ С промокодом TSFIRST билеты сейчас всего по 1 000 ₽
(Скоро цена вырастет, так что успевай, пока такая халява. Мы предупредили!)