19745
Все о Data Science, машинном обучении и искусственном интеллекте: от базовой теории до cutting-edge исследований и LLM. MAX - https://max.ru/devsp Личный блог - @just_genych По вопросам рекламы или разработки - @g_abashkin РКН: https://vk.cc/cJPGXD
Оптимизация распределённого жадного поиска сплитов в GBDT: вычислительное бюджетирование
Узкое место любого GBDT под нагрузкой — жадный поиск порогов для сплитов. Когда фич под тысячу, а строк миллионы, строить гистограмму для каждой на каждом узле — взрывной рост стоимости. В распределённой среде это буквально трата времени на заведомо мусорные сплиты.
Почему это важно
В production ML сценариях с высокой нагрузкой — мониторинг потоковых данных, обучение на Spark, CatBoost в real-time — каждая микросекунда на сплит умножается на число фич и глубину дерева. Типичная ошибка: строить плотные гистограммы для всех фич, даже для разреженных или с низким градиентом, что перегружает сеть и CPU.
Идея вычислительного бюджетирования
Вводим явный бюджет — лимит на количество гистограмм на узел. Фичи ранжируются по эвристике: сумма абсолютных градиентов или плотность ненулевых значений. Для разреженных фич это особенно спасает — они отсекаются до передачи данных между узлами.
Пример псевдокода для распределённой среды:
def allocate_budget(feature_stats, budget=100):
scores = {feat: len(nonzero_grads[feat]) for feat in feature_stats}
sorted_feats = sorted(scores, key=scores.get, reverse=True)
return sorted_feats[:budget]
for tree_level in range(max_depth):
budget_features = allocate_budget(current_stats)
parallel_build_histograms(budget_features)
Многорукие бандиты с контекстом в микросервисном ранжировании: как не утонуть в сервисах
Цепочка ранжирования из нескольких сервисов — это не просто конвейер, а распределенная задача обучения с подкреплением. Типичная ошибка — внедрять бандита на каждый узел изолированно, не учитывая сквозной эффект решений.
Проблема фрагментации контекста
Каждый микросервис видит только свой контекст: сервис кандидатов знает время суток, сервис фич — нагрузку на вычисления, модель — идентификатор эксперимента, пост-процессинг — бизнес-правила. Если каждый из них принимает жадное или исследовательское решение независимо, цепочка накапливает шум. Контекстуальный бандит на финальном шаге бесполезен, если предыдущие узлы сгенерировали нерепрезентативные состояния.
Архитектура с общей обратной связью
Рабочее решение — цепочка контекстуальных бандитов, где каждый узел получает два сигнала: глобальное вознаграждение (например, CTR) и локальное (latency, ошибки). Ключевой трюк — смешивание их с фиксированными весами:
reward = 0.7 * глобальный CTR + 0.3 * локальный latency
Пример из продакшена:
- Сервис A выбирает источники кандидатов. Контекст: время суток, тип устройства.
- Сервис B решает, делать полный пересчет фич или инкрементальный — это влияет на latency и свежесть признаков.
- Сервис C выбирает версию модели: explore или exploit.
- Сервис D применяет бусты/дебусты в пост-процессинге.
Все бандиты работают с одним session_id (через OpenTelemetry) для трекинга сквозных цепочек. Вознаграждение выплачивается после завершения всего запроса — используем delayed rewards и policy gradient, а не Q-learning, чтобы избежать переобучения на локальных паттернах.
Практический совет и типичная ошибка
Реализация элементарна: класс с альфой на скользящее среднее и двумя полями под reward. Но главное — не добавлять бандита на узел, который не генерирует измеримого изменения в глобальной метрике. Иначе получите фоновый шум, который только увеличит variance A/B теста.
В нашем эксперименте на production: рост CTR +15% при сохранении latency. Сервисы остались автономными, но скоординированными. Добавление нового узла в цепочку — просто протянуть session_id и определить свой reward.
Вывод: Цепочка контекстуальных бандитов с общей обратной связью позволяет координировать микросервисы ранжирования без нарушения их изоляции, но требует явного смешивания глобальных и локальных метрик для стабильного обучения.
Как измерять качество рекомбинации сплитов в GBDT при динамическом изменении числа деревьев в serving: три online-метрики для production
Когда в serving число деревьев меняется динамически — из-за A/B-тестов, калибровки композитных моделей или адаптации под latency — классические offline-метрики пасуют. Рекомбинация сплитов, где разные деревья используют одинаковые разбиения, становится скрытой точкой отказа, которую не видят ни AUC, ни logloss.
Gradient Overlap
Процент деревьев с совпадающими сплитами по признакам на последовательных итерациях. Если overlap выше 70%, рекомбинация стабильна. Ниже — шум или переобучение. Практический совет: используйте Gradient Overlap как триггер остановки добавления деревьев — при overlap менее 50% увеличение числа деревьев скорее всего ухудшит обобщение.
Split Importance Drift
Корреляция Спирмена топ-10 сплитов по gain при n и n+1 деревьях. Дрифт выше 0.3 — сигнал к корректировке hyperparams. Типичная ошибка: игнорировать этот дрифт и продолжать наращивать деревья, ухудшая как latency, так и качество.
Online-AUC на скользящем окне
AUC на окне из N=20 деревьев показывает, как рекомбинация влияет на качество при добавлении или удалении. Production-oriented пример: в fraud detection или real-time bidding каждое дерево критично — online-AUC выявляет момент, когда рекомбинация ломается, раньше, чем offline-метрики.
import numpy as np
from scipy.stats import spearmanr
class OnlineSplitTracker:
def __init__(self, window_size=20):
self.window_size = window_size
self.split_gains = []
def update(self, tree_splits, tree_gains):
self.split_gains.append(tree_gains)
if len(self.split_gains) > self.window_size:
current_top = self._get_top_features(-1)
prev_top = self._get_top_features(-2)
drift, _ = spearmanr(current_top, prev_top)
print(f"Split drift: {drift:.3f}")
def _get_top_features(self, idx):
agg = {}
for gains in self.split_gains[idx]:
for feat, gain in gains.items():
agg[feat] = agg.get(feat, 0) + gain
return sorted(agg.keys(), key=lambda x: agg[x], reverse=True)[:10]
Оптимизация калибровки GBDT в production: как адаптировать вероятности через мониторинг скользящих ошибок второго типа
Калибровка вероятностей моделей градиентного бустинга — это не разовая операция, а непрерывный процесс. На serving-данных распределения целевой переменной и признаков могут дрифтовать, и статичная калибровка, выполненная на валидации, теряет точность. Типичная ошибка — полагаться на среднюю log-loss или Brier score, не отслеживая динамику ошибок по классам.
Почему FN rate — а не accuracy или AUC
В задачах с дисбалансом классов (fraud detection, CVR, credit scoring) глобальные метрики скрывают ухудшение калибровки для редкого класса. FN rate — доля false negatives среди реальных положительных — чувствителен к дрифту, при котором модель начинает систематически недооценивать вероятность редкого события. Именно это поведение критически важно для бизнеса: пропущенный фрод или кредитный дефолт обходится дороже, чем ложное срабатывание.
Как организовать мониторинг и онлайн-коррекцию
Процесс выглядит так:
* На этапе обучения фиксируем эталонный FN rate на валидации при пороге 0.5.
* В serving каждые N инференсов (например, 10 тыс. событий) собираем буфер сырых предсказаний (raw logits) и реальных меток (с учетом лагов агрегации).
* На каждом окне вычисляем текущий FN rate.
* Если отклонение от эталона превышает порог δ (например, 0.05), запускаем адаптивную калибровку на буфере.
Реализация Platt scaling на лету
Лучший выбор — логистическая регрессия с L2-регуляризацией на буфере последних logits. Алгоритм:
* Хранится FIFO-буфер buffer_preds (raw scores) и buffer_labels.
* При достижении окна window_size=10000 старые элементы вытесняются.
* Функция check_drift(ref_fn_rate) вычисляет FN rate в окне:
buffer_preds, buffer_labels = [], []
window_size = 10000
def update(raw_pred, true_label):
buffer_preds.append(raw_pred)
buffer_labels.append(true_label)
if len(buffer_preds) > window_size:
buffer_preds.pop(0)
buffer_labels.pop(0)
def check_drift(ref_fn_rate):
fn = (np.array(buffer_preds) > 0.5) & (np.array(buffer_labels) == 0)
current_fn = fn.sum() / max((labels == 0).sum(), 1)
if abs(current_fn - ref_fn_rate) > 0.05:
calibrator.fit(buffer_preds, buffer_labels)
Почему дорогая 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)
Вечная тема: все хотят "говорить свободно", но в реальном диалоге впадают в ступор через 10 секунд. А продаваны курсов с радостью впарят тебе очередного "AI-тьютора", который отвечает через раз и с пятисекундной задержкой.
Парень на Хабре расписал свою Aisha — голосового репетитора английского. Это не просто обертка над GPT. Он реально запарился над архитектурой: голосовой ввод, стриминг, обработка ошибок в моменте, чтобы диалог не превращался в пытку лагами.
Суть проблемы, которую он решил: если тупо гонять аудио туда-сюда через API, живой диалог сдохнет на старте. Надо обрабатывать поток, резать payload и не проваливаться в задержки под контекст. Это hard mode, а не игрушки.
Новичкам: не пытайтесь сразу клепать таких монстров. Сначала научитесь гонять через whisper одну фразу и получать осмысленный ответ. Это база.
Для тех, кто шарит: статья — норм разбор того, как резать лаги в реалтайм-диалоге. Полезно, если пилите своего бойца.
Динамический выбор алгоритма ветвления GBDT на основе аппаратных счетчиков производительности
Когда production GBDT внезапно начинает тормозить на одинаковой нагрузке, корень часто не в модели, а в том, как она ветвится на конкретном железе. CPU последовательно долбит if-else с промахами в prefetch, а GPU простаивает из-за дивергенции варпов. Типичная ошибка — фиксировать стратегию ветвления (left-heavy, depth-first) без учета аппаратных счетчиков.
Проблема статического ветвления
Классические GBDT-библиотеки (XGBoost, LightGBM) используют фиксированный порядок обхода дерева. На CPU это приводит к cache misses при холодных данных или branch miss penalties при нерегулярных паттернах. На GPU — к warp divergence, когда разные треды в варпе идут по разным веткам. В одном production-кейсе с XGBoost инференс на CPU vs GPU давал разницу в 2x из-за структуры дерева, хотя сама модель была идентична.
PMC-управляемое ветвление на CPU
Решение — на лету переключать алгоритм ветвления, используя Performance Monitoring Counters (PMC). Следим за:
- INSTRUCTIONS RETIRED — нагрузка на ядро;
- BRANCH MISS PREDICT — если >5%, переходим на предикаты (вычисляем обе ветки, выбираем результат);
- CACHE MISS (L1/L2) — при высоких значениях включаем prefetch и column-wise layout.
Пример: если cache miss >10% и branch miss predict <3%, оставляем row-wise traversal. Иначе — переключаемся на column-wise с prefetch-инструкциями через __builtin_prefetch. Мониторинг через libpfc или perf_event_open добавляет 1-3% overhead, но стабильно выигрывается 10-25% latency на стриминге.
GPU адаптация: occupancy и дивергенция
На GPU ключевой параметр — warp divergence. Порог — 30%: при превышении реорганизуем дерево в SIMD-дружественную структуру. Листья одного уровня упаковываются в плоский массив, а ветвление заменяется на gather/scatter из __shfl_sync. Работает через NVML или cupti для чтения счетчиков. Выигрыш 15-40% времени при стриминге, но портировать на ARM сложно (PMC там другие).
Trade-offs и ML-управление
Static-библиотеки не учитывают реальную нагрузку. Использование PMC добавляет overhead, но окупается на горячих путях. В production я добавил маленький регрессор (20 признаков из PMC) для предсказания оптимальной стратегии. Overhead тот же 1-3%, но точность подбора выше — снижает branch miss еще на 5%. Минус: портирование между x86 и ARM требует переписывать парсеры счетчиков.
Вывод:
Динамический выбор алгоритма ветвления GBDT на основе аппаратных счетчиков позволяет выжать 15-40% производительности на CPU и GPU, но требует учета overhead мониторинга и архитектурных различий.
Инференс и Unity в контейнере: почему X11 стал главным узким местом
Казалось бы, что сложного: в YTsaurus есть GPU, джобы пробрасывают /dev/nvidia0. Закинул бинарник, запустил, получил видео. На практике — всё сломалось на этапе инициализации.
Проблема: Window System Integration
Unity и Unreal Engine используют WSI. Даже для offscreen-рендеринга им нужен X11-сервер. Без него библиотеки XCB/Xlib не создадут VkSurfaceKHR — поверхность для финального кадра.
Цепочка зависимостей выглядит так:
1. Vulkan ICD обращается к /dev/dri/renderD128 для аллокации буферов.
2. Движок рендерит кадр и передаёт dma-buf в X-сервер через DRI3.
3. Xorg должен быть запущен с DDX-драйвером nvidia_drv.so на том же GPU.
4. Только тогда драйвер импортирует dma-buf и выведет их на виртуальный монитор.
Что нужно пробросить помимо /dev/nvidia0
Для ML достаточно трёх устройств. Для графики список шире:
/dev/nvidiactl
/dev/nvidia0
/dev/nvidia-uvm
/dev/nvidia-modeset
/dev/dri/card0
/dev/dri/renderD128
Автоматическая настройка аугментации для 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