# > Machine Learning Guru| > Admin Email: kdmbroker@gmail.com ## Записи ### EGARCH, TGARCH, FIGARCH для моделирования асимметричной волатильности Волатильность большинства финансовых активов ассиметрична: негативные шоки увеличивают ее сильнее, чем позитивные той же величины. Классическая модель GARCH (1,1) предполагает симметричный отклик условной дисперсии, что приводит к систематическим ошибкам в оценке риска и неэффективному хеджированию опционных позиций. Эконометрические модели EGARCH, TGARCH и FIGARCH расширяют базовую спецификацию GARCH, позволяя моделировать асимметричные эффекты и долгую память в волатильности. Эти модели применяются в расчете Value at Risk, ценообразовании деривативов и построении торговых стратегий на волатильности. Выбор между ними определяется характеристиками данных и целями моделирования. Асимметрия волатильности на финансовых рынках Эмпирические исследования фондовых индексов показывают отрицательную корреляцию между доходностью и последующей волатильностью. Падение S&P 500 на 2% увеличивает оцененную волатильность в среднем на 15-20%, тогда как рост на 2% снижает ее лишь на 8-10%. Этот эффект проявляется на различных временных масштабах: от внутридневных данных до месячных интервалов. Эффект левериджа и асимметричный отклик Классическое объяснение асимметрии — эффект левериджа (leverage effect). Снижение цены акций увеличивает соотношение долг/капитал компании, что повышает финансовый риск и волатильность. Рост цены производит обратный эффект меньшей величины. Альтернативная гипотеза волатильного фидбэка (volatility feedback) предполагает, что негативные новости вызывают немедленный рост требуемой доходности, что снижает текущую цену и одновременно увеличивает волатильность. Эмпирические данные подтверждают оба механизма, причем их относительный вклад варьируется в зависимости от рыночных условий. Для индексов развивающихся рынков асимметрия выражена сильнее: коэффициент асимметрии достигает -0.8 против -0.5 для развитых рынков. Криптовалюты демонстрируют слабую асимметрию или ее отсутствие, что связано с тем что тут совершенно иные драйверы волатильности, не связанные с доходностью компаний, выпускающих финансовые активы. Ограничения симметричных моделей GARCH Базовая спецификация GARCH (1,1) моделирует условную дисперсию как: σ²ₜ = ω + α·ε²ₜ₋₁ + β·σ²ₜ₋₁ где: σ²ₜ — условная дисперсия в момент t; εₜ₋₁ — стандартизированный остаток в момент t-1; ω, α, β — параметры модели. Квадратичная форма ε²ₜ₋₁ означает одинаковое влияние положительных и отрицательных шоков. Модель недооценивает волатильность после негативных новостей и переоценивает после позитивных. Прогнозы VaR получаются смещенными, что не годится для риск-менеджмента в современных условиях. Попытки улучшить симметричный GARCH через увеличение порядка модели (p, q) не решают проблему асимметрии. Модели GARCH (2,2) или GARCH (3,3) лучше аппроксимируют долгосрочную память, но сохраняют симметричный отклик на шоки разного знака. Рис. 1: Cравнение истинной волатильности и моделируемой GARCH. Симметричная GARCH модель систематически недооценивает волатильность после негативных шоков и переоценивает после позитивных. Распределение ошибок смещено влево для периодов после падений EGARCH: Экспоненциальная обобщенная условная гетероскедастичность Модель Exponential GARCH, предложенная Nelson (1991), использует логарифмическую параметризацию условной дисперсии. Это гарантирует положительность волатильности без ограничений на параметры и позволяет моделировать асимметричный отклик произвольной формы. Архитектура модели и логарифмическая параметризация Спецификация EGARCH (1,1): log(σ²ₜ) = ω + β·log(σ²ₜ₋₁) + α·|zₜ₋₁| + γ·zₜ₋₁ где: zₜ₋₁ = εₜ₋₁/σₜ₋₁ — стандартизированный остаток; ω — константа; β — коэффициент персистентности; α — влияние величины шока; γ — параметр асимметрии. Логарифмическая форма устраняет необходимость проверки неотрицательности: exp(log(σ²ₜ)) всегда положителен при любых значениях параметров. В стандартном GARCH требуется ω > 0, α ≥ 0, β ≥ 0 и α + β < 1 для стационарности. Персистентность волатильности определяется коэффициентом β. Значение β = 0.98 означает полураспад шока волатильности примерно через 35 периодов. Для дневных данных акций типичный диапазон β составляет 0.94-0.99, что соответствует долгой памяти волатильности. Моделирование знака и величины шоков Член α·|zₜ₋₁| захватывает влияние величины шока независимо от знака. Положительный α означает, что крупные движения любого направления увеличивают волатильность. Типичные оценки для индексов: α ∈ [0.1, 0.3]. Параметр асимметрии γ определяет различие в отклике на положительные и отрицательные шоки: γ < 0: негативные шоки увеличивают волатильность сильнее (leverage effect); γ = 0: симметричный отклик; γ > 0: позитивные шоки увеличивают волатильность сильнее (редко для акций). Для индекса S&P 500 оценки γ находятся в диапазоне от -0.15 до -0.25. Это значит, что негативный шок в 1 стандартное отклонение увеличивает log(σ²ₜ) на величину α + γ ≈ 0.05, тогда как позитивный шок той же величины — на α - γ ≈ 0.35. Комбинация членов позволяет разделить эффекты: |zₜ₋₁| отвечает за базовый отклик на размер движения; zₜ₋₁ добавляет асимметричную компоненту; их взаимодействие создает нелинейный отклик сложной формы. Оценка параметров и практические аспекты Максимизация логарифмической функции правдоподобия (MLE) при нормальном распределении инноваций вычисляется следующим образом: L = -T/2·log(2π) - 1/2·Σlog(σ²ₜ) - 1/2·Σ(ε²ₜ/σ²ₜ) где: T — количество наблюдений; σ²ₜ — условная дисперсия в момент t; εₜ — остаток в момент t. Логарифмическое правдоподобие суммирует вклад каждого наблюдения. Оптимизация проводится численными методами (BFGS, Newton-Raphson). Библиотека arch в Python реализует эффективные алгоритмы с автоматическим вычислением градиентов. Важно учитывать, что распределение случайных ошибок (инноваций, residuals) влияет на оценки параметров. Так, к примеру: Распределение Стьюдента (Student-t) с оцениваемыми степенями свободы лучше подходит для данных с «тяжелыми хвостами»; Для криптовалют и внутридневных данных акций скошенное распределение Стьюдента (skewed Student-t) обычно дает более качественную подгонку по информационным критериям. Для стационарности модели EGARCH (Exponential GARCH) нужно, чтобы выполнялось условие |β| < 1. Если β ≥ 1, модель становится нестационарной. В этом случае шоки волатильности не затухают со временем. Интегрированный вариант IEGARCH (Integrated Exponential GARCH) с β = 1 применяют для моделирования постоянных сдвигов уровня волатильности. Начальное значение условной дисперсии σ²₀ влияет на первые 50–100 наблюдений. Обычно используют безусловную дисперсию выборки или оценку из предварительной модели GARCH (Generalized ARCH). Для длинных временных рядов (1000+ наблюдений) выбор начального значения почти не влияет на результат. Рис. 2: Механика EGARCH модели. Асимметричная функция отклика показывает более сильное влияние негативных шоков. Декомпозиция разделяет эффект величины и знака инновации TGARCH: пороговая модель волатильности Threshold GARCH (также GJR-GARCH по авторам Glosten, Jagannathan, Runkle) использует явное разделение положительных и отрицательных инноваций через индикаторную функцию. Модель проще интерпретировать чем EGARCH, сохраняя при этом способность захватывать асимметрию. Механизм разделения по знаку инновации Спецификация TGARCH (1,1): σ²ₜ = ω + α·ε²ₜ₋₁ + γ·ε²ₜ₋₁·Iₜ₋₁ + β·σ²ₜ₋₁ где: Iₜ₋₁ = 1 если εₜ₋₁ < 0, иначе 0; ω, α, β — стандартные параметры GARCH; γ — коэффициент асимметрии. Индикатор Iₜ₋₁ активирует дополнительный член γ·ε²ₜ₋₁ для негативных шоков. Положительные инновации влияют на волатильность с коэффициентом α, негативные — с коэффициентом α + γ. Параметр γ > 0 означает более сильный отклик на падения. Условия стационарности: ω > 0, α ≥ 0, β ≥ 0, γ ≥ 0 и α + β + γ/2 < 1 Член γ/2 появляется потому что негативные шоки возникают в среднем в половине случаев. Безусловная дисперсия равна: σ² = ω / (1 - α - β - γ/2) Типичные оценки для индекса S&P 500: α ≈ 0.04, β ≈ 0.92, γ ≈ 0.08. Негативный шок величиной σ увеличивает следующую условную дисперсию на (α + γ)·σ² = 0.12·σ², тогда как позитивный — на α·σ² = 0.04·σ². Асимметрия в три раза. Сравнение с EGARCH: когда использовать каждую модель TGARCH сохраняет квадратичную форму базового GARCH, что упрощает интерпретацию и вычисления. Ограничения на параметры более строгие: необходимо следить за неотрицательностью и условиями стационарности. Модель EGARCH свободна от этих ограничений благодаря логарифмической форме. Функция отклика TGARCH кусочно-квадратичная с изломом в нуле. EGARCH моделирует гладкую нелинейную функцию через комбинацию |zₜ₋₁| и zₜ₋₁. Для активов с резким переключением режимов (например, при пересечении критических уровней поддержки) TGARCH может быть предпочтительнее. Сравнение по информационным критериям (AIC, BIC) для различных классов активов: Индексы акций развитых рынков: EGARCH и TGARCH показывают сопоставимые результаты, разница в AIC менее 5 пунктов; Индексы развивающихся рынков: EGARCH лучше на 8-12 пунктов AIC благодаря более гибкой функции отклика; Валютные пары: TGARCH часто предпочтительнее из-за меньшей асимметрии и более простой структуры; Товарные фьючерсы: EGARCH эффективнее для энергетических контрактов, TGARCH — для металлов. Вычислительная сложность TGARCH ниже: квадратичная форма вычисляется быстрее экспоненты. Для высокочастотных данных или портфелей из сотен активов это существенно. EGARCH требует примерно на 20-30% больше времени для оценки параметров. Интерпретация коэффициентов асимметрии Отношение γ/α показывает относительное усиление от негативных шоков. Значение γ/α = 2 означает, что негативный шок создает в три раза больший эффект на волатильность чем позитивный (базовый α плюс дополнительный 2α от γ). Декомпозиция вклада в безусловную дисперсию: Константа ω: базовый уровень волатильности; Член α·ε²ₜ₋₁: симметричная реакция на все шоки; Член γ·ε²ₜ₋₁·Iₜ₋₁: дополнительная волатильность от негативных новостей; Член β·σ²ₜ₋₁: персистентность предыдущей волатильности. Рис. 3: Механика TGARCH модели. Кусочная функция отклика с изломом в нуле показывает резкое изменение коэффициента для негативных шоков. Декомпозиция выделяет дополнительный вклад асимметричного компонента Доля асимметричного компонента в общей волатильности вычисляется как: γ/(2(α + β + γ/2)) Для типичных параметров фондовых индексов эта доля обычно составляет 8–12%. Остальная часть волатильности объясняется симметричными факторами и устойчивостью волатильности во времени. Статистическую значимость параметра γ проверяют с помощью t-критерия (t-test). Нулевая гипотеза формулируется так: H₀: γ = 0, то есть асимметрии нет. Для большинства фондовых индексов γ значимо отличается от нуля на уровне значимости 1%. Для валютных пар асимметрия часто оказывается статистически незначимой. FIGARCH: долгая память в волатильности Модель Fractionally Integrated GARCH расширяет стандартные модели для захвата долгосрочной зависимости в условной дисперсии. Автокорреляционная функция квадратов доходностей затухает по степенному закону вместо экспоненциального, что типично для финансовых данных. Концепция фрактальной интегрированности Стандартная модель GARCH (1,1) предполагает экспоненциальное затухание шоков волатильности. Импульсная функция отклика снижается как β^k, где k — количество периодов после шока. Для β = 0.95 влияние шока через 20 периодов составляет лишь 36% от начального уровня. Эмпирические автокорреляции |rₜ|² для индексов акций демонстрируют медленное гиперболическое затухание. На лагах 50-100 периодов корреляции остаются статистически значимыми, что несовместимо с экспоненциальной моделью. Это свойство называется долгой памятью. FIGARCH вводит дробный оператор разности (1-L)^d, где: L — лаговый оператор (L·εₜ = εₜ₋₁); d — параметр фрактальной интегрированности, 0 < d < 1. Спецификация FIGARCH (1,d,1): σ²ₜ = ω + [1 - (1-β·L)⁻¹·(1-φ·L)·(1-L)^d]·ε²ₜ где: ω — константа; β — параметр GARCH; φ — параметр ARCH; d — степень фрактальной интегрированности. Параметр d определяет скорость затухания: d = 0 соответствует стандартному GARCH, d = 1 — интегрированному IGARCH с бесконечной памятью. Типичные оценки для фондовых индексов: d ∈ [0.3, 0.5]. Отличия от стандартных моделей GARCH Модель условной дисперсии GARCH (1,1) имеет три параметра, FIGARCH — четыре. Дополнительная гибкость позволяет лучше аппроксимировать долгосрочную структуру автокорреляций. Информационные критерии показывают улучшение на 15-25 пунктов AIC для месячных и квартальных данных. Безусловная дисперсия в модели FIGARCH не определена при d > 0. Это означает, что процесс нестационарен в широком смысле. При этом условная дисперсия может оставаться стационарной по ковариации при соблюдении ограничений на параметры. На практике это обычно не является проблемой, так как основной упор делают на условные прогнозы. Прогнозы волатильности на длинных горизонтах (20 и более периодов) всех 3 моделей условной дисперсии сильно различаются: Обобщенная авторегрессионная условная гетероскедастичность (GARCH, Generalized ARCH): быстрая сходимость к безусловному среднему уровню; Фракционно-интегрированная обобщенная авторегрессионная условная гетероскедастичность (FIGARCH, Fractionally Integrated GARCH): медленное затухание; прогнозы долго остаются выше безусловного уровня; Интегрированная обобщенная авторегрессионная условная гетероскедастичность (IGARCH, Integrated GARCH): прогнозы не сходятся; любой шок действует постоянно. Для торговых стратегий на волатильности это имеет ключевое значение. Возврат к среднему уровню волатильности (mean reversion) происходит медленнее, чем предсказывает обычная модель GARCH. Это меняет оптимальные точки входа в позиции по опционам и влияет на итоговую доходность стратегий. Применимость к различным классам активов Индексы акций демонстрируют наиболее выраженную долгую память: d = 0.35-0.45. Кризисные периоды усиливают эффект: оценки d во время 2008-2009 достигали 0.55-0.6. Это отражает затянутые периоды повышенной волатильности. Валютные пары показывают слабую долгую память: d = 0.15-0.25. Стандартный GARCH часто достаточен. Исключение — кросс-курсы развивающихся рынков к доллару, где d достигает 0.35. Рис. 4: Долгая память в FIGARCH. Гиперболическое затухание импульсной функции отклика приводит к персистентным автокорреляциям. Прогнозы волатильности сходятся медленнее чем в стандартном GARCH Товарные фьючерсы неоднородны: Энергетические контракты (нефть, газ): d = 0.4-0.5, выраженная долгая память; Металлы (золото, серебро): d = 0.25-0.35, умеренная долгая память; Сельхозпродукция: d = 0.1-0.2, близко к стандартному GARCH. Криптовалюты демонстрируют экстремальную долгую память на ранних этапах развития рынка: d = 0.5-0.7 для Bitcoin в 2013-2017. С ростом ликвидности параметр снижается до 0.3-0.4. Высокие значения d отражают длительные тренды волатильности во время пузырей и коллапсов. Сравнительный анализ моделей Выбор между моделями EGARCH, TGARCH и FIGARCH зависит от характеристик данных, целей моделирования и вычислительных ограничений. Данные модели условной дисперсии решают разные проблемы: асимметрия отклика (EGARCH, TGARCH) и долгая память (FIGARCH). Комбинированные спецификации объединяют эти свойства. Критерии выбора модели Характер асимметрии определяет выбор между EGARCH и TGARCH: EGARCH лучше подходит для рынков, где реакция на новости происходит плавно. Цена и волатильность меняются постепенно, без резких скачков; TGARCH лучше описывает рынки с резкими переключениями режимов. Это характерно для активов с четкими психологическими уровнями или структурными разрывами в динамике. Форму отклика проверяют через регрессию квадратов стандартизированных остатков: z²ₜ = c₀ + c₁·zₜ₋₁ + c₂·z²ₜ₋₁ + c₃·|zₜ₋₁| + uₜ Интерпретация результатов простая: если значимы c₁ и c₃, но c₂ незначим — это признак плавной линейно-абсолютной реакции, характерной для EGARCH; если значим только c₂, причем коэффициенты отличаются для положительных и отрицательных значений zₜ₋₁, — это поддерживает выбор TGARCH. Для долгосрочных прогнозов волатильности лучше подходит фракционно-интегрированная обобщенная авторегрессионная условная гетероскедастичность (FIGARCH), если в данных есть эффект долгой памяти. Наличие этого эффекта проверяют с помощью теста Гейвека–Портера–Худака (GPH test). Для краткосрочных прогнозов на горизонте 1–5 периодов обычно достаточно стандартной модели обобщенной авторегрессионной условной гетероскедастичности (GARCH) или пороговой версии (TGARCH). Вычислительная сложность растет в следующем порядке: TGARCH → EGARCH → FIGARCH. При работе с портфелями из 100 и более активов с ежедневной переоценкой это становится важным моментом. По скорости расчета при сопоставимых моделях TGARCH работает примерно на 25% быстрее, чем EGARCH (Exponential GARCH), а TGARCH работает примерно на 40% быстрее, чем FIGARCH. Комбинированные модели Модель FIEGARCH объединяет долгую память и асимметрию в одной конструкции. Для этого используется логарифмическая спецификация и дробный оператор. Формула модели выглядит так: log(σ²ₜ) = ω + [1-(1-β·L)⁻¹·(1-L)^d]·(α·|zₜ₋₁| + γ·zₜ₋₁) Эта модель включает пять параметров, тогда как GARCH имеет только три. Из-за этого на коротких временных рядах (менее 500 наблюдений) часто возникает переобучение. Чтобы ограничить излишнюю сложность модели, используют байесовский информационный критерий (BIC). Он сильнее наказывает сложные модели, чем другие критерии (например, AIC), и помогает избежать лишней параметризации. Модель фракционно-интегрированной асимметричной степенной обобщённой авторегрессионной условной гетероскедастичности FIAPARCH расширяет стандартные модели за счет степенного преобразования волатильности. Ее спецификация имеет вид: σᵟₜ = ω + [1-(1-β·L)⁻¹·(1-L)^d]·(|εₜ₋₁| - γ·εₜ₋₁)ᵟ Ключевую роль играет параметр δ — показатель степени. Он позволяет моделировать нелинейности в условной дисперсии. Практическая интерпретация: типичные оценки: δ ∈ [1.5; 2.5]; для высокочастотных данных δ обычно близок к 1; для месячных данных δ чаще приближается к 2. Таким образом, FIAPARCH гибко подстраивается под структуру данных и позволяет лучше описывать как быстрые, так и медленные режимы изменения волатильности. Параметр δ в модели FIAPARCH позволяет учитывать нелинейности в условной дисперсии. Типичные значения: δ ∈ [1.5, 2.5]. Для высокочастотных данных δ близко к 1, для месячных данных — к 2. Практическое применение комбинированных моделей: Ценообразование долгосрочных опционов (> 6 месяцев): FIEGARCH учитывает долгую память и асимметрию улыбки волатильности (volatility smile); Стратегии волатильного арбитража: FIAPARCH точнее описывает экстремальные движения рынка; Риск-модели для портфелей: EGARCH достаточно для большинства задач и упрощает реализацию. Сравнительная таблица моделей Для формального сравнения моделей используют информационные критерии: AIC (Akaike Information Criterion) и BIC (Bayesian Information Criterion). Разница менее 5 пунктов считается несущественной. Характеристика EGARCH TGARCH FIGARCH Асимметрия отклика Гладкая нелинейная Кусочная квадратичная Нет (если не комбинировать) Долгая память Нет Нет Да Количество параметров 4 4 4 Ограничения на параметры Минимальные Строгие (неотрицательность) Умеренные Вычислительная сложность Средняя Низкая Высокая Интерпретируемость Средняя Высокая Низкая Оптимальная область применения Индексы акций, опционы Валюты, товары Долгосрочные прогнозы Типичное улучшение AIC vs GARCH 10-20 пунктов 8-15 пунктов 15-25 пунктов (месячные данные) Важно тестировать модели как на обучающей выборке, так и на тестовой, так как сложные модели могут переобучаться, и их прогнозная точность снижается. Для оценки корректности модели также применяют следующие дополнительные тесты: Тест Льюнга–Бокса (Ljung-Box) на автокорреляцию стандартизированных остатков. P-value > 0.05 на лагах 10–20 говорит о том, что модель не оставила неучтенной структуры в данных; Тест на квадраты остатков помогает выявить оставшуюся гетероскедастичность, то есть неполностью смоделированную волатильность. Практические рекомендации по применению Реализация асимметричных GARCH моделей требует внимания к деталям подготовки данных, оценки параметров и валидации результатов. Ошибки на этапе спецификации приводят к смещенным оценкам риска и неэффективным торговым сигналам. Диагностика модели и тестирование остатков Стандартизированные остатки z_t = ε_t/σ_t должны удовлетворять предположениям модели: нулевое среднее, единичная дисперсия, отсутствие автокорреляции. Отклонения указывают на проблемы спецификации. Тесты на автокорреляцию: Ljung-Box Q-тест на лагах 10, 15, 20 для уровней остатков; Ljung-Box Q-тест для квадратов остатков (проверка оставшейся условной гетероскедастичности); ARCH-LM тест для различных порядков лагов. P-values ниже 0.05 сигнализируют о неадекватности модели. Решение: увеличение порядка (p,q), переход к более сложной спецификации, или изменение распределения инноваций. Тестирование асимметрии через знаковый bias тест: z²ₜ = c₀ + c₁·Sₜ₋₁⁻ + c₂·Sₜ₋₁⁻·zₜ₋₁ + c₃·Sₜ₋₁⁺·zₜ₋₁ + uₜ где: Sₜ₋₁⁻ = 1 если εₜ₋₁ < 0, иначе 0 - Sₜ₋₁⁺ = 1 если εₜ₋₁ > 0, иначе 0 Незначимые коэффициенты c₁, c₂, c₃ подтверждают адекватное моделирование асимметрии. Значимость указывает на неучтенные нелинейности. Визуализация через QQ-plot стандартизированных остатков против теоретического распределения (нормального или распределения Стьюдента) позволяет выявить отклонения в хвостах. Если наблюдаются систематические отклонения в левом хвосте при нормальном распределении, часто переходят на скошенное распределение Стьюдента. Тест Нюблома (Nyblom) проверяет стабильность параметров модели и помогает выявить структурные изломы. Для модели с четырьмя параметрами критическое значение на уровне значимости 5% составляет примерно 0.47. Если значение теста превышает этот порог, это указывает на нестабильность параметров. В таких случаях рекомендуется оценивать модель на подвыборках или использовать модели с временной вариацией параметров. Прогнозирование VaR и риск-метрики Value at Risk на уровне α вычисляется как: VaR_α = μₜ₊₁ + σₜ₊₁·q_α где: μₜ₊₁ — прогноз среднего (часто ноль для доходностей); σₜ₊₁ — прогноз волатильности из GARCH модели; q_α — квантиль распределения инноваций. Для уровня значимости α = 0.05 и нормального распределения квантиль равен q_α = -1.645. Для распределения Стьюдента с 6 степенями свободы квантиль примерно q_α ≈ -2.0, что увеличивает оценку VaR на 20%. Неправильный выбор распределения может привести к недооценке VaR и стать причиной регуляторных нарушений. Backtesting VaR оценивает качество прогнозов через последовательность хитов (hit sequence): Iₜ = 1 если rₜ < VaR_α,t, иначе 0 Основные тесты: Безусловный coverage тест (Kupiec) проверяет, что доля хитов соответствует уровню α; Условный coverage тест (Christoffersen) дополнительно проверяет независимость хитов во времени. Если хиты кластеризуются, это указывает на недооценку волатильности в определенных рыночных режимах. Условный риск Expected Shortfall (ES, также CVaR) оценивает средние потери при превышении VaR: ES_α = E[r | r < VaR_α] Для асимметричных моделей GARCH ES вычисляют через численное интегрирование прогнозного распределения. Например, EGARCH с скошенным распределением Стьюдента дает значения ES на 15–25% выше, чем симметричный GARCH с нормальным распределением. Многопериодные прогнозы волатильности для горизонта h вычисляются по формуле: σ²ₜ₊ₕ|ₜ = ω·(1 + (α+β) + ... + (α+β)^(h-1)) + (α+β)^h·σ²ₜ Для модели GARCH (1,1) эта формула упрощается. Для EGARCH и TGARCH прогнозы рассчитывают итеративно через симуляции. Обычно 10000 симуляций Монте-Карло обеспечивают точность прогноза на уровне 1–2%. Практические рекомендации для внедрения моделей в продакшн Переоценка параметров: еженедельно для высоколиквидных активов; ежемесячно для менее активных. Скользящее окно: 500–1000 наблюдений для дневных данных; меньшие окна для высокочастотных. Робастность к выбросам - применяют винзоризацию данных на уровне 0,1% и 99,9% перцентилей. Мониторинг стабильности - отслеживание параметров моделей и статистик качества подгонки. Интеграция с системами риск-менеджмента требует автоматизации всего пайплайна: загрузки данных, оценки моделей, генераций прогнозов и расчета метрик. Ошибки обработки данных, например пропуски или аномалии, должны триггерить алерты, чтобы иметь возможность вовремя провести ручную проверку и корректировку. Выводы Асимметрия волатильности — эмпирически устойчивое свойство финансовых рынков, игнорирование которого приводит к систематическим ошибкам в оценке риска. Для решения этой проблемы специалисты заменяют классическую GARCH модель на другие: EGARCH моделирует гладкую нелинейную зависимость через логарифмическую параметризацию; TGARCH использует явное разделение по знаку инновации; FIGARCH захватывает долгую память в условной дисперсии. Выбор между ними определяется характером данных, горизонтом прогноза и вычислительными ограничениями. Практическое применение этих моделей выходит далеко за рамки академических исследований: Портфельные менеджеры используют асимметричные модели GARCH для динамического хеджирования; Трейдеры опционов применяют их для калибровки volatility smile; Риск-менеджеры — для точных оценок VaR и Expected Shortfall. Правильная спецификация модели волатильности напрямую влияет на доходность стратегий, поскольку недооценка волатильности после негативных шоков приводит к излишнему риску, а переоценка после позитивных шоков — к упущенным возможностям. Проведение тестов, проверка остатков, обновление параметров и аккуратный бэктестинг превращают эти модели из теоретических в реальные инструменты, на которые можно опереться при принятии решений в условиях рыночной неопределенности. ### Автоматизация заполнения пропусков на основе сравнения 14 методов Пропуски в данных или NaN — это одна из самых частых проблем, с которой сталкиваются аналитики. И мало эти пропуски найти, важно еще правильно поработать с ними. Библиотека pandas предлагает множество методов замены пропусков, но на практике нередко возникает другой вопрос: а какой метод лучше выбрать, чтобы не исказить данные и сохранить их изначальный смысл и структуру? В этой статье я покажу, как можно автоматизировать работу по заполнению пропусков в датафреймах pandas и сравнить эффективность 14 популярных способов импутации, с учетом их влияния на статитистические параметры данных. На конкретном примере мы сравним какие методы выполняют импутацию NaN максимально бережно, а какие могут существенно менять распределение. Риски неправильного заполнения пропусков и сложность выбора метода Работа с пропусками - важный этап в анализе данных и тут важна аккуратная работа. Неверное заполнение NaN в датафреймах может привести к нескольким проблемам: Искажение статистики данных: могут кардинально измениться среднее, медиана и разброс значений; Нарушение временной структуры: особенно во временных рядах, где пропуски разрывают автокорреляцию; Появление ложных корреляций/закономерностей: когда в данных возникают артефакты, которых не было в исходном наборе; Снижение качества моделей и потеря их устойчивости: если пропусков много, на искаженных данных ML-алгоритмы склонны к недообучению или переобучению, возрастают риски ошибочной классификации / прогнозов при работе на "боевых" данных; Нарушение бизнес-логики: формируются значения, невозможные или некорректные с точки зрения предметной области. В библиотеках pandas и scikit-learn есть множество способов заполнения пропусков: Простые статистические методы: среднее и медиана; Последовательное заполнение: прямое и обратное заполнение (forward fill и backward fill); Интерполяция: линейная, полиномиальная и на основе сплайнов; Продвинутые методы машинного обучения: алгоритм ближайших соседей (KNN) и множественное восстановление пропусков (MICE). Главная проблема заключается в том, что при работе с незнакомым датасетом нам остается угадывать какой метод подойдет лучше всего. Приходится пробовать каждый вручную, писать однотипный код и субъективно оценивать качество результата. Пишем функцию автоматизации Чтобы автоматизировать заполнение пропусков, нам нужна универсальная функция, которая сможет честно сравнивать разные методы. Для ее разработки сначала подготовим данные, на которых будем проводить эксперимент. Подготовка данных и разведочный анализ (EDA) Для того, чтобы объективно сравнивать методы импутации, нам нужен датасет с известными свойствами. Мы создадим синтетический временной ряд с трендом, сезонностью, периодическими колебаниями (циклами), случайным шумом. Затем искусственно добавим 15% пропусков и попробуем их восстановить так, чтобы максимально сохранить исходные свойства. Почему 15%? Это середина диапазона 10%-20% - частая доля пропусков во многих реальных датасетах. Если пропусков меньше 5% — почти любой метод даст хороший результат. Если больше 30% — данные в принципе будут сильно искажаться любыми методами импутации. import numpy as np import pandas as pd from sklearn.experimental import enable_iterative_imputer from sklearn.impute import KNNImputer, IterativeImputer import matplotlib.pyplot as plt from matplotlib.ticker import PercentFormatter # Генерируем синтетический датасет np.random.seed(42) dates = pd.date_range('2025-05-01', periods=100, freq='D') prices = 1000 + np.cumsum(np.random.randn(100)*20) + np.sin(np.arange(100)*0.3)*100 # тренд + сезонность df = pd.DataFrame({ 'Date': dates, 'Prices': prices }) df.set_index('Date', inplace=True) # Добавляем 15% NaN nan_idx = np.random.choice(100, 15, replace=False) df.loc[df.index[nan_idx], 'Prices'] = np.nan print(f"Датасет для тестирования: {len(df)} строк, {df['Prices'].isna().sum()} NaN ({df['Prices'].isna().mean()*100:.0f}%)") print(df.sample(10)) Датасет для тестирования: 100 строк, 15 NaN (15%) Prices Date 2025-07-03 760.109153 2025-05-12 1055.254704 2025-06-26 NaN 2025-07-24 814.347983 2025-08-08 693.358257 2025-05-16 894.105795 2025-06-09 748.892171 2025-07-04 805.648893 2025-05-13 NaN 2025-06-24 745.379006 Что создает этот код: Исскуственный временной ряд цен с гранулярностью по дням года - 100 наблюдений за период с мая по август 2025; 15 случайно выбранных значений заменены на NaN; Исходный ряд без пропусков сохранен для последующего сравнения; Данные проиндексированы по дате — важно для временных методов. Обратите внимание на формулу генерации: prices = 1000 + np.cumsum(np.random.randn(100)*20) + np.sin(np.arange(100)*0.3)*100 Здесь: 1000 - базовый уровень; np.cumsum(...) - генерация тренда; np.sin(...) - синусоидальная сезонность Такая конструкция имитирует реальные финансовые или экономические временные ряды. Теперь давайте проанализируем визуально исходный временной ряд. # График исходного ряда с пропусками plt.figure(figsize=(8, 5)) plt.plot(df['Prices'], color='darkblue') plt.show() Рис. 1: График исходного ряда с пропусками На графике видны разрывы — места, где данные отсутствуют. Pandas автоматически не соединяет линией точки через пропуски, что помогает увидеть масштаб проблемы. Интересно также отметить, что пропуски распределены случайно по всему ряду. Это типичная ситуация для: Данных с датчиков (сбои оборудования); Данных веб-аналитики (проблемы с трекингом посещений); Финансовых данных (приостановка торгов в выходные и праздники). Отмечу, что мы имеем дело с небольшими и нечастыми пробелами в данных. Если пропуски идут подряд большими блоками — это другой тип проблемы, требующий иных подходов (например, моделирование всего отсутствующего периода). Базовая статистика: что мы пытаемся сохранить Прежде чем заполнять пропуски, важно зафиксировать статистические свойства исходных данных (без NaN). Это наш «золотой стандарт» — именно к нему должны стремиться все методы импутации. # Выводим исходные статистики print(df.describe()) # И сохраняем их для сравнения describe_original = df['Prices'].describe().to_frame('original') Prices count 85.000000 mean 870.554022 std 117.447765 min 689.871709 25% 773.402592 50% 871.363306 75% 922.144618 max 1170.186578 Ключевые метрики исходного датасета: count: 85 - видно что есть только 85 реальных наблюдений, без NaN; mean: 870.55 - средняя цена; std: 117.45 - волатильность (разброс); min/max: 689.87 / 1170.19 - диапазон значений; квартили (25%, 50%, 75%) - распределение данных. Эти цифры мы будем использовать как бенчмарк. Идеальный метод заполнения должен: Сохранить похожее среднее и медиану - не сдвинуть центр распределения; Не увеличить / не уменьшить дисперсию - сохранить естественную волатильность; Не создать выбросов - оставаться в пределах min/max или близко к ним; Сохранить форму распределения - квартили должны остаться примерно на месте. В чем тут может быть сложность? Методы заполнения работают по-разному: простые (mean/median) тянут значения к центру и уменьшают дисперсию, последовательные (forward/backward fill) копируют соседей и могут создавать плато, интерполяция сглаживает скачки, а методы машинного обучения чаще отдают предпочтения глобальным паттернам вместо локальных, плюс более время и вычислительно затратны. Универсальная функция fill_na: швейцарский нож для импутации Обычно аналитикам для каждого метода приходится писать отдельный код: df['col'].fillna(df['col'].mean()) # для mean df['col'].fillna(method='ffill') # для forward fill df['col'].interpolate(method='linear') # для интерполяции # и так далее Это неудобно, особенно когда нужно быстро протестировать более десятка разных методов. Поэтому создадим единую функцию, которая принимает название метода, имеет под капотом все популярные способы импутации, легко вызывается, настраивается и легко масштабируется новыми методами. # Универсальная функция замены NaN def fill_na(df, col, method, inplace=False, constant_value=1000): ma = {'ma5': 5, 'ma10': 10, 'ma20': 20} #Moving averages periods poly_order = 2 #Polynomial regression order knn_neighbors = 5 #KNN neighbors rstate = 42 #Random state for Iterative Imputer s = df[col] if method == 'median': filled = s.fillna(s.median()) elif method == 'mean': filled = s.fillna(s.mean()) elif method == 'most_frequent': mode = s.mode() filled = s.fillna(mode[0] if not mode.empty else 0) elif method == 'zero': filled = s.fillna(0) elif method == 'constant': filled = s.fillna(constant_value) elif method == 'ffill': filled = s.fillna(method='ffill') elif method == 'bfill': filled = s.fillna(method='bfill') elif method in ma: filled = s.fillna(s.rolling(ma[method], min_periods=1).mean()) elif method == 'linear_interpol': filled = s.interpolate(method='linear') elif method == 'poly_interpol': filled = s.interpolate(method='polynomial', order=poly_order) elif method == 'knn': imputer = KNNImputer(n_neighbors=knn_neighbors) filled_array = imputer.fit_transform(df[[col]]) filled = pd.Series(filled_array.flatten(), index=df.index) elif method == 'iterative': imputer = IterativeImputer(random_state=rstate) filled_array = imputer.fit_transform(df[[col]]) filled = pd.Series(filled_array.flatten(), index=df.index) else: raise ValueError(f"Unknown method: {method}") if inplace: df[col] = filled return None else: return filled # Список всех методов FILL_METHODS = ['median', 'mean', 'most_frequent', 'zero', 'constant', 'ffill', 'bfill', 'ma5', 'ma10', 'ma20', 'linear_interpol', 'poly_interpol', 'knn', 'iterative'] print("Функция fill_na загружена успешно!") print("Методы замены NaN:", FILL_METHODS) Функция fill_na загружена успешно! Методы замены NaN: ['median', 'mean', 'most_frequent', 'zero', 'constant', 'ffill', 'bfill', 'ma5', 'ma10', 'ma20', 'linear_interpol', 'poly_interpol', 'knn', 'iterative'] Вызывается функция очень просто: fill_na(df, col, method, inplace=False, constant_value=1000) В скобках параметры функции означают: df - название датафрейма; col - название столбца для заполнения пропусков; method - строка с названием метода (представлен в списке выше); inplace - надо ли изменять исходный датафрейм (True) или вернуть новый объект Series (False); constant_value - значение для метода 'constant' (замена NaN определенной константой). Я не буду здесь полностью перечислять особенности каждого метода, лишь отмечу что это самые популярные методы работы с NaN сегодня, включающие не только среднюю, медиану, моду, замену нулями, но и методы заполнения пропуска предыдущим значением (ffill), последующим (bfill), разными скользящими средними (ma5-ma20), разными вариантами интерполяций и двумя методами из машинного обучения: k-nearest neighbors (knn) и iterarive (mice). Отдельно отмечу, что функция защищена от некоторых частых ошибок: Метод most_frequent использует mode() с проверкой на отсутствие моды; Скользящие средние имеют ограничитель min_periods=1, что гарантирует расчет даже на краях ряда; Все константы: периоды средних, ордер полинома, число соседей knn, random_state вынесены отдельно, в начало функции для облегчения экспериментов и подбора лучших значений. Функция гибкая. Хотите добавить новый метод? Просто добавьте еще один elif: elif method == 'spline': filled = s.interpolate(method='spline', order=3) Нужны другие параметры? Вынесите их в аргументы функции или создайте словарь настроек. Визуализация влияния: как разные методы импутации меняют данные Теперь самое интересное — как понять, какой метод указать в функции fill_na? Какой метод будет лучшим выбором для конкретного набора данных? Нельзя просто выбрать наобум. Нужно убедиться, что метод не исказил распределение, сохранил временные зависимости и не создал аномалий. Для этого создадим функцию plot_fill_impact(), которая: Применит все 14 методов к копиям данных; Посчитает 5 ключевых статистических метрик до и после заполнения NaN; Визуализирует относительные изменения (Δ%) для каждого метода, где 0% - отсутствие изменений; Отсортирует методы от наименее искажающих данные к наиболее искажающим. Почему будем сравнивать по относительным изменениям в %? Потому что абсолютные значения не передают масштаб расхождений разных метрик (среднее может быть 870, а автокорреляция 0.6). Ниже код функции plot_fill_impact(): # Функция визуализации изменения статистик после замены NaN разными методами def plot_fill_impact(df, col, methods=None): if methods is None: methods = FILL_METHODS original = df[col] stats = {'mean': [], 'median': [], 'std': [], 'autocorr': [], 'seasonal': []} seasonal_cycle = 7 #Сезонный цикл (по умолчанию 7 периодов) for m in methods: try: filled = fill_na(df, col, m, inplace=False) stats['mean'].append((original.mean(), filled.mean())) stats['median'].append((original.median(), filled.median())) stats['std'].append((original.std(), filled.std())) # Автокорреляция stats['autocorr'].append((original.autocorr(lag=1) or 0, filled.autocorr(lag=1) or 0)) # Сезонность stats['seasonal'].append((original.rolling(seasonal_cycle, center=True).std().std() or 0, filled.rolling(seasonal_cycle, center=True).std().std() or 0)) except Exception: for k in stats: stats[k].append((np.nan, np.nan)) fig, axs = plt.subplots(5, 1, figsize=(9, 18)) titles = ['Mean', 'Median', 'Std', 'Autocorr', 'Seasonal'] stat_data = [stats['mean'], stats['median'], stats['std'], stats['autocorr'], stats['seasonal']] for i, (title, data) in enumerate(zip(titles, stat_data)): before, after = zip(*data) changes = np.array(after) - np.array(before) rel_changes = np.divide(changes, np.abs(np.array(before)), where=np.abs(np.array(before))>1e-10) * 100 valid_mask = ~np.isnan(rel_changes) & np.isfinite(rel_changes) valid_changes = rel_changes[valid_mask] valid_methods = np.array(methods)[valid_mask] sort_idx = np.argsort(np.abs(valid_changes)) sorted_methods = valid_methods[sort_idx] sorted_changes = valid_changes[sort_idx] y = np.arange(len(sorted_methods)) positive = sorted_changes > 0 axs[i].bar(y[positive], sorted_changes[positive], color='#8B0000', alpha=0.6, width=0.6) axs[i].bar(y[~positive], sorted_changes[~positive], color='#8B0000', alpha=0.6, width=0.6) zero_idx = np.abs(sorted_changes) < 0.01 for j in np.where(zero_idx)[0]: axs[i].hlines(0, y[j]-0.25, y[j]+0.25, colors='black', linewidth=3, alpha=0.9) axs[i].set_xticks(y) axs[i].set_xticklabels(sorted_methods, rotation=45, ha='right') axs[i].set_title(f'{title} (Δ% от исходного)') axs[i].yaxis.set_major_formatter(PercentFormatter()) axs[i].set_ylim(-15, 15) # Ограничиваем высоту шкалы y для удобства сравнения axs[i].axhline(0, color='black', linewidth=1.5, alpha=0.8) axs[i].grid(axis='y', linestyle='--', alpha=0.4) axs[i].grid(True, axis='x', linestyle=':', color='gray', alpha=0.3, linewidth=0.8) plt.tight_layout() plt.show() Технические особенности функции: Обработка ошибок через try-except для каждого метода - если какой-то сломается, остальные продолжат работу; Расчет сезонности через rolling(7, center=True).std().std() - стандартное отклонение от скользящих std (мера стабильности паттернов); Защита от деления на ноль: np.divide(..., where=...) - безопасное деление, избегаем inf; Фильтрация NaN через valid_mask убирает методы, в которых расчет не удался; Сортировка по модулю через np.argsort(np.abs(...)) - сначала показываем методы с минимальным влиянием; Ограничение Y-оси через ylim=(-15, 15) - если какой-то метод искажает статистики на 15% и более по модулю, то сравнивать их с другими смысла нет. Теперь запустим функцию визуализации и внимательно изучим, как каждый из 14 методов влияет на данные. Это ключевой этап, который позволит принимать решения о выборе метода на основе фактов, а не просто потому, что так принято. # Запуск функции визуализации изменений статистик plot_fill_impact(df, 'Prices') Рис. 2: Сравнительный анализ влияния 14 методов заполнения пропусков на ключевые статистические характеристики временного ряда На графиках представлены относительные изменения (Δ%) пяти метрик после применения различных методов заполнения пропусков к временному ряду с 15% отсутствующих значений: Среднего значения (Mean); Медианы (Median); Стандартного отклонения (Std); Автокорреляции первого порядка (Autocorr); Сезонной волатильности (Seasonal). Методы упорядочены по возрастанию абсолютного искажения каждой метрики. При этом столбцы близкие к нулевой линии указывают на минимальное влияние соответствующего метода на исходные статистические свойства данных. Интерпретация результатов: Визуализация демонстрирует, что методы интерполяции (linear_interpol, poly_interpol) и машинного обучения (knn, iterative) показывают наименьшее искажение по всем 5 метрикам, в то время как простые статистические методы (mean, median, zero) и метод константного заполнения существенно изменяют дисперсию и автокорреляционную структуру временного ряда; Различия особенно заметны на графике стандартного отклонения, где глобальные методы заполнения (mean, median) систематически уменьшают волатильность данных на 5-10%, тогда как локальные методы (poly_interpol, linear_interpol) сохраняют исходную вариативность с отклонением менее 3%; Для автокорреляции наблюдается обратная картина: последовательные методы (ffill, bfill) и короткие скользящие средние (ma5, ma10) лучше сохраняют временную зависимость, в то время как глобальные методы приводят к ее снижению на 10-15%, что критично для задач прогнозирования и анализа причинно-следственных связей в динамических системах. Вывод: На основании комплексного анализа всех 5 метрик для данного временного ряда оптимальным методом является полиномиальная интерполяция (poly_interpol), демонстрирующая минимальное искажение по всем статистическим характеристикам одновременно — изменение среднего составило менее 1%, стандартного отклонения около 3%, при этом автокорреляционная структура и сезонные паттерны сохранились практически без изменений. Альтернативой может служить линейная интерполяция (linear_interpol), уступающая poly_interpol только в способности улавливать нелинейные волнообразные паттерны, но превосходящая по вычислительной эффективности. Применяем выбранный метод Теперь, основываясь на результатах сравнительного анализа, заполним пропуски методом полиномиальной интерполяции. Используем inplace=True, чтобы изменения применились напрямую к датафрейму. # Запускаем функцию заполнения пропусков, выбирая метод # с наименьшим искажением "родных" статистик fill_na(df, 'Prices', 'poly_interpol', inplace=True) Теперь колонка df['Prices'] не содержит NaN, а заполненные значения оптимально вписываются в существующий ряд. print(df.tail(15)) Date Prices 2025-07-25 833.497870 2025-07-26 877.783579 2025-07-27 904.808399 2025-07-28 907.308405 2025-07-29 922.144618 2025-07-30 919.724388 2025-07-31 926.192485 2025-08-01 891.856346 2025-08-02 859.430652 2025-08-03 822.449771 2025-08-04 778.322339 2025-08-05 741.496403 2025-08-06 723.396477 2025-08-07 706.743247 2025-08-08 693.358257 Важный момент: мы не можем проверить, насколько точно восстановлены именно эти 15 значений (мы не знаем "правильных" ответов), но мы знаем, что: Метод минимально исказил общие статистики; Значения находятся в правдоподобном диапазоне (689-1170); Временная структура сохранена. В реальных проектах это лучшее, что можно сделать - выбрать метод, который лучше всего сохраняет известные свойства данных. Проверка результата: сравниваем с исходными статистиками Финальный шаг — убедиться, что наш метод действительно сработал хорошо. Сравним статистики заполненного датасета с исходным (помните, мы сохранили их в самом начале): # Статистики изменененного датасета describe_filled = df['Prices'].describe().to_frame('filled') # Сравнение с исходным датасетом comparison = pd.concat([describe_original, describe_filled], axis=1) print(comparison) original filled count 85.000000 100.000000 mean 870.554022 874.959696 std 117.447765 121.188231 min 689.871709 689.871709 25% 773.402592 777.092402 50% 871.363306 874.063689 75% 922.144618 926.829211 max 1170.186578 1170.186578 Давайте построчно сравним, что изменилось и на сколько: Рис. 3: Сравнительная таблица статистических метрик исходных данных до и после импутации пропусков Все изменения исходных статистик минимальны. А теперь давайте посмотрим на график временного ряда: # График временных рядов с заполненными пропусками plt.figure(figsize=(8, 5)) plt.plot(df['Prices'], color='darkblue') plt.show() Рис. 4: График "восстановленного" временного ряда после заполнения 15% пропущенных значений Мы теперь больше не видим разрывов линий и четко видим структуру ряда и все его тенденции. Корректное заполнение пропусков открывает новые возможности для работы с данными: Применение алгоритмов временных рядов: теперь можно использовать ARIMA, Prophet, LSTM и другие модели прогнозирования, которые требуют непрерывных данных без пропусков; Расчет производных метрик: теперь возможно корректно вычислять скользящие средние, экспоненциальное сглаживание, технические индикаторы без артефактов на границах пропусков; Сохранение временных зависимостей: автокорреляционная структура осталась неизменной, что важно для анализа причинно-следственных связей и выявления лагов между переменными; Статистическая надежность: минимальное искажение распределения (< 1% по среднему, < 3% по дисперсии) гарантирует корректность статистических тестов и доверительных интервалов; Обучение ML-моделей: заполненный датасет можно использовать для тренировки моделей машинного обучения без риска систематической ошибки из-за неправильно восстановленных значений; Кросс-валидация и бэктестинг: возможность проводить скользящую проверку моделей на всем диапазоне данных без необходимости разбивать выборку на фрагменты из-за пропусков; Визуализация и презентация: непрерывные графики выглядят профессионально и позволяют бизнес-пользователям видеть полную картину без «дыр» в данных. Заключение Работа с пропусками — важный этап в анализе данных. В этой статье мы рассмотрели как его можно автоматизировать: создали функцию с 14 методами импутации пропусков и научились визуально сравнивать, как каждый метод влияет на данные. В нашем примере лучше всего справилась полиномиальная интерполяция. Однако данные бывают разными, и природа пропусков может быть тоже. На другом наборе данных этот метод может оказаться далеко не лучшим. Именно поэтому всегда важно проводить анализ и тестировать не один метод, а сразу десятки, чтобы определить оптимальный под вашу задачу. ### Собственные числа и собственные векторы в финансах: разложения PCA и SVD Анализ главных компонент (PCA) — один из популярных способов изучения взаимосвязей между доходностями активов и их оценке в финансовом анализе. Метод основан на разложении ковариационной матрицы доходностей: собственные векторы определяют направления факторов, а собственные числа показывают, сколько дисперсии объясняет каждый фактор. PCA позволяет выделить доминирующие источники совместной изменчивости и отделить систематический риск от несистематического. Практическое применение охватывает задачи от хеджирования рыночного фактора до построения декоррелированных портфелей в статистическом арбитраже и стратегий риск-паритета (risk parity). В основе анализа главных компонент лежат собственные числа и векторы матриц. Их экономическая интерпретация и вычислительная реализация определяют качество получаемых факторов и устойчивость основанных на них стратегий. Математические основы собственных значений и векторов Собственный вектор квадратной матрицы A — это ненулевой вектор v, который при умножении на A меняет только масштаб, но не направление. Масштаб задается собственным числом λ: Av = λv Геометрически это означает, что собственные векторы показывают направления, в которых данные имеют максимальную дисперсию при проекции, а собственные числа — величину этой дисперсии. Для симметричных положительно определенных матриц (например, ковариационной матрицы доходностей) выполняются ключевые свойства: Все собственные числа вещественные и неотрицательные; Собственные векторы ортогональны друг к другу; Матрица диагонализируема: A = VΛV^T. Эти свойства делают разложение собственных значений и векторов особенно полезной операцией для финансового анализа. Она позволяет декоррелировать активы, выявлять главные источники риска и строить факторные модели, где компоненты независимы и интерпретируемы. Рис. 1: Собственные векторы как оси максимальной дисперсии. Красные стрелки показывают направления и масштаб собственных чисел на примере двумерного облака точек Определение и геометрическая интерпретация Первая главная компонента соответствует направлению наибольшей дисперсии данных. Вторая компонента ортогональна первой и захватывает максимум оставшейся дисперсии. В финансовом контексте это означает: первая компонента обычно отражает общий рыночный фактор, вторая и третья — крупные секторальные изменения, либо изменения в инвестиционных стилях. Свойства собственных векторов ковариационной матрицы Собственные векторы ковариационной матрицы доходностей представляют собой портфели с единичной волатильностью и нулевой корреляцией между собой. Коэффициенты в векторе — это веса активов в таком портфеле. Сумма собственных чисел равна следу ковариационной матрицы, то есть общей дисперсии системы. Доля i-го собственного числа в сумме определяет объясненную компонентой долю общей изменчивости. SVD как численная основа PCA Классическое собственное разложение ковариационной матрицы вычисляется по формуле: X^T X / (n-1) Такое разложение чувствительно к численному шуму при большом числе активов. Сингулярное разложение (Singular Value Decomposition, SVD) решает эту проблему напрямую на центрированной матрице доходностей. Для матрицы доходностей X размером n × p (n наблюдений, p активов) выполняется: X = UΣV^T где: X — матрица доходностей размером n × p (n наблюдений, p активов); U — матрица левых сингулярных векторов, задает проекции наблюдений; Σ — диагональная матрица сингулярных чисел, связанные с дисперсией; V — матрица правых сингулярных векторов, направления главных компонент. Главные компоненты получают как проекции исходных данных на правые сингулярные векторы: Z=XV Квадраты сингулярных чисел показывают дисперсию каждой компоненты. Деление на n−1 позволяет получить величину объясненной дисперсии, если данные центрированы по столбцам. Таким образом, сингулярное разложение (SVD) одновременно позволяет: Определить направления наибольшей изменчивости данных; Количественно оценить, сколько дисперсии объясняет каждая компонента. Это делает SVD мощным инструментом для анализа финансовых временных рядов и построения факторных моделей. Сингулярное разложение vs классическое собственное разложение Сингулярное разложение (SVD) вычисляется напрямую через матрицу данных X, а не через Xᵀ·X. Это снижает накопление ошибок округления, особенно если число признаков p значительно больше числа наблюдений n или при высокой коллинеарности данных. На практике, для портфелей из 30–100 активов, разница в оценке малых собственных чисел между SVD и классическим разложением может достигать 10–30%. Малые собственные числа влияют на оценку менее значимых компонент и стабильность факторных моделей. Центрирование данных и масштабирование При SVD центрирование данных по столбцам обязательно, иначе первая компонента будет отражать сдвиг уровня, а не реальные направления изменчивости. Кроме того, при визуализации кумулятивной дисперсии и выборе числа значимых компонент центрированные данные позволяют корректно оценить вклад каждой компоненты без искажения из-за среднего уровня цен. А вот масштабирование или стандартизация (деление на стандартное отклонение) обычно не нужна при работе с доходностями — она искажает экономический смысл весов, делая их менее интерпретируемыми в финансовом контексте. PCA на матрице доходностей активов PCA применяется к матрице логарифмических или простых доходностей активов. Каждая строка — наблюдение во времени, каждый столбец — отдельный актив. Результатом разложения становятся ортогональные временные ряды — главные компоненты, объясняющие максимум ковариации. Ковариационная матрица Σ доходностей симметрична и положительно полуопределена. Ее собственное разложение дает полный набор некоррелированных факторов. Рис. 2: Для 20 крупнейших акций США первые три компоненты объясняют 55–65% общей дисперсии, 10 компонент — более 85% Построение ковариационной матрицы и ее разложение Ковариационная матрица вычисляется как: (Xᵀ·X)/(n-1) где: X — матрица центрированных данных (строки — наблюдения, столбцы — активы); n — число наблюдений. Сингулярное разложение (SVD) дает те же правые сингулярные векторы V, что и разложение ковариационной матрицы. Это позволяет получать главные компоненты напрямую из данных и количественно оценивать, сколько дисперсии объясняет каждая компонента. Если число активов превышает 100, рекомендуется использовать randomized SVD, которая ускоряет вычисления в 5–10 раз при погрешности менее 1e-6. Такой подход особенно полезен для больших портфелей или высокочастотных данных, когда классическое SVD становится медленным. Главные компоненты как ортогональные портфели Каждый собственный вектор V[:, i] можно рассматривать как портфель с весами по активам. Волатильность такого портфеля равна √λᵢ. Портфели некоррелированы друг с другом и имеют максимальную возможную дисперсию при данных ограничениях. Сумма квадратов весов в каждом векторе равна 1, что обеспечивает нормировку. Положительные веса соответствуют длинным позициям, а отрицательные — коротким. Дополнительно, такая интерпретация позволяет использовать главные компоненты для построения факторных портфелей и оценки системного риска. Интерпретация главных компонент в финансовых временных рядах На практике первые компоненты почти всегда отражают известные факторы риска. Структура коэффициентов (загрузок) стабильна на долгих интервалах, если активов достаточно много. Рис. 3: Первая компонента - почти равновзвешенный длинный портфель (рыночный фактор). Вторая и третья выделяют сектора, к которым относятся акции Первая компонента: рыночный фактор Первая компонента сильно коррелирует с индексом SP500 — 0.9 и выше. Ее веса близки к рыночной капитализации или к равновзвешенному портфелю при широкой выборке. Она отражает общий рыночный бета-риск. Для хеджирования она работает эффективнее, чем сам индекс, так как не содержит индивидуального шума отдельных акций. Вторые и последующие компоненты: факторы секторов и инвестиционных стилей Вторая компонента обычно противопоставляет технологический сектор остальному рынку. Третья компонента отражает разницу между финансовым сектором и потребительскими товарами. На выборках более 100 активов компоненты с 2-й по 10-ю повторяют известные факторы Фама-Френча — например, факторы размера (SMB), стоимости (HML) и моментум — с корреляцией 0.6–0.85. Шум и индивидуальный риск в остатке После выделения главных компонент остаются только случайные колебания отдельных акций. Они представляют собой индивидуальный риск (идиосинкратический риск) и рыночный шум. Эти остатки не несут информации о системных факторах. Компоненты после 20–30 часто имеют собственные числа близкие к уровню шума. Их временные ряды (оценки U[:, i] × √λᵢ) демонстрируют отсутствие автокорреляции и предсказуемости. Именно эти остатки используются в статистическом арбитраже: доходности, очищенные от систематических факторов, имеют более высокое соотношение Шарпа при торговле на разворот. Выбор количества значимых компонент Количество компонент определяет баланс между захватом системного риска и исключением шума. Если оставить слишком много компонент, модель переобучается; слишком мало — теряются важные источники риска. Рис. 4: Скорость уменьшения собственных значений (дельта λᵢ) для топ-20 акций США. Логарифмическая шкала подчеркивает резкий спад первых компонент и длинный пологий хвост, начиная с точки перегиба между 9-й и 11-й компонентами Доля объясненной дисперсии и кумулятивный порог Кумулятивная доля объясненной дисперсии показывает, сколько общей изменчивости портфеля захватывают выбранные компоненты. Обычно достаточно оставить компоненты до достижения 80–90% дисперсии. Для портфелей из 20+ американских акций это соответствует примерно 8–15 компонентам. В развивающихся рынках (emerging markets) или при работе с криптоактивами систематических факторов больше, поэтому порог часто повышают до 90–95%. Выбор компонента по порогу помогает исключить шум и сосредоточиться на значимых источниках риска, делая модели более стабильными и экономически интерпретируемыми. График собственных значений и критерии выбора числа компонент Scree plot, или график собственных значений, показывает дисперсию, объясняемую каждой компонентой. Критерий локтя — визуальное определение точки, после которой добавление новых компонент почти не увеличивает объясненную дисперсию. На финансовых данных «локоть» часто размытый, поэтому для выбора числа значимых компонент применяют автоматизированные методы. Есть еще один популярный метод - критерий Кайзера. Он сохраняет компоненты с собственными числами λ больше 1, когда данные стандартизованы. На доходностях его нельзя использовать напрямую, но модификация λ > среднее(λ) показывает стабильные результаты. Правило broken-stick сравнивает наблюдаемые собственные числа с распределением случайных чисел, если дисперсия равномерно распределена между компонентами. Компоненты, превышающие 95-й перцентиль такого распределения, считаются значимыми и отражают настоящие систематические факторы риска. Применение PCA в управлении портфелем и трейдинге Метод PCA преобразует коррелированную систему активов в набор некоррелированных факторов. Это позволяет выделять независимые источники риска и работать с ними отдельно. Такой подход позволяет решать задачи, которые сложно или невозможно решить напрямую на уровне отдельных активов. Например, можно строить портфели, оптимизированные по систематическим факторам, хеджировать общий рыночный риск с помощью первой компоненты или анализировать секторальные и стилевые факторы для более точного управления риском и доходностью. Рис. 5: Распределение риска до и после декорреляции с помощью PCA. После перехода к главным компонентам риск перераспределяется по независимым направлениям, что упрощает управление портфелем и делает вклад каждой компоненты прозрачным Декорреляция активов и построение риск-паритет портфелей На главных компонентах задача построения риск-паритет портфелей становится простой: веса распределяются обратно пропорционально √λᵢ. Полученный портфель имеет максимальное соотношение Шарпа среди всех риск-паритетных конструкций на исходных активах. Использование главных компонент позволяет разделить систематический риск на независимые направления и сделать распределение риска более прозрачным и управляемым. Статистический арбитраж на остатках после вычитания главных компонент Если вычесть k первых компонент, очищенные доходности можно получить так: r_clean = r − V_k V_k^T r Эти остатки обладают низкой корреляцией и высокой предсказуемостью на коротких горизонтах. Стратегии возврата к среднему (Mean-reversion) на таких остатках могут давать высокий коэффициент Шарпа в 1.5–3.0 при обороте 20–50x. Такой подход позволяет выявлять локальные аномалии рынка, которые сложно заметить на уровне исходных активов. Хеджирование бета-риска первой компонентой Первая компонента отражает общий рыночный фактор и часто чище индекса SPY или фьючерса ES. Коэффициент хеджирования β можно вычислить так: β = cov(r_portfolio, PC1) / var(PC1) где: r_portfolio — доходности портфеля; PC1 — первая главная компонента; cov — ковариация; var — дисперсия. Использование 1-й компоненты для хеджирования бета-риска снижает волатильность портфеля на 5–15% по сравнению с прямым хеджированием индексом. Кроме того, такой подход уменьшает влияние случайного шума и секторальных колебаний, делая управление риском более точным. Практические аспекты реализации Реализация PCA на финансовых данных требует строгой предобработки и выбора параметров оценивания. Малейшие отклонения от корректной процедуры приводят к артефактам в спектре собственных значений. Рис. 6: Первое собственное значение резко возрастает в кризисные / стрессовые периоды на рынке из-за концентрации риска на рыночном факторе. В спокойные периоды вклад первой компоненты снижается до 30–40% Перед анализом доходностей активов важно правильно подготовить данные. Для стабильного построения моделей и корректного применения методов вроде PCA или факторного анализа необходимо устранить выбросы и заполнить пропуски в данных. Ниже представлен пример функции, которая решает эти задачи: import pandas as pd def prepare_returns(df): # Лог-доходности rets = np.log(df).diff().dropna(how='all') # Внизоризация 1% квантилей по каждому активу lower = rets.quantile(0.01, axis=0) upper = rets.quantile(0.99, axis=0) rets = rets.clip(lower, upper, axis=1) # Интерполяция Forward-fill максимум 5 дней, затем удаление rets = rets.replace([-np.inf, np.inf], np.nan) rets = rets.ffill(limit=5) rets = rets.dropna(axis=1, thresh=int(len(rets)*0.8)) # активы с >80% данных # Заполнение оставшихся пропусков медианой по сечению rets = rets.fillna(rets.median(axis=1), axis=0) return rets clean_returns = prepare_returns(data) print(clean_returns.tail(5)) Ticker AAPL ADBE AMZN DIS GOOGL HD INTC JNJ JPM MA META MSFT NFLX NVDA PG PYPL TSLA UNH V VZ Date 2025-11-21 0.019490 0.037045 0.016217 0.015267 0.034666 0.032384 0.025838 0.004079 -0.001207 0.023441 0.008619 -0.013277 -0.012954 -0.009791 0.018255 0.041548 -0.010530 0.026699 0.012919 0.011465 2025-11-24 0.016186 -0.016985 0.025014 -0.022695 0.053282 -0.019827 0.036709 0.010489 -0.000067 -0.004730 0.031146 0.003974 0.025181 0.020309 -0.026453 -0.000165 0.066017 -0.002879 0.004016 -0.025050 2025-11-25 0.003798 0.002569 0.014870 0.013350 0.015140 0.034584 0.001117 0.009334 0.016639 0.015900 0.037098 0.006288 -0.024319 -0.026252 0.010221 0.010839 0.003870 0.022408 0.015757 0.009406 2025-11-26 0.002092 -0.006373 -0.002223 0.001161 -0.010849 0.012455 0.026984 0.004297 0.015198 -0.002822 -0.004111 0.017684 0.016529 0.013628 -0.001618 0.009915 0.016975 0.010458 -0.002214 0.007119 2025-11-28 0.004673 0.008186 0.017562 0.010005 0.000719 0.004071 0.075207 -0.003088 0.017528 0.010224 0.022380 0.013320 0.013476 -0.018250 -0.000607 0.013813 0.008381 0.000182 0.001945 0.005610 Последовательная очистка от экстремальных движений и пропусков сохраняет реальную структуру ковариации активов, предотвращает доминирование отдельных резких событий и делает данные более устойчивыми для анализа. Стабилизация ковариационной матрицы Ковариационная матрица доходностей часто нестабильна из-за ограниченного числа наблюдений и высокой волатильности отдельных активов. Прямое использование такой матрицы может привести к переобучению при построении портфелей или факторных моделей. Для повышения устойчивости применяют методы сжатия, которые сглаживают малые собственные числа и уменьшают влияние шумовых факторов. from sklearn.covariance import LedoitWolf # Сжатие методом Ледуа-Вульфа (Ledoit-Wolf Shrinkage) lw = LedoitWolf().fit(clean_returns) cov_stabilized = lw.covariance_ shrinkage = lw.shrinkage_ # Сравнение спектров eig_clean = np.linalg.eigvalsh(np.cov(clean_returns.T)) eig_lw = np.linalg.eigvalsh(cov_stabilized) print(f"Shrinkage intensity: {shrinkage:.3f}") Shrinkage intensity: 0.007 Метод Ледуа-Вульфа автоматически подбирает оптимальную интенсивность сжатия, которая обычно лежит в диапазоне 0.15–0.35 для дневных доходностей. В нашем примере она получилась очень низкой (0.007), что говорит о том, что ковариационная матрица уже была относительно стабильной. Такое сжатие сглаживает малые собственные числа, снижает риск переобучения и делает построение портфелей более надежным. Кроме того, оно сохраняет структуру значимых факторов и не искажает крупные систематические связи между активами. Анализ через скользящее окно (Rolling window) Для анализа динамики структуры ковариации и главных компонент удобно использовать скользящее окно (rolling window). В отличие от накопительного окна (expanding window), где каждая оценка учитывает все данные с начала периода, скользящее окно позволяет отслеживать локальные изменения в рисках и корреляциях активов на фиксированном временном горизонте. Такой подход особенно полезен при работе с финансовыми временными рядами, где волатильность и зависимости между активами могут меняться во времени. В следующей функции мы вычисляем собственные значения ковариационной матрицы по скользящему окну с центровкой данных и сохраняем только первые n_components главных компонент. def rolling_pca(rets, window=252, min_periods=180, n_components=10): eigvals_history = [] dates = [] for i in range(window, len(rets)): window_data = rets.iloc[i-window:i] if len(window_data) < min_periods: continue X = window_data.values X_c = X - X.mean(axis=0) # SVD _, s, _ = np.linalg.svd(X_c, full_matrices=False) eig = s**2 / (len(X)-1) # Берем только нужное число компонент, дополняем нулями если нужно eig_padded = np.zeros(n_components) n = min(len(eig), n_components) eig_padded[:n] = eig[:n] eigvals_history.append(eig_padded) dates.append(rets.index[i]) return pd.DataFrame(eigvals_history, index=dates, columns=[f'PC{i+1}' for i in range(n_components)]) rolling_eigs = rolling_pca(clean_returns) print(rolling_eigs.tail()) PC1 PC2 PC3 PC4 PC5 PC6 PC7 PC8 PC9 PC10 2025-11-21 0.003115 0.000935 0.000681 0.000617 0.000425 0.000283 0.000249 0.000234 0.000198 0.000168 2025-11-24 0.003117 0.000935 0.000691 0.000613 0.000425 0.000290 0.000249 0.000234 0.000199 0.000167 2025-11-25 0.003142 0.000937 0.000699 0.000613 0.000422 0.000288 0.000252 0.000239 0.000200 0.000168 2025-11-26 0.003144 0.000935 0.000705 0.000615 0.000420 0.000288 0.000249 0.000238 0.000204 0.000165 2025-11-28 0.003146 0.000936 0.000703 0.000606 0.000417 0.000290 0.000250 0.000236 0.000204 0.000165 Результат функции — датафрейм с временным рядом собственных значений для каждой из выбранных компонент. Каждая строка соответствует конкретной дате, а столбцы — значения первых 10 главных компонент (PC1…PC10). Анализ этих временных рядов позволяет выявлять периоды концентрации риска в отдельных компонентах и динамику декорреляции активов. При этом скользящее окно сохраняет адаптивность оценки, предотвращая сильное влияние старых данных и обеспечивая более точное отражение текущей рыночной структуры. Ограничения метода и риски применения Метод анализа главных компонент (PCA) основан на предположении линейности взаимосвязей между активами и стационарности 2-го момента (ковариаций). На практике финансовые рынки редко соответствуют этим идеальным условиям, поэтому результаты PCA требуют аккуратной интерпретации. Нестационарность корреляций Корреляционная структура рынка может существенно меняться за считанные недели, особенно в периоды кризисов или резких изменений макроэкономических условий. Использование rolling PCA с коротким окном (менее 180 дней) позволяет отслеживать эти изменения, но одновременно увеличивает шум в оценках, делая малые собственные числа менее стабильными. Слишком длинное окно, наоборот, сглаживает изменения и может не успевать отражать текущую структуру рисков. Чувствительность к экстремальным событиям Резкие движения рынка, например одномоментные падения или рост на 10% и более, могут сильно повлиять на ковариационную матрицу и, как следствие, на главные компоненты. Чтобы снизить влияние таких выбросов, применяют технику винзоризации, робастные оценки ковариации (например, Minimum Covariance Determinant или Ledoit-Wolf Shrinkage), либо комбинацию этих методов. Это помогает сохранить структуру факторов и предотвратить доминирование отдельных экстремальных событий. Проблема знака и ротации компонент Собственные векторы PCA определяются с точностью до знака. При последовательной оценке на скользящем окне компонента может «перевернуться», что нарушает непрерывность временного ряда факторов. Для корректного анализа необходимо фиксировать знак каждой компоненты относительно предыдущего окна, например, по корреляции с предыдущим вектором. Также стоит учитывать возможность ротации близких по значению компонент, которая может изменить экономическую интерпретацию факторов. Заключение Анализ главных компонент PCA позволяет разложить риск на независимые систематические источники. Первая компонента отражает общий рыночный фактор, следующие 5–15 — основные секторальные и стилевые движения, остаток содержит предсказуемый идиосинкратический риск. Разложение проводится через SVD, что обеспечивает стабильность вычислений и корректную оценку собственных чисел даже при высокой коллинеарности активов или большом числе инструментов. При правильной очистке данных, использовании сжатия и адаптивного окна PCA превращается в надежный инструмент управления риском: от точного хеджирования беты до стратегий статистического арбитража с высоким коэффициентом Шарпа. Экономическая интерпретация компонент позволяет получить важное конкурентное преимущество в алгоритмическом трейдинге. ### Обзор книги "Apache Superset Quick Start Guide" Книга "Apache Superset Quick Start Guide" за авторством Ш. Шекхара, издана Packt Publishing в 2018 году. Цель книги - демострация преимуществ и возможностей Apache Superset, и то как их применять на практике. Автор подробно разбирает подключение Superset к базам данных, работающим на языке SQL. В книге рассматриваются такие системы, как PostgreSQL, Google BigQuery, Snowflake и MySQL. Также описано, как создавать визуализации в режиме реального времени с помощью веб-интерфейса Superset. Книга ориентирована на команды, которым нужна open-source BI-система, как замена проприетарным корпоративным решениям. Автор показывает, как начать работу с Superset без сложной подготовки и как использовать эту BI-систему для совместной работы аналитиков, бизнес-специалистов и инженеров. Инструмент подходит тем, кто хочет работать с данными без глубоких знаний программирования. Целевая аудитория — специалисты по анализу данных, BI-аналитики и разработчики. Знание языка Python желательно, но не обязательно: автор выстраивает материал так, чтобы читатель мог работать даже без серьезных навыков программирования. Обложка книги "Apache Superset Quick Start Guide" Ключевые аспекты Главная идея книги — демонстрация возможностей Apache Superset как полноценной веб-платформы для визуализации данных. Инструмент позволяет создавать дашборды и управлять ими через встроенную систему прав доступа и пользовательских ролей. 1. Установка и настройка инстанса Superset Автор подробно разбирает механизм от установки необходимых зависимостей до развертывания сервиса на базе Google Compute Engine. Процесс включает создание виртуальной среды Python, настройку администратора через систему управления приложением Flask-AppBuilder (fabmanager), а также инициализацию базы мета-данных. Конфигурация также охватывает подключение Superset к веб-серверам, таким как Gunicorn, NGINX и Apache HTTP, которые обрабатывают сетевые запросы по протоколам HTTP/HTTPS. 2. Подключение источников данных и создание наборов данных (datasets) Книга показывает, как подключать к Superset разные базы данных с помощью SQLAlchemy. Процесс включает 3 шага: Настройка подключения к базе данных; Проверка соединения; Регистрация таблиц в качестве datasets внутри Superset. Отдельно рассматривается работа с облачными хранилищами Google BigQuery и Snowflake, которые требуют особых методов аутентификации. Автор объясняет, как создавать виртуальные наборы данных (virtual datasets) через среду для работы с SQL-запросами — SQL Lab. В этом интерфейсе можно писать кастомные SQL-запросы, а полученные результаты сохранять как повторно используемые датасеты. 3. Создание визуализаций через интерфейс Книга рассматривает основные виды визуализаций: Диаграммы временных рядов (time-series charts) для анализа трендов: линейные, столбчатые и area charts; Диаграммы распределения (distribution charts): диаграммы рассеяния (box plots), «виолончельные» графики (violin plots) и гистограммы; Геопространственные визуализации для карт, созданные с использованием интеграции deck.gl; Сводные таблицы (pivot tables) для многомерного анализа. Каждая диаграмма имеет свои настройки: цветовые схемы, аннотации, фильтры и группировки по измерениям. Автор также объясняет контекст запросов — как Superset преобразует выбор пользователя в интерфейсе в запросы на языке SQL к базе данных. 4. Построение дашбордов и управление доступом Дашборды в Superset создаются через простой drag-and-drop на сеточном холсте. Диаграммы добавляются как отдельные компоненты. Их можно менять по размеру и свободно размещать на холсте. Макет автоматически подстраивается под разные размеры экранов благодаря адаптивной верстке. Фильтры на уровне дашборда делают панели интерактивными: изменение одного фильтра обновляет все связанные визуализации. Контроль доступа к данным обеспечивается через механизм построчного ограничения (Row Level Security, RLS). Он позволяет показывать пользователям только те строки данных, которые соответствуют их правам. Superset применяет ограничения через автоматическое добавление условий в SQL-запросы (WHERE-условия). 5. Настройка и расширение возможностей Superset Apache Superset работает на основе фреймворков Flask и React, поэтому его можно гибко настраивать через файл конфигурации superset_config.py. Автор показывает, как можно подключать к инстансу разные способы аутентификации, такие как OAuth, LDAP и OpenID. Также описывается интеграция с системой очередей Celery для выполнения задач в фоне (асинхронные задачи), а также настройка кеширования результатов запросов с помощью Redis или Memcached. Superset поддерживает подключение собственных визуализаций через плагины (visualization plugins). Это позволяет добавлять новые типы диаграмм, созданные на компонентах React. Примеры и кейсы В книге используются демонстрационные датасеты, взятые из открытого доступа. Реальных бизнес-примеров тут нет — автор делает упор на технические возможности платформы. Все визуализации представлены в виде скриншотов интерфейса Superset со пошаговыми инструкциями. Основное внимание уделено работе через пользовательский интерфейс. Кода в книге минимум — только SQL-запросы и небольшие фрагменты конфигурации. Полезность книги Данная книга может стать хорошим стартом для начала работы с Apache Superset. Однако глубина проработки тем в ней совсем неравномерна. Что раскрыто хорошо? Пошаговая установка — от среды разработки до развертывания у облачных провайдеров. Автор приводит конкретные команды и примеры конфигурации для Gunicorn, NGINX и сервисов systemd; Работа с SQL Lab и создание virtual datasets через собственные SQL-запросы. Подробно объяснены планирование запросов, кеширование результатов и механизмы шаринга; Создание чартов и дашбордов через UX интерфейс программы. Что раскрыто плохо? Оптимизация производительности и масштабирование больших наборов данных. Почти не рассматриваются оптимизация запросов, индексация баз данных, распределенное кеширование и горизонтальное масштабирование на нескольких экземплярах Superset; Нет примеров и инструкций по кастомизации настроек и разработке собственных плагинов визуализаций; Не рассмотрены практики безопасности и интеграции с корпоративными системами аутентификации; Очень мало примеров SQL кода. Вердикт Данная книга будет полезной для тех, кто хочет быстро начать работу с Apache Superset и ознакомиться со всеми его основными фичами. Книга не является полноценной заменой документации. Но автор и не ставит такой задачу. Шекхар предлагает быстрый последовательный путь: от установки системы до создания первых дашбордов. Прочитав данную книгу можно получить практическое понимание архитектуры Apache Superset, основных идей и типичного воркфлоу работы с инструментом, что позволит всего за несколько часов перейти к написанию SQL витрин, построению чартов и к анализу данных. Приобрести книгу можно здесь: https://www.amazon.com/Apache-Superset-Quick-Start-Guide-ebook/dp/B07M8QLR8P ### Масштабирование признаков в ML: StandardScaler, MinMaxScaler, RobustScaler и другие методы Масштабирование признаков — базовая процедура предобработки данных, влияющая на скорость обучения и качество предсказаний большинства алгоритмов машинного обучения. Признаки в датасете часто имеют разные единицы измерения и диапазоны значений: цена акции может варьироваться от $10 до $500, объем торгов — от сотен тысяч до миллиардов, а волатильность измеряется в процентах от 5% до 80%. Без масштабирования ML-алгоритмы, основанные на расстояниях или градиентах, будут некорректно оценивать важность признаков. Библиотека scikit-learn предоставляет несколько методов масштабирования (скейлеров), каждый из которых решает специфические задачи: StandardScaler применяет z-score нормализацию для приведения признаков к нулевому среднему и единичной дисперсии (масштабирование к среднему 0 и стандартному отклонению 1); MinMaxScaler линейно масштабирует данные в заданный диапазон (обычно [0, 1] или [-1, 1]), сохраняя исходную форму распределения; RobustScaler использует медиану и межквартильный размах (приведение к медиане 0 и IQR 1), что делает его устойчивым к выбросам. Выбор между этими методами определяется характеристиками данных, требованиями алгоритма и спецификой задачи. Понимание математики каждого метода и его влияния на данные позволяет избежать типичных ошибок: утечки данных из будущего при обучении масштабированных признаков на полном датасете, некорректного масштабирования целевой переменной в задачах регрессии, игнорирования выбросов в финансовых данных. Корректное применение масштабирования в ML-пайплайнах повышает стабильность моделей и качество предсказаний. Зачем ML-моделям требуется масштабирование данных? Влияние масштабирования на качество и устойчивость моделей определяется особенностями используемого алгоритма. Методы, основанные на расстояниях или градиентных вычислениях, чувствительны к абсолютным значениям признаков и требуют корректного приведения данных к единой шкале. В то же время алгоритмы, строящие разбиения пространства признаков, практически не зависят от масштаба и работают стабильно без дополнительного нормирования. Алгоритмы, чувствительные к масштабу Градиентные методы оптимизации Градиентный спуск и его вариации (SGD, Adam и др.) вычисляют производные функции потерь по каждому параметру модели. Если признаки имеют несопоставимые масштабы, параметры, связанные с признаками большого диапазона, то они получают значительно более крупные градиенты. Это вызывает неравномерные шаги обновления: одни веса изменяются слишком агрессивно, другие — чрезмерно медленно. В итоге модель сходится медленнее, либо застревает в локальных минимумах. Линейные модели с регуляризацией В линейной регрессии и логистической регрессии с L1/L2 регуляризацией масштабы признаков напрямую влияют на величину штрафов. Признаки с большими значениями получают более жесткие наказания, что искусственно занижает их коэффициенты и искажает интерпретацию важности признаков. Это приводит к ухудшению качества модели и некорректному подбору регуляризационных параметров. Модели, основанные на расстояниях Модели SVM (Support Vector Machine — Метод опорных векторов), KNN (k-Nearest Neighbors — Метод k ближайших соседей) и другие алгоритмы, использующие евклидово расстояние, крайне чувствительны к масштабу. Признаки с большими значениями начинают доминировать в метрике расстояния, подавляя вклад маломасштабных признаков: в SVM это приводит к смещению разделяющей гиперплоскости и ухудшению качества классификации или регрессии; в KNN это приводит к некорректному поиску ближайших объектов: модель фактически ориентируется только на признаки с большим масштабом и игнорирует остальные. Поэтому для моделей этого типа обязательны процедуры масштабирования данных — стандартизация (StandardScaler) или нормализация (MinMaxScaler). Нейронные сети Нейронные сети обучаются с помощью градиентных методов и используют функции активации, эффективные лишь в ограниченных диапазонах входных значений. Неотмасштабированные признаки могут приводить к взрывному или затухающему градиенту, а также к попаданию входов в зоны насыщения sigmoid или tanh. Это резко замедляет обучение и ухудшает способность сети к генерализации. Алгоритмы, не чувствительные к масштабу признаков Многие модели принимают решения на основе порядка или локальных сравнений внутри признака, а не его абсолютных величин. Для таких алгоритмов масштабирование не является обязательным условием корректной работы. Деревья решений и ансамбли деревьев Алгоритмы, основанные на разбиении пространства признаков: Decision Tree, Random Forest, Extra Trees, Gradient Boosting, XGBoost, LightGBM, CatBoost — не зависят от масштаба данных. Разбиения строятся по условиям вида feature ≤ threshold, и сами пороги подстраиваются под диапазон каждого признака. Поэтому разные масштабы признаков не влияют: на форму дерева; на важности признаков; на итоговое качество модели. Однако масштабирование может быть полезно косвенно — например, для ускорения обучения больших GBDT-моделей или при использовании смешанных пайплайнов. Наивный байесовский классификатор Наивный Байес (Naive Bayes) опирается на условные вероятности и плотности распределений. Масштаб признаков влияет только на параметры распределений, но не нарушает логику работы классификатора, поскольку сравнение вероятностей производится в общем масштабе. Правило ближайшего центра (Nearest Centroid) Здесь сравнение происходит по расстоянию до центроидов, но признаки предварительно сводятся к средним. Масштаб признаков влияет лишь на абсолютные расстояния, но не нарушает структуру классификации — хотя в задачах с сильно разнородными признаками масштабирование все же рекомендуется. Влияние на скорость обучения и качество предсказаний Скорость сходимости градиентных методов напрямую зависит от формы поверхности функции потерь. Без масштабирования поверхность потерь вытягивается вдоль осей признаков с большими значениями, формируя узкие овраги. Градиентный спуск в таких оврагах делает много мелких шагов поперек направления минимума и медленно продвигается вдоль оврага. Это увеличивает количество итераций до достижения сходимости в 5-10 раз. Рис. 1: Влияние масштабирования на форму поверхности функции потерь. Левая панель показывает вытянутую поверхность без масштабирования — узкий овраг вдоль оси признака с малым масштабом. Средняя панель демонстрирует изотропную (симметричную) поверхность после StandardScaler. Правая панель сравнивает контуры: вытянутые эллипсы (красный) требуют zigzag траектории градиентного спуска, круговые контуры (синий) позволяют прямое движение к минимуму Корректный выбор learning rate в условиях неотмасштабированных признаков превращается в чрезвычайно трудную задачу: Слишком маленькое значение приводит к чрезмерно медленному обновлению всех параметров; Слишком большое — вызывает нестабильность в направлении признаков с крупным масштабом: шаги становятся «рывками», модель осциллирует вокруг минимума или вовсе выходит за пределы области устойчивости. Масштабирование выравнивает поверхность потерь, делая ее ближе к изотропной. Это позволяет использовать единый и более крупный learning rate, ускоряя и стабилизируя обучение. Качество предсказаний также ухудшается из-за некорректной оценки важности признаков. В линейных моделях коэффициенты напрямую отражают вклад признаков в целевую переменную. Без масштабирования коэффициент признака с малым диапазоном значений становится искусственно завышенным, тогда как коэффициент признака с большим масштабом — заниженным. Это искажает интерпретацию модели, усложняет анализ значимости признаков и может привести к ошибочным выводам. В регуляризованных моделях (L1/L2) отсутствие масштабирования деформирует процесс отбора признаков. Так, к примеру, L1-регуляризация (Lasso), стремясь занулять неинформативные коэффициенты, опирается на их абсолютные значения. Без выравнивания масштаба Lasso может преждевременно отбросить признаки с малым диапазоном значений, даже если они содержат значимую информацию, и, наоборот, сохранить признаки большого масштаба, вклад которых в прогноз минимален. Это приводит к ухудшению структуры модели и снижению качества обобщения. StandardScaler: масштабирование до нормального распределения StandardScaler выполняет z-score стандартизацию, преобразуя признаки к нулевому среднему и единичной дисперсии. Метод предполагает, что данные имеют нормальное или близкое к нормальному распределение. Стандартизация через скейлер StandardScaler сохраняет информацию о выбросах, но делает признаки с выбросами менее стабильными. Математический аппарат Преобразование каждого значения признака выполняется по формуле z-score: z = (x - μ) / σ где: x — исходное значение признака; μ — среднее арифметическое признака по обучающей выборке; σ — стандартное отклонение признака по обучающей выборке; z — стандартизованное значение. Среднее μ рассчитывается как сумма всех значений признака, деленная на количество наблюдений. Стандартное отклонение σ измеряет разброс значений относительно среднего. Вычитание среднего центрирует распределение в нуле, деление на стандартное отклонение нормализует масштаб до единицы. После преобразования признак имеет среднее 0 и стандартное отклонение 1. Значения интерпретируются как количество стандартных отклонений от среднего: z = 2 означает, что значение на 2σ выше среднего; z = -1.5 — на 1.5σ ниже среднего. Свойства и особенности применения StandardScaler оптимален для признаков с распределением, близким к нормальному. Если данные имеют гауссово распределение, после стандартизации около 68% значений попадают в диапазон [-1, 1], 95% — в [-2, 2], 99.7% — в [-3, 3]. Это свойство используют для детекции аномалий: значения |z| > 3 считаются выбросами. Однако метод чувствителен к экстремальным значениям. Выбросы влияют на среднее и стандартное отклонение, смещая параметры стандартизации. Один экстремальный выброс может существенно увеличить σ, что приведет к сжатию основной массы данных в узкий диапазон около нуля. Остальные значения потеряют вариативность, важную для алгоритмов ML. Важно также отметить, что StandardScaler не ограничивает диапазон значений. После преобразования признак может принимать любые значения от -∞ до +∞. Это подходит для большинства алгоритмов, но создает проблемы для нейросетей с функциями активации, чувствительными к диапазону входов (sigmoid требует входы в диапазоне примерно [-6, 6] для эффективной работы). Скейлер сохраняет форму распределения. Если исходные данные имели асимметрию или тяжелые хвосты, эти свойства останутся после стандартизации. StandardScaler не делает распределение более нормальным — он только изменяет масштаб. Практическая реализация Давайте рассмотрим как будет вести себя StandardScaler на синтетических данных, имитирующих финансовые временные ряды. import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.preprocessing import StandardScaler np.random.seed(42) # Генерация синтетических данных n_samples = 252 # торговый год dates = pd.date_range('2024-11-01', periods=n_samples, freq='D') # Цена закрытия: случайное блуждание с трендом price_trend = np.linspace(100, 110, n_samples) price_noise = np.random.normal(0, 5, n_samples) prices = price_trend + price_noise # Объем торгов: логнормальное распределение volumes = np.random.lognormal(13.5, 0.3, n_samples) # Создание датафрейма df = pd.DataFrame({ 'Date': dates, 'Close': prices, 'Volume': volumes }) # Применение StandardScaler scaler = StandardScaler() df[['Close_scaled', 'Volume_scaled']] = scaler.fit_transform(df[['Close', 'Volume']]) # Визуализация fig, axes = plt.subplots(2, 2, figsize=(14, 10)) # Исходные данные axes[0, 0].plot(df['Date'], df['Close'], color='black', linewidth=1) axes[0, 0].set_title('Цена закрытия (исходная)', fontsize=11, fontweight='bold') axes[0, 0].set_ylabel('Цена, $') axes[0, 0].grid(True, alpha=0.3) axes[0, 1].plot(df['Date'], df['Volume'], color='black', linewidth=1) axes[0, 1].set_title('Объем торгов (исходный)', fontsize=11, fontweight='bold') axes[0, 1].set_ylabel('Объем') axes[0, 1].grid(True, alpha=0.3) # Стандартизованные данные axes[1, 0].plot(df['Date'], df['Close_scaled'], color='#2E86AB', linewidth=1) axes[1, 0].axhline(y=0, color='red', linestyle='--', linewidth=0.8, alpha=0.7) axes[1, 0].axhline(y=1, color='gray', linestyle=':', linewidth=0.8, alpha=0.5) axes[1, 0].axhline(y=-1, color='gray', linestyle=':', linewidth=0.8, alpha=0.5) axes[1, 0].set_title('Цена закрытия (StandardScaler)', fontsize=11, fontweight='bold') axes[1, 0].set_ylabel('z-score') axes[1, 0].set_xlabel('Дата') axes[1, 0].grid(True, alpha=0.3) axes[1, 1].plot(df['Date'], df['Volume_scaled'], color='#2E86AB', linewidth=1) axes[1, 1].axhline(y=0, color='red', linestyle='--', linewidth=0.8, alpha=0.7) axes[1, 1].axhline(y=1, color='gray', linestyle=':', linewidth=0.8, alpha=0.5) axes[1, 1].axhline(y=-1, color='gray', linestyle=':', linewidth=0.8, alpha=0.5) axes[1, 1].set_title('Объем торгов (StandardScaler)', fontsize=11, fontweight='bold') axes[1, 1].set_ylabel('z-score') axes[1, 1].set_xlabel('Дата') axes[1, 1].grid(True, alpha=0.3) plt.tight_layout() plt.show() # Статистика преобразования print("Статистика до стандартизации:") print(df[['Close', 'Volume']].describe()) print("\nСтатистика после стандартизации:") print(df[['Close_scaled', 'Volume_scaled']].describe()) print(f"\nПараметры StandardScaler:") print(f"Средние значения: {scaler.mean_}") print(f"Стандартные отклонения: {scaler.scale_}") Рис. 2: Влияние стандартизации (StandardScaler) на временные ряды цены и объема: верхний ряд — исходные данные, нижний ряд — стандартизованные данные (среднее = 0, σ = 1) Обратите внимание на шкалу значений по оси y для верхних графиков и нижних. Заметно как StandardScaler отцентровывает данные по 0 и уже относительно них определяет амплитуды. Статистика до стандартизации: Close Volume count 252.000000 2.520000e+02 mean 104.981176 7.690148e+05 std 5.853713 2.378962e+05 min 89.849482 2.758520e+05 25% 101.040716 5.909502e+05 50% 105.061273 7.311119e+05 75% 108.658175 8.952815e+05 max 127.590351 1.837037e+06 Статистика после стандартизации: Close_scaled Volume_scaled count 2.520000e+02 2.520000e+02 mean -5.639228e-17 7.577713e-17 std 1.001990e+00 1.001990e+00 min -2.590118e+00 -2.077142e+00 25% -6.744951e-01 -7.499863e-01 50% 1.371036e-02 -1.596424e-01 75% 6.293983e-01 5.318199e-01 max 3.870051e+00 4.498378e+00 Параметры StandardScaler: Средние значения: [1.04981176e+02 7.69014786e+05] Стандартные отклонения: [5.84208725e+00 2.37423742e+05] В статистике выше отдельно стоит обратить внимание на показатель std (стандартное отклонение): в исходных данных он составляет 5.853713, в то время как после работы StandardScaler - это 1. Отдельно стоит отметить, что отмасштабированные данные сохраняют относительные расстояния между точками. Если в исходных данных две цены отличались на 2σ, после стандартизации разница останется той же — 2. Это свойство особенно важно для алгоритмов, основанных на расстояниях, таких как KNN, SVM и методы кластеризации. После преобразования параметры scaler.mean_ и scaler.scale_ сохраняются. Они используются для преобразования новых данных методом transform(), что гарантирует применение тех же параметров масштабирования к тестовой выборке. Такой подход предотвращает утечку данных (data leakage) и обеспечивает корректную оценку качества модели на новых данных. MinMaxScaler: масштабирование в фиксированный диапазон Метод MinMaxScaler выполняет линейное масштабирование признаков в заданный диапазон, по умолчанию [0, 1]. Скейлер сохраняет форму исходного распределения и соотношения между значениями. Как и StandardScaler, метод MinMaxScaler чувствителен к выбросам: единичное экстремальное значение может сжать основную массу данных в узкий диапазон. Математический аппарат Преобразование значения признака в диапазон [0, 1] выполняется по формуле: x_scaled = (x - x_min) / (x_max - x_min) где: x — исходное значение признака; x_min — минимальное значение признака в обучающей выборке; x_max — максимальное значение признака в обучающей выборке; x_scaled — масштабированное значение в диапазоне [0, 1]. Числитель (x - x_min) сдвигает все значения так, что минимум становится равным нулю. Деление на диапазон (x_max - x_min) нормализует масштаб: максимальное значение преобразуется в 1, промежуточные значения распределяются пропорционально между 0 и 1. Для масштабирования в произвольный диапазон [a, b] формула расширяется: x_scaled = a + (x - x_min) × (b - a) / (x_max - x_min) Где параметр a определяет нижнюю границу диапазона, b — верхнюю. В sklearn это задается через параметр feature_range=(a, b). Распространенные варианты: [-1, 1] для данных с отрицательными значениями, [0, 255] для изображений. Когда использовать MinMaxScaler? Метод MinMaxScaler особенно полезен для признаков с известными границами. Если значение по определению ограничено диапазоном (проценты 0–100%, рейтинги 1–5, вероятности 0–1). Для таких данных нормализация в диапазон [0, 1] выглядит естественно и легко интерпретируема. После преобразования 0 соответствует минимальному значению признака, а 1 — максимальному. Нейронные сети с сигмоидальными функциями активации (sigmoid, tanh) обучаются эффективнее при входах в ограниченном диапазоне. Большие по модулю значения попадают в области насыщения функций, где градиенты близки к нулю. Масштабирование в [0, 1] или [-1, 1] удерживает активации в рабочем диапазоне, ускоряя и стабилизируя обучение. Кроме того, MinMaxScaler сохраняет нули в разреженных данных, если минимальное значение равно нулю. Это особенно важно для преобразований типа "мешок слов" (bag-of-words) или one-hot кодирования, где большинство значений исходно нулевые. В отличие от этого, StandardScaler превратил бы нули в отрицательные значения, нарушив структуру разреженной матрицы. Однако, нужно учитывать, что метод MinMaxScaler плохо подходит для данных с выбросами. Один экстремальный выброс расширяет диапазон (x_max - x_min), сжимая основную массу данных в узкий интервал. Например, если большинство цен акции находится в диапазоне $90–110, но есть один день с ценой $200 (например, из-за сплита, не учтенного в данных, либо спайка в котировкой в фиде от брокера), остальные значения окажутся в диапазоне [0, 0.18], теряя вариативность и информативность признака. Кроме того, MinMaxScaler не гарантирует нормальность распределения. Если исходные данные имеют правостороннюю асимметрию (длинный хвост справа), после нормализации асимметрия сохраняется. Для алгоритмов, которые предполагают нормальное распределение признаков, таких как линейная регрессия или LDA (Linear Discriminant Analysis — Линейный дискриминантный анализ), может потребоваться дополнительное преобразование данных. Практическая реализация Давайте рассмотрим как будет вести себя MinMaxScaler на синтетических данных, похожих на финансовые временные ряды. import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.preprocessing import MinMaxScaler np.random.seed(42) # Генерация синтетических данных n_samples = 252 # торговый год dates = pd.date_range('2024-11-01', periods=n_samples, freq='D') # Цена закрытия: случайное блуждание с трендом price_trend = np.linspace(100, 110, n_samples) price_noise = np.random.normal(0, 5, n_samples) prices = price_trend + price_noise # Объем торгов: логнормальное распределение volumes = np.random.lognormal(13.5, 0.3, n_samples) # Создание датафрейма df = pd.DataFrame({ 'Date': dates, 'Close': prices, 'Volume': volumes }) # Применение MinMaxScaler scaler = MinMaxScaler() df[['Close_scaled', 'Volume_scaled']] = scaler.fit_transform(df[['Close', 'Volume']]) # Визуализация fig, axes = plt.subplots(2, 2, figsize=(14, 10)) # Исходные данные axes[0, 0].plot(df['Date'], df['Close'], color='black', linewidth=1) axes[0, 0].set_title('Цена закрытия (исходная)', fontsize=11, fontweight='bold') axes[0, 0].set_ylabel('Цена, $') axes[0, 0].grid(True, alpha=0.3) axes[0, 1].plot(df['Date'], df['Volume'], color='black', linewidth=1) axes[0, 1].set_title('Объем торгов (исходный)', fontsize=11, fontweight='bold') axes[0, 1].set_ylabel('Объем') axes[0, 1].grid(True, alpha=0.3) # Масштабированные данные axes[1, 0].plot(df['Date'], df['Close_scaled'], color='#2E86AB', linewidth=1) axes[1, 0].axhline(y=0.5, color='red', linestyle='--', linewidth=0.8, alpha=0.7) # центр диапазона axes[1, 0].axhline(y=0, color='gray', linestyle=':', linewidth=0.8, alpha=0.5) # нижняя граница axes[1, 0].axhline(y=1, color='gray', linestyle=':', linewidth=0.8, alpha=0.5) # верхняя граница axes[1, 0].set_title('Цена закрытия (MinMaxScaler)', fontsize=11, fontweight='bold') axes[1, 0].set_ylabel('Масштаб [0, 1]') axes[1, 0].set_xlabel('Дата') axes[1, 0].grid(True, alpha=0.3) axes[1, 1].plot(df['Date'], df['Volume_scaled'], color='#2E86AB', linewidth=1) axes[1, 1].axhline(y=0.5, color='red', linestyle='--', linewidth=0.8, alpha=0.7) # центр диапазона axes[1, 1].axhline(y=0, color='gray', linestyle=':', linewidth=0.8, alpha=0.5) # нижняя граница axes[1, 1].axhline(y=1, color='gray', linestyle=':', linewidth=0.8, alpha=0.5) # верхняя граница axes[1, 1].set_title('Объем торгов (MinMaxScaler)', fontsize=11, fontweight='bold') axes[1, 1].set_ylabel('Масштаб [0, 1]') axes[1, 1].set_xlabel('Дата') axes[1, 1].grid(True, alpha=0.3) plt.tight_layout() plt.show() # Статистика преобразования print("Статистика до масштабирования:") print(df[['Close', 'Volume']].describe()) print("\nСтатистика после масштабирования MinMaxScaler:") print(df[['Close_scaled', 'Volume_scaled']].describe()) print(f"\nПараметры MinMaxScaler:") print(f"Минимальные значения: {scaler.data_min_}") print(f"Максимальные значения: {scaler.data_max_}") print(f"Диапазон (data_range_): {scaler.data_range_}") Рис. 3: Масштабирование признаков MinMaxScaler для финансовых данных. Верхний ряд — исходные временные ряды цены и объема с разными масштабами. Нижний ряд — масштабированные данные с диапазоном [0, 1], красная пунктирная линия показывает центр диапазона 0.5. Масштабирование сохраняет относительные различия между точками и делает признаки сопоставимыми для алгоритмов, чувствительных к масштабам Обратите внимание на шкалу значений по оси y для верхних графиков и нижних. Заметно как MinMaxScaler отцентровывает данные по 0.5 и уже относительно них определяет амплитуды. Статистика до масштабирования: Close Volume count 252.000000 2.520000e+02 mean 104.981176 7.690148e+05 std 5.853713 2.378962e+05 min 89.849482 2.758520e+05 25% 101.040716 5.909502e+05 50% 105.061273 7.311119e+05 75% 108.658175 8.952815e+05 max 127.590351 1.837037e+06 Статистика после масштабирования MinMaxScaler: Close_scaled Volume_scaled count 252.000000 252.000000 mean 0.400937 0.315890 std 0.155103 0.152382 min 0.000000 0.000000 25% 0.296528 0.201833 50% 0.403059 0.291612 75% 0.498364 0.396769 max 1.000000 1.000000 Параметры MinMaxScaler: Минимальные значения: [8.98494817e+01 2.75852008e+05] Максимальные значения: [1.27590351e+02 1.83703652e+06] Диапазон (data_range_): [3.77408690e+01 1.56118451e+06] В статистике выше отдельно стоит обратить внимание на показатели min и max: в исходных данных они составляют 89.849482 и 127.590351 соответственно, в то время как после преобразования через MinMaxScaler - это 0 и 1. Представленный выше пример код масштабирует признаки по шкале от 0 до 1. Но метод поддерживает и другие опции. Вот в чем разница: Вариант [0, 1] подходит для признаков, где нулевое значение естественно соответствует минимуму (например, объем торгов не может быть отрицательным); Вариант [-1, 1] удобен для признаков, у которых середина диапазона имеет смысловое значение (например, индекс относительной силы RSI = 50, либо индекс предпринимательской уверенности равный 50 - все они отражают нейтральное состояние рынка). Сохраненные параметры data_min_, data_max_ и data_range_ используются для обратного преобразования через метод inverse_transform(). Это важно, когда модель предсказывает масштабированные значения, а для анализа или визуализации нужны исходные единицы измерения. Ключевое свойство MinMaxScaler — линейность. Если значение x в исходных данных было в два раза больше y, после масштабирования это соотношение сохраняется. Линейность гарантирует, что расстояния между точками изменяются пропорционально, что особенно важно для алгоритмов, использующих метрики расстояний, таких как KNN, SVM или кластеризация. RobustScaler: устойчивость к выбросам Метод масштабирования RobustScaler использует статистики, устойчивые к выбросам: медиану вместо среднего, межквартильный размах (IQR) вместо стандартного отклонения. Скейлер масштабирует данные без смещения параметров из-за экстремальных значений. RobustScaler оптимален для финансовых данных, где выбросы — регулярное явление, а не ошибки измерения. Математический аппарат Преобразование выполняется по формуле: x_scaled = (x - Q₂) / IQR где: x — исходное значение признака; Q₂ — медиана (второй квартиль, 50-й процентиль); IQR — межквартильный размах (Interquartile Range); x_scaled — масштабированное значение. Медиана Q₂ — значение, которое делит упорядоченные данные пополам: 50% наблюдений меньше медианы, 50% больше. В отличие от среднего, медиана не чувствительна к выбросам. Если в данных есть экстремальное значение, оно не смещает медиану, тогда как среднее может измениться существенно. Межквартильный размах IQR вычисляется как разность третьего и первого квартилей: IQR = Q₃ - Q₁ где:Q₁ (первый квартиль, 25-й процентиль) — значение, ниже которого 25% данных; Q₃ (третий квартиль, 75-й процентиль) — значение, ниже которого 75% данных. IQR измеряет разброс центральных 50% данных, игнорируя крайние 25% с каждой стороны. Это делает IQR устойчивым к выбросам в хвостах распределения. Преимущества при работе с зашумленными данными Финансовые рынки часто демонстрируют экстремальные события: резкие распродажи, новостные шоки, корпоративные события (сплиты, дивиденды). Эти события создают выбросы в данных о доходностях, объемах и спредах. Выбросы несут информацию о рыночных режимах и не должны удаляться, но одновременно они не должны искажать масштабирование основной массы данных. Использование StandardScaler в присутствии выбросов приводит к увеличению стандартного отклонения, что сжимает значения основной массы к нулю. Например, если доходность акции обычно находится в диапазоне [-3%, +3%], но один день показал +50% (эффект новостного объявления), стандартное отклонение σ будет рассчитано с учетом этого выброса. В результате обычные колебания ±3% превратятся в z-score около ±0.1, теряя вариативность. Метод RobustScaler решает эту проблему, игнорируя выбросы при вычислении параметров масштабирования. Медиана и межквартильный размах (IQR) рассчитываются по центральной части распределения, поэтому экстремальные значения, например +50%, не влияют на масштабирование. Обычные колебания ±3% сохраняют свою вариативность после преобразования. Метод RobustScaler не ограничивает диапазон значений, как это делает MinMaxScaler. Центральные 50% данных (между Q₁ и Q₃) масштабируются примерно в диапазон [-0.5, 0.5], но выбросы могут принимать значения ±5, ±10 и более. Это позволяет моделям учитывать экстремальные события, не искажая масштаб основных данных. Масштабирование данных с помощью RobustScaler особенно эффективно для данных с тяжелыми хвостами распределений. Финансовые доходности часто имеют лептокуртическое распределение с более толстыми хвостами, чем у нормального распределения. StandardScaler предполагает нормальность данных и может работать хуже в таких случаях, тогда как RobustScaler не делает предположений о форме распределения и сохраняет адекватную масштабировку. Практическая реализация Создадим данные дневных доходностей с выбросами: основная масса в диапазоне [-2%, +2%], но 5% дней имеют экстремальные доходности до ±15%. import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.preprocessing import RobustScaler, StandardScaler, MinMaxScaler np.random.seed(42) n_samples = 500 # Основная масса доходностей returns_base = np.random.normal(0, 1.5, n_samples) # Добавление выбросов: 5% экстремальных значений n_outliers = int(n_samples * 0.05) outlier_indices = np.random.choice(n_samples, n_outliers, replace=False) outliers = np.random.choice([-1, 1], n_outliers) * np.random.uniform(8, 15, n_outliers) returns_base[outlier_indices] = outliers df = pd.DataFrame({ 'Returns': returns_base }) # Масштабирование robust_scaler = RobustScaler() standard_scaler = StandardScaler() minmax_scaler = MinMaxScaler(feature_range=(0, 1)) df['Returns_robust'] = robust_scaler.fit_transform(df[['Returns']]) df['Returns_standard'] = standard_scaler.fit_transform(df[['Returns']]) df['Returns_minmax'] = minmax_scaler.fit_transform(df[['Returns']]) # Визуализация распределений (2 ряда) fig, axes = plt.subplots(2, 1, figsize=(14, 8)) # Верхний ряд: гистограммы axes[0].hist(df['Returns_robust'], bins=50, alpha=0.6, label='RobustScaler', color='#2E86AB', edgecolor='black') axes[0].hist(df['Returns_standard'], bins=50, alpha=0.5, label='StandardScaler', color='#A23B72', edgecolor='black') axes[0].hist(df['Returns_minmax'], bins=50, alpha=0.5, label='MinMaxScaler', color='#F18F01', edgecolor='black') axes[0].axvline(0, color='red', linestyle='--', linewidth=1) axes[0].set_title('Гистограммы масштабированных доходностей', fontsize=12, fontweight='bold') axes[0].set_xlabel('Масштабированные значения') axes[0].set_ylabel('Частота') axes[0].grid(True, alpha=0.3) axes[0].legend() # Нижний ряд: временные ряды axes[1].plot(df.index, df['Returns_robust'], label='RobustScaler', color='#2E86AB', linewidth=1) axes[1].plot(df.index, df['Returns_standard'], label='StandardScaler', color='#A23B72', linewidth=1) axes[1].plot(df.index, df['Returns_minmax'], label='MinMaxScaler', color='#F18F01', linewidth=1) axes[1].axhline(0, color='red', linestyle='--', linewidth=1) axes[1].set_title('Временные ряды масштабированных доходностей', fontsize=12, fontweight='bold') axes[1].set_xlabel('День') axes[1].set_ylabel('Масштабированное значение') axes[1].grid(True, alpha=0.3) axes[1].legend() plt.tight_layout() plt.show() # Отдельный график для сравнения распределений plt.figure(figsize=(12, 6)) box_data = [df['Returns_robust'], df['Returns_standard'], df['Returns_minmax']] labels = ['RobustScaler', 'StandardScaler', 'MinMaxScaler'] plt.boxplot(box_data, labels=labels, patch_artist=True, boxprops=dict(facecolor='#D3D3D3', color='black'), medianprops=dict(color='red', linewidth=2), whiskerprops=dict(color='black'), capprops=dict(color='black'), flierprops=dict(marker='o', markerfacecolor='orange', markersize=5, linestyle='none', markeredgecolor='black')) plt.title('Сравнение масштабированных распределений', fontsize=14, fontweight='bold') plt.ylabel('Масштабированные значения') plt.grid(True, alpha=0.3) plt.show() # Статистика сравнения print("Параметры RobustScaler:") print(f"Медиана: {robust_scaler.center_}") print(f"IQR: {robust_scaler.scale_}") print("\nПараметры StandardScaler:") print(f"Среднее: {standard_scaler.mean_}") print(f"Стандартное отклонение: {standard_scaler.scale_}") print("\nПараметры MinMaxScaler:") print(f"Минимум: {minmax_scaler.data_min_}") print(f"Максимум: {minmax_scaler.data_max_}") # Диапазон центральных 50% print("\nВлияние выбросов на масштабирование (Q1-Q3):") print(f"RobustScaler: [{df['Returns_robust'].quantile(0.25):.2f}, {df['Returns_robust'].quantile(0.75):.2f}]") print(f"StandardScaler: [{df['Returns_standard'].quantile(0.25):.2f}, {df['Returns_standard'].quantile(0.75):.2f}]") print(f"MinMaxScaler: [{df['Returns_minmax'].quantile(0.25):.2f}, {df['Returns_minmax'].quantile(0.75):.2f}]") Рис. 4: Сравнение распределений доходностей после масштабирования различными методами: RobustScaler, StandardScaler и MinMaxScaler. На верхнем графике: гистограммы распределений, красная пунктирная линия показывает нулевое значение. На нижнем графике - динамика временных рядов масштабированных доходностей по всем трем методам Рис. 5: Сравнение масштабированных распределений доходностей с помощью графиков boxplot. Центральная линия в коробке — медиана, края коробки — квартильный размах (Q1–Q3), выбросы показаны точками По графику выше мы видим ключевое отличие методов при работе с данными с выбросами: RobustScaler сохраняет вариативность центральных данных, выбросы остаются видимыми и не искажают масштаб; StandardScaler сжимает основную массу данных, потому что экстремальные значения стремяться расширить стандартное отклонение; MinMaxScaler сжимает практически все данные в узкий диапазон около нуля, сильно теряя вариативность. Параметры RobustScaler: Медиана: [0.0255043] IQR: [2.07063403] Параметры StandardScaler: Среднее: [0.00838531] Стандартное отклонение: [3.0210029] Параметры MinMaxScaler: Минимум: [-14.60986542] Максимум: [14.79740772] Влияние выбросов на масштабирование (Q1-Q3): RobustScaler: [-0.54, 0.46] StandardScaler: [-0.36, 0.32] MinMaxScaler: [0.46, 0.53] Интерпретация показателей: RobustScaler (медиана 0.0255, IQR 2.07) — центральные 50% данных остаются хорошо различимыми, выбросы почти не влияют. Идеально для финансовых рядов с экстремальными событиями; StandardScaler (среднее 0.0084, σ 3.02) — выбросы раздувают стандартное отклонение, поэтому обычные колебания сильно сжаты. А это значит модель «теряет» детали основной массы данных; MinMaxScaler (min -14.61, max 14.80) — экстремальные значения растягивают диапазон, а вся основная масса данных сжимается в узкий интервал около [0.46, 0.53]; чувствительность к выбросам высокая; Сравнение Q1–Q3: Robust [-0.54, 0.46] показывает хорошую вариативность центральных данных, Standard [-0.36, 0.32] слегка сжимает, MinMax [0.46, 0.53] почти нивелирует всякое распределение. Вывод: для зашумленных финансовых данных RobustScaler лучше сохраняет структуру основной массы, тогда как Standard и MinMax искажают распределение из-за влияния экстремальных значений. Параметры center_ и scale_ в RobustScaler соответствуют медиане и межквартильному размаху (IQR). Эти значения используются для масштабирования новых данных через метод transform(). Важно отметить: если в тестовой выборке появятся выбросы, отсутствовавшие в обучающей, RobustScaler корректно обработает их, присвоив большие абсолютные значения, не требуя переобучения самого скейлера. Сравнение методов масштабирования Выбор метода масштабирования зависит от характеристик данных, требований алгоритма и задачи. Универсального решения не существует: каждый метод оптимален для определенного сценария. Систематический подход к выбору основывается на анализе распределения признаков, наличия выбросов и специфики ML-модели. Матрица выбора метода Характеристика данных StandardScaler MinMaxScaler RobustScaler Распределение Нормальное или близкое Любое Любое, особенно с тяжелыми хвостами Наличие выбросов Отсутствуют или удалены Отсутствуют или удалены Присутствуют, несут информацию Диапазон значений Неограничен Bounded [0, 1] или custom Неограничен Оптимальные алгоритмы Линейная регрессия, логистическая регрессия, SVM, нейросети Нейросети с sigmoid/tanh, алгоритмы на основе расстояний Все gradient-based методы при зашумленных данных Интерпретируемость Z-score: количество σ от среднего Позиция в диапазоне min-max Отклонение от медианы в единицах IQR Чувствительность к новым данным Средняя (новые выбросы искажают μ, σ) Высокая (новый max/min меняет диапазон) Низкая (медиана, IQR устойчивы) Типичные задачи Классификация, регрессия на чистых данных Computer vision, bounded признаки (проценты, рейтинги) Финансовые данные, сенсорные измерения, данные с артефактами Таблица демонстрирует, что методы не взаимозаменяемы. StandardScaler предполагает нормальность и чистоту данных. MinMaxScaler требует известных границ и отсутствия выбросов. RobustScaler жертвует некоторой эффективностью на чистых данных ради устойчивости к аномалиям. Влияние масштабирования на работу моделей Метод градиентного бустинга и нейросети по-разному реагируют на масштабирование данных. Стандартизация данных влияет на скорость сходимости и стабильность обучения: StandardScaler ускоряет сходимость градиентных методов и нейросетей на данных без выбросов, выравнивая градиенты всех признаков и сокращая количество итераций до сходимости в 2–3 раза; MinMaxScaler полезен для нейросетей с функциями активации, которые работают в ограниченном диапазоне, такими как Sigmoid и tanh, обеспечивая корректную работу первого слоя и предотвращая затухание или накопление сигналов; RobustScaler делает обучение стабильнее на данных с выбросами, так как градиенты, вычисленные на экстремальных значениях, не доминируют, что позволяет использовать более высокий шаг обучения без риска расхождения; Алгоритмы на основе деревьев, такие как Random Forest и XGBoost, почти не зависят от масштабирования с точки зрения качества предсказаний, но масштабирование может ускорить обучение за счет улучшенной числовой стабильности при разбиениях; Модели K-Nearest Neighbors критически зависят от масштаба признаков, потому что признаки с большими значениями доминируют при вычислении расстояний, и применение StandardScaler или RobustScaler выравнивает вклад всех признаков и снижает влияние выбросов; Модели SVM с RBF kernel требуют масштабирования для корректной оценки сходства точек, и StandardScaler тут обычно работает оптимально, тогда как RobustScaler может быть полезен на зашумленных данных. Другие методы масштабирования Помимо трех основных методов, scikit-learn предоставляет специализированные методы масштабирования данных для задач, требующих нелинейных преобразований или сохранения специфических свойств данных. Эти методы применяются реже, но помогают решать специфические задачи. MaxAbsScaler MaxAbsScaler масштабирует каждый признак, деля его на максимальное абсолютное значение. В результате все значения попадают в диапазон [-1, 1], при этом нули остаются нулями. Преобразование выполняется по формуле: x_scaled = x / max(|x|) Метод особенно подходит для разреженных данных, где большинство значений равны нулю. Примеры таких данных: "Мешок слов" (Bag-of-Words) для текстов; TF-IDF векторы; One-hot кодирование категориальных признаков. MaxAbsScaler сохраняет разреженность: нули остаются нулями, а ненулевые значения масштабируются. В отличие от него, StandardScaler и MinMaxScaler превращают нули в ненулевые значения, что разрушает разреженную структуру и увеличивает использование памяти. MaxAbsScaler не центрирует данные, то есть не вычитает среднее или медиану. Это важно, когда значение 0 имеет особое значение. Например, в TF-IDF ноль означает отсутствие слова, и это информативный сигнал, который нельзя менять. Метод чувствителен к выбросам так же, как MinMaxScaler: одно экстремальное значение определяет максимум, сжимая остальные данные. Для данных с выбросами лучше использовать RobustScaler. PowerTransformer Метод PowerTransformer применяет степенные преобразования для приведения распределения признака к нормальному. Метод использует два варианта преобразований: Box-Cox (только для положительных данных); Yeo-Johnson (для любых данных, включая отрицательные и нулевые). Box-Cox преобразование параметризуется λ (lambda) и определяется как: x_transformed = (x^λ - 1) / λ, если λ ≠ 0 x_transformed = log(x), если λ = 0 Параметр λ подбирается автоматически методом максимального правдоподобия для максимизации нормальности распределения: при λ = 1 преобразование линейно (не меняет данные); при λ = 0.5 соответствует квадратному корню; при λ = 0 — логарифму. Yeo-Johnson расширяет метод Box-Cox на все числовые значения. Он использует разные формулы для положительных и отрицательных чисел. Это делает его удобным для финансовых данных, где признаки могут быть отрицательными, например доходности или P&L. PowerTransformer помогает исправлять асимметрию распределений. Финансовые данные часто имеют правостороннюю асимметрию: большинство значений слева, длинный хвост справа (например, объем торгов или рыночная капитализация). Линейные модели и многие алгоритмы машинного обучения лучше работают с нормальными признаками. PowerTransformer преобразует асимметричное распределение в более симметричное, повышая точность моделей. После преобразования PowerTransformer автоматически применяет StandardScaler, приводя данные к нулевому среднему и единичной дисперсии. В итоге признаки приближаются к стандартному нормальному распределению N(0, 1). QuantileTransformer Метод QuantileTransformer выполняет нелинейное преобразование, отображая распределение признака на равномерное или нормальное распределение. Метод основан на квантильной функции: значения заменяются их процентилями, затем процентили отображаются на целевое распределение. QuantileTransformer работает в два этапа: Сначала вычисляется квантильная функция исходного распределения — для каждого значения x определяется его процентиль p; Процентиль p заменяется на соответствующее значение в целевом распределении — равномерном или нормальном. Для равномерного распределения значения распределяются в диапазоне [0, 1]: минимальное становится 0, максимальное — 1, а промежуточные — пропорционально их рангу. Для нормального распределения процентили отображаются на квантили стандартного нормального распределения N(0, 1). Преимущества метода: Максимальная устойчивость к выбросам. Преобразование основано на рангах, а не на абсолютных значениях. Выбросы получают крайние процентили, не искажая распределение основных данных; Подходит для сильно зашумленных данных с множественными экстремальными значениями. Особенности: Нелинейность преобразования изменяет расстояния между точками; Близкие значения в областях высокой плотности могут стать более удаленными, если их процентили различаются; Линейные зависимости разрушаются, что может ухудшить работу линейных моделей, но улучшает работу нелинейных алгоритмов, таких как деревья и нейросети. Корректное применение масштабирования в ML-пайплайнах Правильное масштабирование в процессе обучения модели важно для предотвращения утечки данных (data leakage) и обеспечения корректных предсказаний на новых данных. Частые ошибки: Обучение скейлера на полном датасете, включая тестовую выборку; Использование разных параметров масштабирования для обучающей (train) и тестовой (test) выборок; Игнорирование обратного преобразования (inverse transform) при интерпретации предсказаний. Разделение на train/test и предотвращение data leakage Утечка данных (Data leakage) возникает, когда информация из тестовой выборки попадает в процесс обучения или предобработки. Для масштабирования это значит: параметры скейлера (μ и σ для StandardScaler, min и max для MinMaxScaler, медиана и IQR для RobustScaler) должны вычисляться только на обучающей выборке. Неправильный подход: # НЕПРАВИЛЬНО: scaler обучается на всех данных from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # Leakage: используются данные из будущего test split X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.2) Этот код вычисляет среднее и стандартное отклонение по всему датасету, включая данные, которые позже попадут в тестовую выборку. Результат: модель косвенно "видит" статистики тестовых данных, что завышает оценку качества и приводит к переобучению на конкретном разбиении. Правильный подход: # ПРАВИЛЬНО: сначала split, потом fit scaler только на train X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) # Вычисляем параметры только по train X_test_scaled = scaler.transform(X_test) # Применяем те же параметры к test Метод fit_transform() одновременно обучает скейлер и применяет преобразование. Его используют только на обучающих данных. Метод transform() применяет уже вычисленные параметры к новым данным без повторного обучения. Так гарантируется, что тестовые данные масштабируются с теми же μ и σ (для StandardScaler), min и max (для MinMaxScaler) или медианой и IQR (для RobustScaler), что и обучающие. Важно учитывать: значения в X_test_scaled могут выходить за ожидаемый диапазон: Если в тестовых данных есть значения вне диапазона [min_train, max_train], MinMaxScaler может дать значения вне [0, 1]; Если в тесте есть выбросы, отсутствовавшие в обучающем наборе, StandardScaler может дать |z| > 3. Это нормальное поведение, которое отражает отличие распределения test выборки от train выборки. Интеграция в Sklearn Pipeline Класс Pipeline от scikit-learn позволяет автоматизировать последовательность предобработки и обучения модели. Он гарантирует правильное использование fit и transform: fit вызывается только на обучающих данных; transform применяется автоматически к новым данным при предсказании. Все шаги выполняются в заданном порядке, что предотвращает ошибки и утечку данных. import numpy as np import pandas as pd from sklearn.pipeline import Pipeline from sklearn.preprocessing import RobustScaler from sklearn.linear_model import Ridge from sklearn.model_selection import cross_val_score, TimeSeriesSplit from sklearn.metrics import mean_squared_error, r2_score np.random.seed(42) # Синтетические данные: предсказание доходности акции n_samples = 1000 # Признаки: исторические доходности, объемы, волатильность lag_returns_1 = np.random.normal(0, 2, n_samples) lag_returns_5 = np.random.normal(0, 1.5, n_samples) volume_change = np.random.normal(0, 10, n_samples) volatility = np.abs(np.random.normal(20, 8, n_samples)) # Добавление выбросов в 3% наблюдений outlier_idx = np.random.choice(n_samples, int(n_samples * 0.03), replace=False) lag_returns_1[outlier_idx] *= np.random.uniform(3, 6, len(outlier_idx)) volume_change[outlier_idx] *= np.random.uniform(5, 10, len(outlier_idx)) # Целевая переменная: будущая доходность (с зависимостью от признаков + шум) target = 0.3 * lag_returns_1 + 0.15 * lag_returns_5 - 0.02 * volatility + np.random.normal(0, 1, n_samples) X = pd.DataFrame({ 'lag_returns_1d': lag_returns_1, 'lag_returns_5d': lag_returns_5, 'volume_change_pct': volume_change, 'volatility': volatility }) y = target # Разделение на train/test (последние 20% для теста, имитация временного ряда) split_idx = int(len(X) * 0.8) X_train, X_test = X[:split_idx], X[split_idx:] y_train, y_test = y[:split_idx], y[split_idx:] # Создание Pipeline с RobustScaler и Ridge регрессией pipeline = Pipeline([ ('scaler', RobustScaler()), ('regressor', Ridge(alpha=1.0)) ]) # Обучение pipeline pipeline.fit(X_train, y_train) # Предсказания y_pred_train = pipeline.predict(X_train) y_pred_test = pipeline.predict(X_test) # Оценка качества train_r2 = r2_score(y_train, y_pred_train) test_r2 = r2_score(y_test, y_pred_test) train_rmse = np.sqrt(mean_squared_error(y_train, y_pred_train)) test_rmse = np.sqrt(mean_squared_error(y_test, y_pred_test)) print(f"Train R²: {train_r2:.4f} | Test R²: {test_r2:.4f}") print(f"Train RMSE: {train_rmse:.4f} | Test RMSE: {test_rmse:.4f}") # Кросс-валидация с TimeSeriesSplit (для временных рядов) tscv = TimeSeriesSplit(n_splits=5) cv_scores = cross_val_score(pipeline, X_train, y_train, cv=tscv, scoring='neg_mean_squared_error') cv_rmse = np.sqrt(-cv_scores) print(f"\nКросс-валидация RMSE: {cv_rmse.mean():.4f} ± {cv_rmse.std():.4f}") # Доступ к компонентам pipeline scaler = pipeline.named_steps['scaler'] regressor = pipeline.named_steps['regressor'] print(f"\nПараметры RobustScaler:") print(f"Медианы: {scaler.center_}") print(f"IQR: {scaler.scale_}") print(f"\nКоэффициенты Ridge:") for feature, coef in zip(X.columns, regressor.coef_): print(f"{feature}: {coef:.4f}") Train R²: 0.3594 | Test R²: 0.3937 Train RMSE: 0.9926 | Test RMSE: 1.0300 Кросс-валидация RMSE: 0.9936 ± 0.0563 Параметры RobustScaler: Медианы: [ 0.02559429 0.10343076 0.05247758 20.08151803] IQR: [ 2.69661915 2.01790813 13.27108432 11.37738439] Коэффициенты Ridge: lag_returns_1d: 0.7555 lag_returns_5d: 0.3328 volume_change_pct: -0.0044 volatility: -0.2966 Метод Pipeline обеспечивает атомарность операций: fit() обучает все шаги последовательно на одних и тех же обучающих данных; predict() автоматически применяет transform() каждого шага перед передачей данных в модель; Это исключает ошибки, когда скейлер обучен на одних данных, а применяется к другим. TimeSeriesSplit используется для кросс-валидации временных рядов. В отличие от обычного K-Fold, который перемешивает данные случайным образом, TimeSeriesSplit сохраняет временной порядок. Каждый фолд использует более ранние данные для обучения и более поздние для валидации. Это предотвращает утечку данных из будущего. RobustScaler в Pipeline корректно обрабатывает выбросы, не искажая масштаб основных данных. Ridge-регрессия с L2-регуляризацией снижает переобучение на зашумленных данных. Комбинация RobustScaler + Ridge часто оптимальна для финансовых задач с регулярными аномалиями. Доступ к компонентам Pipeline через named_steps позволяет анализировать параметры каждого шага. Коэффициенты Ridge на масштабированных признаках становятся сопоставимыми, что упрощает интерпретацию важности признаков. Без масштабирования коэффициенты имели бы разные масштабы, что затрудняло бы анализ. Типичные ошибки при масштабировании и как их избежать При работе с масштабированием данных часто совершаются ошибки, которые напрямую влияют на качество моделей и корректность предсказаний. Ниже перечислены наиболее типичные проблемы и рекомендации, как их избежать: Утечка данных из будущего (data leakage): использование скейлера на полном датасете, включая тестовую выборку, завышает оценку качества. Решение: fit scaler только на train, transform — к test; Масштабирование целевой переменной без обратного преобразования: нейросети могут требовать масштабирования y, но предсказания будут в масштабированном виде. Решение: применять inverse_transform() для возвращения предсказаний в исходные единицы; Разные скейлеры для train и test: несопоставимые масштабы ухудшают качество модели. Решение: использовать один и тот же fitted scaler для всех данных; Игнорирование масштабирования в продакшене: новые данные подаются без transform(), предсказания некорректны. Решение: сохранять fitted scaler вместе с моделью и применять его ко всем новым наблюдениям; Масштабирование категориальных признаков после one-hot encoding: StandardScaler превращает бинарные значения в дробные, теряя интерпретацию. Решение: не масштабировать бинарные признаки; MinMaxScaler для данных с неизвестными границами: новые значения могут выходить за [0, 1], нарушая работу алгоритмов. Решение: использовать clip=True или выбирать RobustScaler; Забытое масштабирование при feature engineering: новые признаки создаются в исходном масштабе, старые — в масштабированном, создавая дисбаланс. Решение: делать feature engineering до масштабирования или повторно масштабировать все признаки; Предположение о стационарности параметров: распределения данных меняются (concept drift). Решение: периодически переобучать scaler на скользящем окне или использовать онлайн-методы для потоковых данных. Заключение Масштабирование признаков — это важный этап машинного обучения, влияющий на работу модели с вариативностью данных, выбросами и общими паттернами. Разные методы подходят для разных задач: StandardScaler ускоряет сходимость градиентных методов на чистых данных с нормальным распределением; MinMaxScaler сохраняет форму распределения и ограничивает диапазон, что важно для нейросетей; RobustScaler устойчив к выбросам, сохраняя информативные аномалии, что особенно полезно для финансовых данных. Правильное применение масштабирования в ML-пайплайнах предотвращает утечку данных и обеспечивает воспроизводимость. Sklearn Pipeline автоматизирует предобработку, гарантируя одинаковые преобразования для обучающих и тестовых данных с параметрами, вычисленными только на обучающем наборе. Понимание принципов работы каждого метода и его влияния на распределение и выбросы позволяет делать осознанный выбор, повышая точность моделей и стабильность предсказаний в продакшене. ### Градиентный бустинг: концепция и механизм работы Градиентный бустинг относится к семейству ансамблевых методов машинного обучения, где финальное предсказание формируется как взвешенная сумма предсказаний множества слабых моделей. Ключевое отличие от других ансамблевых подходов — последовательное обучение, при котором каждая новая модель корректирует ошибки предыдущих. Алгоритм строит композицию из простых моделей (чаще всего деревьев решений малой глубины), добавляя их итеративно и минимизируя функцию потерь на каждом шаге. Эффективность градиентного бустинга объясняется способностью фокусироваться на сложных для предсказания наблюдениях. Первая модель обучается на исходных данных, вторая — на ошибках первой, третья — на ошибках композиции из первых двух моделей. Процесс продолжается до достижения заданного количества итераций или выполнения критерия остановки. Результат — точная модель, где каждый компонент специализируется на определенных аспектах данных. Последовательное построение ансамбля Алгоритм градиентного бустинга начинается с инициализации базового предсказания. Для задач регрессии это обычно среднее значение целевой переменной, для классификации — логарифм отношения шансов. Первое дерево обучается предсказывать остатки между базовым предсказанием и истинными значениями. Предсказание первого дерева добавляется к базовому с определенным весом, формируя обновленное предсказание. На втором шаге вычисляются новые остатки — разность между обновленным предсказанием и истинными значениями. Второе дерево обучается на этих остатках, его предсказание снова добавляется к композиции. Процесс повторяется заданное количество раз. Каждое новое дерево получает все более сложную задачу — предсказать то, что не смогли предсказать предыдущие модели. Финальное предсказание представляет собой сумму базового предсказания и взвешенных предсказаний всех деревьев. Веса определяются параметром learning rate, который контролирует вклад каждого дерева в итоговую композицию. Последовательная природа процесса делает невозможным параллельное обучение деревьев — каждое зависит от результатов предыдущих. Градиентный спуск в пространстве моделей Название "градиентный бустинг" отражает связь алгоритма с методом градиентного спуска. Классический градиентный спуск минимизирует функцию потерь, обновляя параметры модели в направлении антиградиента. Градиентный бустинг применяет ту же идею, но вместо обновления параметров добавляет новые модели. На каждой итерации алгоритм вычисляет градиент функции потерь по текущим предсказаниям. Этот градиент показывает направление наибольшего увеличения ошибки для каждого наблюдения. Новое дерево обучается предсказывать антиградиент — направление, в котором нужно скорректировать текущие предсказания для уменьшения ошибки. Добавление предсказаний этого дерева к композиции эквивалентно шагу градиентного спуска. Гибкость подхода проявляется в возможности работать с произвольными дифференцируемыми функциями потерь. Для регрессии часто используется MSE (mean squared error), для классификации — log loss или экспоненциальная функция потерь. Выбор функции потерь определяет, какие аспекты данных алгоритм будет оптимизировать в первую очередь. Математический аппарат градиентного бустинга Формализация алгоритма строится на итеративном обновлении предсказаний. Обозначим F_m(x) как предсказание композиции после m итераций, h_m(x) — предсказание дерева на итерации m, L(y, F) — функцию потерь. Обновление на каждом шаге записывается как: F_m(x) = F_(m-1)(x) + ν · h_m(x) где: F_m(x) — предсказание композиции на итерации m; F_(m-1)(x) — предсказание на предыдущей итерации; h_m(x) — предсказание нового дерева; ν — learning rate (коэффициент скорости обучения). Параметр ν контролирует величину шага. При ν = 1 предсказание нового дерева добавляется полностью, при меньших значениях — частично. Новое дерево h_m(x) обучается аппроксимировать антиградиент функции потерь через следующий расчет: h_m(x) ≈ -∂L(y_i, F_(m-1)(x_i)) / ∂F_(m-1)(x_i) где: L(y_i, F_(m-1)(x_i)) — значение функции потерь для i-го наблюдения; ∂L/∂F — частная производная функции потерь по предсказанию; антиградиент указывает направление коррекции предсказаний. Для квадратичной функции потерь L(y, F) = (y - F)² градиент упрощается до 2(F - y), антиградиент — до (y - F), что соответствует остаткам. В этом случае обучение на антиградиенте эквивалентно обучению на остатках между истинными значениями и текущими предсказаниями. Рис. 1: Эволюция предсказаний градиентного бустинга с увеличением числа итераций. Каждая панель демонстрирует аппроксимацию синусоиды после 1, 3, 10 и 50 итераций. Первая итерация дает грубое приближение, последующие деревья корректируют ошибки и улучшают fit к данным. После 50 итераций модель точно воспроизводит нелинейную зависимость, сохраняя способность к обобщению Отличия бустинга от бэггинга Градиентный бустинг и бэггинг (bootstrap aggregating) представляют две фундаментально различные стратегии построения ансамблей. Обе техники комбинируют множество базовых моделей для улучшения предсказательной способности, но механизмы достижения этой цели противоположны: Бэггинг снижает дисперсию за счет усреднения независимых моделей; Градиентный бустинг уменьшает смещение через последовательную коррекцию ошибок. Различия проявляются на всех уровнях: от способа обучения моделей до типов задач, где каждый метод демонстрирует преимущества. Понимание этих различий критично для выбора подходящего алгоритма под конкретную задачу. Параллельное и последовательное обучение В бэггинге все модели обучаются параллельно и независимо. Каждая получает случайную подвыборку данных (bootstrap sample), а итоговое предсказание формируется усреднением (для регрессии) или голосованием (для классификации). Независимость моделей позволяет использовать сразу несколько ядер процессора для вычислений. Градиентный бустинг строит модели последовательно. Каждое новое дерево исправляет ошибки предыдущих. Параллелизация здесь невозможна: обучение следующей модели ждет окончания предыдущей. Итоговое предсказание — взвешенная сумма с коэффициентами, задаваемыми скоростью обучения (learning rate). Последовательность увеличивает время обучения, но часто позволяет достичь высокой точности с меньшим количеством деревьев. Независимость и зависимость моделей В бэггинге каждая модель обучается на разных данных. Выбираются N наблюдений из исходного набора размером N с возвращением. Около 63% данных попадают в выборку, остальные 37% остаются для проверки (out-of-bag). Ошибки моделей некоррелированы, поэтому усреднение снижает разброс. В градиентном бустинге все данные используются на каждой итерации. Отличие моделей создается последовательным исправлением ошибок: первое дерево ловит общие тренды; второе — отклонения; третье — отклонения от первых двух и т.д. Модели зависят друг от друга, что повышает риск переобучения. Вот почему тут важно контролировать глубину деревьев и число итераций. Бэггинг в этом плане более устойчив: добавление деревьев почти никогда не ухудшает результат. Смещение и разброс (bias-variance) Ошибка модели состоит из смещения (bias), разброса (variance) и шума. Смещение отражает систематическую ошибку модели, а разброс показывает, насколько сильно предсказания меняются при изменении данных. Алгоритмы Бэггинга уменьшают разброс. Глубокие деревья имеют низкое смещение, но высокую вариативность. Усреднение множества моделей снижает разброс, не увеличивая смещение. Алгоритм случайного леса (Random Forest) особенно эффективен, когда базовые модели сложные и разнообразные. Градиентный бустинг, напротив, снижает смещение. Здесь используют простые модели с высоким смещением и низкой вариативностью. Каждое новое дерево исправляет ошибки предыдущих, постепенно уменьшая смещение ансамбля. Разброс контролируется через скорость обучения и ограничение числа итераций. Такой подход позволяет из простых моделей строить сложные зависимости. Рис. 2: Сравнение подходов к снижению ошибки. Левая панель: одно глубокое дерево с сильным переобучением и высокой вариативностью предсказаний. Средняя панель: Random Forest (бэггинг) усредняет множество деревьев, уменьшая разброс и давая более стабильную аппроксимацию. Правая панель: Gradient Boosting (градиентный бустинг) последовательно снижает смещение, добавляя простые деревья Learning rate: управление скоростью обучения Коэффициент скорости обучения Learning rate контролирует вклад каждого нового дерева в итоговую композицию. Параметр определяет величину шага при обновлении предсказаний и напрямую влияет на баланс между скоростью обучения и качеством модели: Малые значения learning rate требуют большего количества итераций для достижения оптимума, но обеспечивают лучшую генерализацию; Высокие значения ускоряют обучение, но повышают риск переобучения. Выбор Learning rate — это компромисс между временем обучения и точностью модели. На практике модели с learning rate 0.001–0.1 и большим числом деревьев обычно точнее, чем модели с learning rate 0.1-0.5 и меньшим числом деревьев, даже при одинаковом времени обучения. Роль коэффициента в итоговом предсказании Learning rate ν определяет, какой вклад новое дерево вносит в ансамбль: При ν = 1 дерево полностью добавляется к текущему предсказанию, модель резко исправляет ошибки; При ν = 0.1 учитывается лишь 10% предсказания дерева, корректировка более осторожная. Итоговое предсказание после M итераций выражается как: F_M(x) = F_0(x) + ν · Σ h_m(x) где: F_0(x) — базовое предсказание (среднее значение целевой переменной); h_m(x) — предсказание дерева на итерации m; ν — learning rate; Σ обозначает суммирование по всем M итерациям. Снижение скорости обучения действует как регуляризация: каждое дерево вносит меньший вклад, ансамбль медленнее подстраивается под данные. Это снижает риск переобучения и улучшает обобщающую способность модели, но требует больше итераций для достижения той же ошибки на обучении. Соотношение между learning rate и числом деревьев нелинейное. Уменьшение learning rate в 10 раз (например, с 0.1 до 0.01) обычно требует увеличения числа деревьев в 5–7 раз, а не в 10, так как модель при малом learning rate использует каждое дерево более эффективно. Компромисс между скоростью и точностью Высокий learning rate (0.3–1.0) быстро снижает ошибку на обучении: первые 10–20 деревьев дают заметный эффект, дальше улучшение минимальное. Обучение проходит быстрее, но модель склонна к переобучению — на валидации качество часто достигает пика и затем падает. Низкий learning rate (0.001–0.01) требует сотен или тысяч деревьев. Каждое дерево вносит небольшой вклад, ошибка снижается постепенно. Время обучения увеличивается, но оптимизация более плавная, модель реже переобучается, а качество на валидации продолжает расти дольше. Промежуточные значения (0.03–0.3) обеспечивают баланс: 100–300 деревьев достаточно для хорошей точности и приемлемого времени обучения. Этот диапазон обычно используется как стартовая точка для подбора гиперпараметров, а дальнейшая оптимизация зависит от данных и требований к производительности. Подходы к подбору скорости обучения (learning rate) Оптимальная скорость обучения зависит от сложности задачи, размера выборки и архитектуры модели. Для датасетов с сильным сигналом и простыми зависимостями допустимы более высокие значения, например 0,1–0,3. Для зашумленных данных или сложных нелинейных паттернов лучше использовать меньшие значения — 0,005–0,05. Распространенная стратегия подбора: Начинать с learning rate = 0,1 и количества деревьев 100–200. Метрики на валидации отслеживаются с помощью ранней остановки (early stopping); Если модель переобучается слишком рано (после 50–100 деревьев), learning rate уменьшают до 0,05 или 0,01 и пропорционально увеличивают максимальное количество деревьев; Если после 200 деревьев качество продолжает улучшаться, learning rate можно повысить до 0,2 для ускорения обучения. Learning rate влияет на работу модели в сочетании с другими гиперпараметрами: Глубокие деревья (max_depth 8–10) при высоком learning rate быстро приводят к переобучению; Мелкие деревья (max_depth 3–5) позволяют использовать более высокие значения learning rate без потери способности модели к генерализации. Аналогично, малые значения min_samples_leaf усиливают переобучение при высоком learning rate. import numpy as np import matplotlib.pyplot as plt from sklearn.ensemble import GradientBoostingRegressor from sklearn.model_selection import train_test_split from sklearn.metrics import mean_squared_error # Генерация данных np.random.seed(42) X = np.sort(np.random.uniform(0, 10, 400)).reshape(-1, 1) y = np.sin(X).ravel() + 0.3 * np.sin(3 * X).ravel() + np.random.normal(0, 0.2, X.shape[0]) X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.3, random_state=42) # Различные learning rates learning_rates = [0.01, 0.05, 0.1, 0.5, 1.0] max_iterations = 500 fig, axes = plt.subplots(2, 3, figsize=(16, 10)) axes = axes.ravel() summary = [] for idx, lr in enumerate(learning_rates): train_errors = [] val_errors = [] gb = GradientBoostingRegressor( learning_rate=lr, max_depth=3, warm_start=True, # наращивание деревьев random_state=42 ) for n_trees in range(1, max_iterations + 1): gb.n_estimators = n_trees gb.fit(X_train, y_train) train_pred = gb.predict(X_train) val_pred = gb.predict(X_val) train_errors.append(mean_squared_error(y_train, train_pred)) val_errors.append(mean_squared_error(y_val, val_pred)) # Визуализация кривых обучения axes[idx].plot(range(1, max_iterations + 1), train_errors, color='#3498DB', linewidth=2, label='Train MSE', alpha=0.8) axes[idx].plot(range(1, max_iterations + 1), val_errors, color='#E74C3C', linewidth=2, label='Validation MSE', alpha=0.8) min_val_idx = np.argmin(val_errors) axes[idx].axvline(x=min_val_idx + 1, color='gray', linestyle='--', linewidth=1.5, alpha=0.7) axes[idx].scatter([min_val_idx + 1], [val_errors[min_val_idx]], color='#2C3E50', s=80, zorder=5) axes[idx].set_title(f'Learning Rate = {lr}\n(Optimal trees: {min_val_idx + 1})', fontsize=11, fontweight='bold') axes[idx].set_xlabel('Количество деревьев') axes[idx].set_ylabel('MSE') axes[idx].legend(loc='upper right') axes[idx].grid(True, alpha=0.3) axes[idx].set_xlim(0, max_iterations) # Сохраняем результат для сводной таблицы summary.append((lr, min_val_idx + 1, val_errors[min_val_idx], train_errors[min_val_idx])) # Удаление лишней панели fig.delaxes(axes[5]) plt.tight_layout() plt.show() # Сводная таблица оптимальных параметров print("\n" + "="*60) print("Анализ влияния learning rate") print("="*60) for lr, opt_trees, val_mse, train_mse in summary: print(f"LR={lr:4.2f} | Оптимум: {opt_trees:3d} деревьев | " f"Val MSE: {val_mse:.4f} | Train MSE: {train_mse:.4f}") print("="*60) Рис. 3: Влияние скорости обучения learning rate на динамику обучения градиентного бустинга. Каждая панель показывает эволюцию ошибки на обучающей и валидационной выборках для разных значений коэффициента. Вертикальная пунктирная линия отмечает оптимальное количество деревьев для каждого learning rate. Чем ниже скорость обучения, тем больше деревьев требуется для обучения модели. При этом растет время обучения, но ниже ошибка MSE Анализ влияния learning rate ============================================================ LR=0.01 | Оптимум: 338 деревьев | Val MSE: 0.0429 | Train MSE: 0.0352 LR=0.05 | Оптимум: 63 деревьев | Val MSE: 0.0427 | Train MSE: 0.0361 LR=0.10 | Оптимум: 32 деревьев | Val MSE: 0.0420 | Train MSE: 0.0354 LR=0.50 | Оптимум: 8 деревьев | Val MSE: 0.0496 | Train MSE: 0.0332 LR=1.00 | Оптимум: 6 деревьев | Val MSE: 0.0495 | Train MSE: 0.0298 Код демонстрирует базовую процедуру подбора learning rate через сравнение кривых обучения. На практике скорость обучения редко указывают вручную, используют grid search или случайный поиск по пространству гиперпараметров, включая max_depth, min_samples_split и subsample. Библиотеки вроде Optuna или Hyperopt автоматизируют процесс и находят комбинации параметров, недостижимые ручной настройкой. Реализация градиентного бустинга на Python Экосистема Python предлагает несколько реализаций градиентного бустинга с различными оптимизациями и возможностями: Scikit-learn предоставляет базовую имплементацию, достаточную для понимания алгоритма и решения стандартных задач; Специализированные библиотеки XGBoost, LightGBM и CatBoost добавляют продвинутые техники регуляризации, эффективную работу с категориальными признаками и распределенное обучение. Выбор библиотеки зависит от требований к скорости, размера данных и специфики задачи. Реализация градиентного бустинга с нуля полезна для глубокого понимания механизма работы алгоритма. Написание цикла обучения, вычисление градиентов и построение композиции проясняет детали, скрытые в высокоуровневых API. Далее рассматриваем базовую имплементацию и работу с готовыми инструментами. Базовая имплементация с sklearn Библиотека Scikit-learn предоставляет классы GradientBoostingRegressor и GradientBoostingClassifier для задач регрессии и классификации соответственно. Основные параметры включают: n_estimators - количество деревьев; learning_rate - скорость обучения; max_depth - глубина деревьев; min_samples_split и min_samples_leaf - контроль размера узлов. Параметр subsample определяет долю наблюдений для обучения каждого дерева, значения меньше 1.0 добавляют стохастичность и регуляризацию. Метод staged_predict возвращает предсказания после каждой итерации обучения. Функционал полезен для анализа динамики улучшения модели и определения оптимального количества деревьев без переобучения нескольких моделей. Атрибут feature_importances_ содержит важности признаков, вычисленные как средний прирост качества разбиений во всех деревьях. Параметр loss определяет функцию потерь для оптимизации: Для регрессии доступны 'squared_error' (MSE), 'absolute_error' (MAE), 'huber' и 'quantile'; Для классификации - 'log_loss' (бинарная и мультиклассовая) и 'exponential' (эквивалентно AdaBoost). Выбор функции потерь влияет на чувствительность к выбросам и интерпретацию предсказаний. import numpy as np import pandas as pd import yfinance as yf from sklearn.ensemble import GradientBoostingRegressor from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score import matplotlib.pyplot as plt import warnings warnings.filterwarnings('ignore') # Загрузка данных ticker = yf.Ticker("2330.TW") # Taiwan Semiconductor Manufacturing (TSMC) data = ticker.history(period="5y", interval="1d") # Если MultiIndex, убираем уровень if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) # Создание признаков data['Returns'] = data['Close'].pct_change() data['SMA_20'] = data['Close'].rolling(window=20).mean() data['SMA_50'] = data['Close'].rolling(window=50).mean() data['Volatility_10'] = data['Returns'].rolling(window=10).std() data['Volatility_30'] = data['Returns'].rolling(window=30).std() # Безопасное вычисление изменений объема (частая аномалия) volume_change = data['Volume'].pct_change() volume_change.replace([np.inf, -np.inf], np.nan, inplace=True) # заменить inf на NaN data['Volume_Change'] = volume_change.interpolate(method='linear') # линейная интерполяция # Лаговые признаки for lag in [1, 2, 3, 5, 10]: data[f'Return_Lag_{lag}'] = data['Returns'].shift(lag) data[f'Vol_Lag_{lag}'] = data['Volatility_10'].shift(lag) # Целевая переменная data['Target'] = data['Close'].shift(-5) / data['Close'] - 1 # Удаление пропусков data = data.dropna() # Подготовка данных feature_cols = [col for col in data.columns if col not in ['Close', 'Open', 'High', 'Low', 'Volume', 'Dividends', 'Stock Splits', 'Target']] X = data[feature_cols] y = data['Target'] # Разделение на train/test split_idx = int(len(X) * 0.8) X_train, X_test = X[:split_idx], X[split_idx:] y_train, y_test = y[:split_idx], y[split_idx:] # Обучение Gradient Boosting gb_model = GradientBoostingRegressor( n_estimators=200, learning_rate=0.05, max_depth=4, min_samples_split=20, min_samples_leaf=10, subsample=0.8, random_state=42, verbose=0 ) gb_model.fit(X_train, y_train) # Предсказания и метрики y_pred_train = gb_model.predict(X_train) y_pred_test = gb_model.predict(X_test) print("="*60) print("Результаты Gradient Boosting на TSMC") print("="*60) print(f"Train R²: {r2_score(y_train, y_pred_train):.4f}") print(f"Test R²: {r2_score(y_test, y_pred_test):.4f}") print(f"Train RMSE: {np.sqrt(mean_squared_error(y_train, y_pred_train)):.6f}") print(f"Test RMSE: {np.sqrt(mean_squared_error(y_test, y_pred_test)):.6f}") print(f"Test MAE: {mean_absolute_error(y_test, y_pred_test):.6f}") print("="*60) # Визуализация важности признаков importances = gb_model.feature_importances_ indices = np.argsort(importances)[::-1][:10] plt.figure(figsize=(12, 6)) plt.barh(range(10), importances[indices], color='#2C3E50', alpha=0.8) plt.yticks(range(10), [feature_cols[i] for i in indices]) plt.xlabel('Важность признака') plt.title('Top-10 признаков по важности (Gradient Boosting)', fontweight='bold') plt.gca().invert_yaxis() plt.grid(axis='x', alpha=0.3) plt.tight_layout() plt.show() Результаты Gradient Boosting на TSMC ============================================================ Train R²: 0.7807 Test R²: -0.2721 Train RMSE: 0.018136 Test RMSE: 0.043908 Test MAE: 0.033989 Рис. 4: Важность признаков в модели градиентного бустинга для прогнозирования доходности акций TSMC. Скользящие средние и лаговые значения волатильности доминируют, что подтверждает автокорреляционную природу финансовых временных рядов Представленный выше код демонстрирует типичный пайплайн для задач прогнозирования на финансовых данных: Сначала выполняется инжиниринг признаков (Feature engineering). Этап включает создание лаговых переменных, различных индикаторов и метрик волатильности; Разделение данных (train/test split) выполняется по времени без перемешивания, что критично для корректной оценки на временных рядах; Модель обучается на исторических данных и тестируется на более поздних периодах. После чего оцениваются метрики ошибок на тестовой выборке. Визуализация влияния learning rate Динамика обучения при различных значениях learning rate позволяет наглядно увидеть взаимосвязь между скоростью конвергенции модели и ее качеством. Построение кривых ошибки на обучающей и валидационной выборках для нескольких значений learning rate помогает определить момент, когда дальнейшее увеличение числа деревьев перестает улучшать способность модели к генерализации. Метод staged_predict из sklearn существенно упрощает процесс: он позволяет получать предсказания после каждой итерации без необходимости заново обучать модель с увеличенным числом деревьев. Это особенно полезно при визуализации кривых обучения и поиске оптимального числа деревьев. import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.ensemble import GradientBoostingRegressor from sklearn.metrics import mean_squared_error # Используем ранее подготовленные данные X_train, X_test, y_train, y_test learning_rates = [0.001, 0.01, 0.05, 0.1] n_estimators = 150 fig, axes = plt.subplots(2, 2, figsize=(14, 10)) axes = axes.ravel() for idx, lr in enumerate(learning_rates): # Обучение модели gb = GradientBoostingRegressor( n_estimators=n_estimators, learning_rate=lr, max_depth=4, min_samples_split=20, subsample=0.8, random_state=42, verbose=0 ) gb.fit(X_train, y_train) # Получение предсказаний после каждой итерации train_errors = [] test_errors = [] for pred_train, pred_test in zip(gb.staged_predict(X_train), gb.staged_predict(X_test)): train_errors.append(mean_squared_error(y_train, pred_train)) test_errors.append(mean_squared_error(y_test, pred_test)) # Визуализация axes[idx].plot(range(1, n_estimators + 1), train_errors, color='#3498DB', linewidth=2, label='Train MSE', alpha=0.8) axes[idx].plot(range(1, n_estimators + 1), test_errors, color='#E74C3C', linewidth=2, label='Test MSE', alpha=0.8) # Отметка минимума min_test_idx = np.argmin(test_errors) axes[idx].axvline(x=min_test_idx + 1, color='gray', linestyle='--', linewidth=1.5, alpha=0.7) axes[idx].scatter([min_test_idx + 1], [test_errors[min_test_idx]], color='#2C3E50', s=80, zorder=5) axes[idx].set_title(f'Learning Rate = {lr}\n(Минимум test MSE: {min_test_idx + 1} деревьев)', fontsize=11, fontweight='bold') axes[idx].set_xlabel('Количество деревьев') axes[idx].set_ylabel('MSE') axes[idx].legend() axes[idx].grid(True, alpha=0.3) plt.tight_layout() plt.show() Рис. 5: Кривые обучения градиентного бустинга на данных TSMC для 4-х значений learning rate. При LR=0.001 порог переобучения модели происходит после 149 итераций, после порога тестовая ошибка становится слишком высокой. При LR=0.01 оптимум достигается на 36 деревьях. При LR=0.05 порог срабатывает уже всего на 11 деревьях. При LR=0.1 модель не может эффективно обучаться, так как метрики на тестовых данных отрываются практически сразу от метрик на обучающей выборке Анализ кривых обучения помогает выявить признаки переобучения. Если ошибка на валидационной выборке начинает расти при продолжающемся снижении ошибки на тренировочных данных, модель начинает запоминать шум вместо закономерностей. Оптимальное число деревьев определяется минимумом валидационной ошибки, обеспечивая баланс между недообучением и переобучением. Использование метода ранней остановки Early stopping автоматизирует этот процесс: обучение прекращается, когда валидационная ошибка не улучшается в течение заданного числа итераций, что ускоряет обучение и предотвращает переобучение. Сравнение с Random Forest Модели Градиентного бустинга и Случайного леса (Random Forest) решают схожие задачи, но показывают разную эффективность в зависимости от данных. Random Forest хорошо работает на данных с высокой дисперсией, множеством нерелевантных признаков и выбросами, а градиентный бустинг — на структурированных данных с сильным сигналом, где последовательная оптимизация дает преимущество. Сравнение моделей показывает: Random Forest быстрее обучается и не требует тонкой настройки гиперпараметров. Градиентный бустинг может давать более высокую точность при оптимальном learning_rate и числе деревьев, но чувствителен к шуму и выбросам. Таким образом, градиентный бустинг эффективнее на структурированных табличных данных, а Random Forest предпочтителен при ограниченном времени обучения или высоком шуме. Для максимальной точности в соревнованиях и продакшен среде часто используют ансамбли обеих моделей. Применение в задачах классификации и регрессии Градиентный бустинг одинаково хорошо работает как для регрессии, так и для классификации, адаптируясь через выбор функции потерь. В задачах регрессии алгоритм минимизирует среднеквадратичную ошибку (MSE), среднюю абсолютную ошибку (MAE) или другие метрики разницы между предсказанными и истинными значениями. В классификации оптимизируются функции log loss для вероятностных предсказаний или exponential loss для жестких меток классов. Такая универсальность делает градиентный бустинг базовым инструментом для широкого спектра задач. В финансовой сфере алгоритм применяют для прогнозирования волатильности, кредитного скоринга, выявления мошенничества и оптимизации портфеля. В каждой из этих областей градиентный бустинг конкурирует с альтернативными методами или превосходит их, благодаря способности улавливать сложные нелинейные зависимости с построением небольшого числа признаков. Градиентный бустинг для прогнозирования волатильности Волатильность является ключевым параметром для риск-менеджмента и оценки опционов. Классические модели семейства GARCH предполагают определенную параметрическую структуру и требуют стационарности данных. В отличие от них, градиентный бустинг способен работать с произвольными нелинейными зависимостями и автоматически выявлять важные лаговые переменные без априорных предположений о процессе генерации данных. Прогнозирование недельной волатильности биржевых активов формулируется как задача регрессии. В качестве признаков можно использовать множество метрик: Исторические значения волатильности с различными окнами, доходности; Диапазоны High-Low и технические индикаторы, включая экспоненциальные скользящие средние (EWMA), ATR и фильтр Баттерворта. Целевая переменная — стандартное отклонение доходностей за следующие две недели. Модель обучается на исторических данных с учетом лагов до 3–4 недель, что позволяет учитывать автокорреляцию и динамику финансового временного ряда. import numpy as np import pandas as pd import yfinance as yf import matplotlib.pyplot as plt from sklearn.ensemble import GradientBoostingRegressor from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score from scipy.signal import butter, filtfilt import warnings warnings.filterwarnings('ignore') # Загрузка данных (EUR/USD, недельный таймфрейм) ticker = yf.Ticker("EURUSD=X") data = ticker.history(period="5y", interval="1wk") # Проверка на MultiIndex if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) # Предобработка data['Returns'] = data['Close'].pct_change() data['Abs_Returns'] = data['Returns'].abs() data['HL_Range'] = (data['High'] - data['Low']) / data['Close'] # EWMA for span in [4, 8]: data[f'EWMA_{span}'] = data['Returns'].ewm(span=span).mean() # Реализованная волатильность (RV) for window in [2, 4, 8]: data[f'RV_{window}'] = data['Returns'].rolling(window=window).std() # ATR for window in [4, 8]: data[f'ATR_{window}'] = (data['High'] - data['Low']).rolling(window=window).mean() # Butterworth filter для High-Low b, a = butter(N=2, Wn=0.1) data['Close_Butter'] = filtfilt(b, a, data['Close']) data['Butter_Range'] = (data['Close'] - data['Close_Butter']).abs() # Лаговые признаки (1, 2, 3, 4 недели) lags = [1, 2, 3, 4] for lag in lags: for col in ['RV_4', 'Abs_Returns', 'HL_Range', 'Butter_Range']: data[f'{col}_Lag_{lag}'] = data[col].shift(lag) # Целевая переменная: волатильность следующих 2 недель data['Target_Volatility'] = data['Returns'].rolling(window=2).std().shift(-1) # Удаление пропусков data = data.dropna() # Подготовка данных для модели feature_cols = [col for col in data.columns if col not in ['Open', 'High', 'Low', 'Close', 'Volume', 'Dividends', 'Stock Splits', 'Target_Volatility']] X = data[feature_cols] y = data['Target_Volatility'] # Разделение на train/test split_idx = int(len(X) * 0.8) X_train, X_test = X[:split_idx], X[split_idx:] y_train, y_test = y[:split_idx], y[split_idx:] dates_test = data.index[split_idx:] # Обучение модели Gradient Boosting gb_vol = GradientBoostingRegressor( n_estimators=250, learning_rate=0.05, max_depth=4, min_samples_split=20, min_samples_leaf=10, subsample=0.8, random_state=42 ) gb_vol.fit(X_train, y_train) # Предсказания y_pred_train = gb_vol.predict(X_train) y_pred_test = gb_vol.predict(X_test) # Метрики print("="*60) print("Прогнозирование недельной волатильности EUR/USD") print("="*60) print(f"Train R²: {r2_score(y_train, y_pred_train):.4f}") print(f"Test R²: {r2_score(y_test, y_pred_test):.4f}") print(f"Train RMSE: {np.sqrt(mean_squared_error(y_train, y_pred_train)):.6f}") print(f"Test RMSE: {np.sqrt(mean_squared_error(y_test, y_pred_test)):.6f}") print(f"Test MAE: {mean_absolute_error(y_test, y_pred_test):.6f}") print("="*60) # Визуализация fig, axes = plt.subplots(2, 1, figsize=(14, 10)) # Временной ряд axes[0].plot(dates_test, y_test.values, color='#2C3E50', linewidth=2, label='Истинная волатильность', alpha=0.8) axes[0].plot(dates_test, y_pred_test, color='#E74C3C', linewidth=1.5, label='Предсказанная волатильность', alpha=0.8) axes[0].set_xlabel('Дата') axes[0].set_ylabel('Волатильность') axes[0].set_title('Прогноз недельной реализованной волатильности EUR/USD', fontweight='bold') axes[0].legend() axes[0].grid(True, alpha=0.3) # Scatter plot: Pred vs True axes[1].scatter(y_test, y_pred_test, alpha=0.6, s=40, color='#3498DB') axes[1].plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], '--', color='#2C3E50', linewidth=2) axes[1].set_xlabel('Истинная волатильность') axes[1].set_ylabel('Предсказанная волатильность') axes[1].set_title(f'Точность предсказаний (R² = {r2_score(y_test, y_pred_test):.4f})', fontweight='bold') axes[1].grid(True, alpha=0.3) plt.tight_layout() plt.show() Прогнозирование недельной волатильности EUR/USD ============================================================ Train R²: 0.9830 Test R²: 0.4108 Train RMSE: 0.000804 Test RMSE: 0.005680 Test MAE: 0.003832 Интерпретация полученных метрик: Метрики на тренировочном наборе демонстрируют высокую объясняющую способность модели (Train R² = 0.9830), что говорит о том, что градиентный бустинг хорошо подстраивается под исторические данные; Однако тестовое R² = 0.4108 значительно ниже, что отражает сложность прогнозирования реальной недельной волатильности валютных пар: финансовые временные ряды высокошумные, имеют нестационарные паттерны и неожиданные макроэкономические влияния, которые модель не может полностью уловить; Несмотря на это, относительно низкие значения RMSE и MAE на тесте (0.005680 и 0.003832) показывают, что модель все же способна давать полезные ориентиры для волатильности, даже если точность ограничена природой данных. Рис. 6: Прогнозирование недельной реализованной волатильности EUR/USD с помощью градиентного бустинга. Верхняя панель показывает временной ряд истинной волатильности (черная линия) и предсказания модели (красная) на тестовом периоде. Нижняя панель — график рассеяния, демонстрирующий соответствие предсказанных значений волатильности истинным; диагональная линия показывает идеальное совпадение Прогнозирование волатильности с помощью градиентного бустинга показывает лучшие результаты по сравнению с простыми моделями, такими как скользящие средние или экспоненциальное скользящее среднее (EWMA, exponentially weighted moving average). Модель сама выявляет сложные, нелинейные зависимости между признаками и быстро адаптируется к изменениям на рынке. Использование таких прогнозов в системах управления рисками позволяет динамически настраивать кредитное плечо и уровни стоп-лосс в зависимости от ожидаемой волатильности. Интерпретация важности признаков Понимание того, какие признаки влияют на предсказания модели, важно для проверки результатов и улучшения подготовки признаков (feature engineering). Градиентный бустинг (Gradient Boosting) позволяет оценивать важность признаков разными способами: Важность на основе прироста качества (Gain-based importance) показывает среднее улучшение модели при разбиении по признаку в деревьях; Важность по количеству разбиений (Split-based importance) учитывает, как часто признак использовался для разделений; Важность по перестановке (Permutation importance) измеряет снижение качества модели при случайной перестановке значений признака. Анализ важности помогает находить избыточные признаки, которые можно убрать без потери точности. Если два признака сильно коррелируют и один важнее другого, менее значимый можно удалить. Это упрощает модель, ускоряет обучение и предсказания, а также снижает риск переобучения. Кроме того, важность признаков помогает строить гипотезы о причинах зависимостей в данных. import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.inspection import permutation_importance # Используем обученную модель gb_vol и данные X_test, y_test # Gain-based importance (встроенная в sklearn) gain_importance = gb_vol.feature_importances_ gain_importance_df = pd.DataFrame({ 'Feature': feature_cols, 'Importance': gain_importance }).sort_values('Importance', ascending=False) # Permutation importance perm_importance = permutation_importance( gb_vol, X_test, y_test, n_repeats=10, random_state=42, n_jobs=-1 ) perm_importance_df = pd.DataFrame({ 'Feature': feature_cols, 'Importance': perm_importance.importances_mean, 'Std': perm_importance.importances_std }).sort_values('Importance', ascending=False) # Визуализация fig, axes = plt.subplots(1, 2, figsize=(16, 6)) # Gain-based importance top_10_gain = gain_importance_df.head(10) axes[0].barh(range(10), top_10_gain['Importance'], color='#2C3E50', alpha=0.8) axes[0].set_yticks(range(10)) axes[0].set_yticklabels(top_10_gain['Feature']) axes[0].set_xlabel('Gain-based Importance') axes[0].set_title('Top-10 признаков (Gain)', fontweight='bold') axes[0].invert_yaxis() axes[0].grid(axis='x', alpha=0.3) # Permutation importance top_10_perm = perm_importance_df.head(10) axes[1].barh(range(10), top_10_perm['Importance'], xerr=top_10_perm['Std'], color='#3498DB', alpha=0.8) axes[1].set_yticks(range(10)) axes[1].set_yticklabels(top_10_perm['Feature']) axes[1].set_xlabel('Permutation Importance') axes[1].set_title('Top-10 признаков (Permutation)', fontweight='bold') axes[1].invert_yaxis() axes[1].grid(axis='x', alpha=0.3) plt.tight_layout() plt.savefig('feature_importance_comparison.png', dpi=300, bbox_inches='tight') plt.show() # Вывод топ-10 признаков print("\n" + "="*70) print("Top-10 признаков по Gain-based Importance") print("="*70) print(gain_importance_df.head(10).to_string(index=False)) print("\n" + "="*70) print("Top-10 признаков по Permutation Importance") print("="*70) print(perm_importance_df.head(10).to_string(index=False)) print("="*70) Рис. 7: Сравнение 2-х метрик важности признаков для модели прогнозирования волатильности. Левая панель показывает gain-based importance — насколько каждый признак в среднем улучшает разбиения в деревьях. Правая панель демонстрирует permutation importance — падение качества модели при перестановке значений признака. Признак Butter_Range (сглаженный диапазон фильтром Баттеруорта) доминирует в обеих метриках. Абсолютные доходности и High-Low диапазон также вносят существенный вклад ====================================================================== Top-10 признаков по Gain-based Importance ====================================================================== Feature Importance Butter_Range 0.227625 Abs_Returns 0.194788 HL_Range 0.095616 Close_Butter 0.048617 HL_Range_Lag_1 0.036969 Butter_Range_Lag_1 0.036223 Abs_Returns_Lag_1 0.024764 HL_Range_Lag_3 0.024736 Abs_Returns_Lag_2 0.022781 EWMA_4 0.021870 ====================================================================== Top-10 признаков по Permutation Importance ====================================================================== Feature Importance Std Butter_Range 0.211098 0.055118 Abs_Returns 0.167270 0.060618 HL_Range 0.087954 0.039019 HL_Range_Lag_3 0.038707 0.016686 RV_4_Lag_1 0.026645 0.017122 Butter_Range_Lag_1 0.023744 0.016664 Abs_Returns_Lag_2 0.013815 0.006870 Close_Butter 0.013680 0.012836 RV_2 0.009985 0.006796 EWMA_8 0.005997 0.006811 Различия между метриками важности могут указывать на артефакты. Если признак имеет высокую важность по приросту качества (gain-based importance), но низкую важность по перестановке (permutation importance), возможно, он сильно коррелирует с другими признаками и дублирует их информацию. Важность permutation importance более надежна для оценки истинного вклада признака, но требует больше вычислений. Использование обеих метрик вместе дает полное понимание роли признаков в модели. Мониторинг переобучения Переобучение в градиентном бустинге проявляется, когда модель улучшает метрики на обучающей выборке, но качество на валидации остается прежним или ухудшается. Мониторинг ошибок на обеих выборках во время обучения помогает остановить процесс в оптимальной точке. Библиотеки sklearn, XGBoost и LightGBM поддерживают раннюю остановку (early stopping) — автоматическую остановку обучения модели при отсутствии улучшения. В sklearn параметр n_iter_no_change задает число итераций без улучшения валидационной метрики перед остановкой, а validation_fraction — долю обучающих данных для валидации. import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.ensemble import GradientBoostingRegressor from sklearn.metrics import mean_squared_error # Используем данные X_train, y_train # Модель с early stopping gb_early = GradientBoostingRegressor( n_estimators=300, learning_rate=0.03, max_depth=4, min_samples_split=20, subsample=0.8, validation_fraction=0.2, # 20% train данных для валидации n_iter_no_change=50, # Остановка если нет улучшения за 50 итераций random_state=42, verbose=0 ) gb_early.fit(X_train, y_train) print(f"\nОбучение остановлено на {gb_early.n_estimators_} итерации") print(f"(из максимальных {gb_early.n_estimators})") # Модель без early stopping для сравнения gb_full = GradientBoostingRegressor( n_estimators=300, learning_rate=0.03, max_depth=4, min_samples_split=20, subsample=0.8, random_state=42, verbose=0 ) gb_full.fit(X_train, y_train) # Получение ошибок на каждой итерации train_errors_full = [] test_errors_full = [] for pred_train, pred_test in zip(gb_full.staged_predict(X_train), gb_full.staged_predict(X_test)): train_errors_full.append(mean_squared_error(y_train, pred_train)) test_errors_full.append(mean_squared_error(y_test, pred_test)) # Визуализация fig, ax = plt.subplots(figsize=(12, 6)) ax.plot(range(1, len(train_errors_full) + 1), train_errors_full, color='#3498DB', linewidth=2, label='Train MSE', alpha=0.8) ax.plot(range(1, len(test_errors_full) + 1), test_errors_full, color='#E74C3C', linewidth=2, label='Test MSE', alpha=0.8) # Отметка точки early stopping optimal_trees = gb_early.n_estimators_ ax.axvline(x=optimal_trees, color='#2C3E50', linestyle='--', linewidth=2.5, label=f'Early Stop ({optimal_trees} trees)') # Отметка минимума test MSE min_test_idx = np.argmin(test_errors_full) ax.scatter([min_test_idx + 1], [test_errors_full[min_test_idx]], color='#2C3E50', s=100, zorder=5, label=f'Min Test MSE ({min_test_idx + 1} trees)') ax.set_xlabel('Количество деревьев') ax.set_ylabel('MSE') ax.set_title('Early Stopping vs Полное обучение', fontweight='bold', fontsize=13) ax.legend(loc='upper right') ax.grid(True, alpha=0.3) plt.tight_layout() plt.show() # Сравнение качества y_pred_early = gb_early.predict(X_test) y_pred_full = gb_full.predict(X_test) from sklearn.metrics import r2_score print("\n" + "="*60) print("Сравнение моделей с Early Stopping и без") print("="*60) print(f"{'Метрика':<30} {'Early Stop':>12} {'Full (300)':>12}") print("-"*60) print(f"{'Количество деревьев':<30} {gb_early.n_estimators_:>12} {300:>12}") print(f"{'Test R²':<30} {r2_score(y_test, y_pred_early):>12.4f} {r2_score(y_test, y_pred_full):>12.4f}") print(f"{'Test RMSE':<30} {np.sqrt(mean_squared_error(y_test, y_pred_early)):>12.6f} {np.sqrt(mean_squared_error(y_test, y_pred_full)):>12.6f}") print("="*60) Обучение остановлено на 51 итерации (из максимальных 300) Сравнение моделей с Early Stopping и без ============================================================ Метрика Early Stop Full (300) ------------------------------------------------------------ Количество деревьев 51 300 Test R² 0.4093 0.4218 Test RMSE 0.005687 0.005627 ============================================================ На примере выше мы видим что ранняя остановка (Early Stopping) позволила обучить модель на 51 дереве вместо 300 без значительной потери качества: тестовая R² изменилась несущественно (0.4093 vs 0.4218), а RMSE практически осталась той же. Это подтверждает, что модель достаточно обучена, и дальнейшие итерации только увеличивали риск переобучения, плюс время и вычислительные затраты на обучение. Рис. 8: Демонстрация работы early stopping в градиентном бустинге. График показывает эволюцию ошибки на обучающей и тестовой выборках с увеличением количества деревьев. Тренировочная ошибка монотонно убывает, тестовая ошибка достигает минимума и начинает расти после определенного количества итераций. Вертикальная линия отмечает точку остановки, выбранную алгоритмом early stopping. Механизм автоматически определяет момент, когда дальнейшее обучение не улучшает генерализацию, экономя время и предотвращая переобучение Механизм ранней остановки Early stopping служит эффективным способом регуляризации, который не требует заранее задавать оптимальное количество итераций. Этот подход особенно полезен при работе с новыми данными или в производственных системах, где важно экономить время обучения. В сочетании с подбором гиперпараметров (grid search) по скорости обучения (learning rate) и глубине деревьев (max_depth) он позволяет автоматически оптимизировать модель с минимальным участием специалиста. Заключение Градиентный бустинг - один из наиболее эффективных и универсальных методов машинного обучения для работы с табличными данными. Его сила в последовательной корректировке ошибок простыми моделями, что обеспечивает высокую точность без сложного создания признаков. В отличие от бэггинга (Bagging), градиентный бустинг снижает смещение через зависимые модели, а не дисперсию через независимые. В сравнении с случайным лесом (Random Forest) он чаще обеспечивает более точные предсказания благодаря последовательному исправлению ошибок, хотя требует более внимательной настройки гиперпараметров. Понимание влияния скорости обучения (learning rate) помогает находить баланс между скоростью обучения и качеством обобщения. Практическое применение моделей бустинга требует внимания к деталям: Выбор функции потерь под конкретную задачу; Контроль переобучения через валидацию; Интерпретация важности признаков для проверки модели; Анализ взаимодействия признаков и выявление мультиколлинеарности, чтобы понять, какие факторы реально влияют на прогноз; Проверка адекватности модели на исторических и стрессовых сценариях, особенно для финансовых временных рядов, чтобы убедиться, что предсказания устойчивы к рыночным изменениям. Современные библиотеки XGBoost, LightGBM и CatBoost расширяют базовый алгоритм регуляризацией и оптимизацией, делая градиентный бустинг конкурентоспособным даже на фоне глубокого обучения. Для структурированных данных — от прогнозирования волатильности до кредитного скоринга — метод остается надежным бейзлайном, который на практике нередко оказывается непревзойденным даже для более сложных моделей, в том числе трансформеров. ### Винрейт (Winrate) и Соотношение риск/прибыль (Risk/Reward Ratio, RRR) Эффективность торговой стратегии определяется двумя базовыми метриками: винрейтом и соотношением риск/прибыль. Винрейт показывает долю прибыльных сделок, RRR — соотношение потенциальной прибыли к риску в каждой позиции. Обе метрики связаны математически: стратегия с винрейтом 30% может быть прибыльной при RRR 3:1, тогда как стратегия с винрейтом 70% остается убыточной при RRR 1:3. Понимание взаимосвязи этих параметров позволяет оценивать стратегии до запуска в продакшен, выявлять системные проблемы в бэктестинге и корректировать параметры риск-менеджмента. Статья раскрывает математический аппарат метрик, типичные значения для разных типов стратегий и практические подходы к оптимизации их комбинации. Винрейт: базовая метрика эффективности Винрейт измеряет процент прибыльных сделок относительно общего количества закрытых позиций. Метрика дает первичное представление о точности торговой системы, но не учитывает размер прибылей и убытков. Определение и расчет Формула винрейта: Winrate = (N_win / N_total) × 100% где: N_win — количество прибыльных сделок; N_total — общее количество сделок; Winrate — процент успешных сделок. Метрика рассчитывается на исторических данных после закрытия всех позиций. Для корректного расчета исключаются сделки с нулевым результатом (break-even), хотя некоторые системы учитывают их как убыточные из-за комиссий. Расчет винрейта требует четкого определения прибыльной сделки. В большинстве случаев это позиция с положительным P&L (Profit&Loss) после вычета комиссий и проскальзывания. Для стратегий с частичным закрытием позиций каждый выход считается отдельной сделкой. Типичные значения винрейта в разных стратегиях Винрейт варьируется в зависимости от типа стратегии и таймфрейма: Стратегии возврата к среднему (Mean reversion): 55-75%. Высокий винрейт обусловлен возвратом цены к среднему значению, но прибыль на сделку обычно невелика; Стратегии следования тренду (Trend following): 30-45%. Низкий винрейт компенсируется крупными прибылями на трендовых движениях; Высокочастотные стратегии (HFT): 50-60%. Винрейт близок к 50% из-за частых сделок, короткого времени удержания позиции, но при этом минимальных спредов; Арбитражные стратегии: 85-95%. Высокий винрейт достигается за счет использования ценовых неэффективностей с ограниченным риском. Показатели приведены для стратегий без агрессивного управления капиталом. Использование усреднения позиций или отсутствие стоп-лоссов искусственно завышает винрейт, создавая ложное ощущение эффективности. Ограничения метрики Винрейт не отражает величину прибылей и убытков. Стратегия с винрейтом 90% может быть убыточной, если средний убыток в 10 раз превышает среднюю прибыль. Такой профиль характерен для стратегий продажи опционов или торговли без стоп-лоссов. Метрика чувствительна к размеру выборки. Винрейт 60% на 20 сделках имеет широкий доверительный интервал и может быть результатом случайности. Для статистически значимых выводов требуется минимум 100-200 сделок, в зависимости от стратегии. Винрейт не учитывает временное распределение результатов. Стратегия может показывать стабильный винрейт 55%, но генерировать прибыль только в определенные рыночные периоды. Анализ винрейта по подпериодам выявляет зависимость от режима рынка. Соотношение риск/прибыль (RRR) Соотношение риск/прибыль определяет отношение потенциальной прибыли к риску в каждой сделке. Метрика Risk/Reward ratio задает параметры входа и выхода из позиции до открытия сделки, обеспечивая дисциплину риск-менеджмента. Концепция и формула Базовая формула RRR: RRR = (Take Profit - Entry) / (Entry - Stop Loss) где: Take Profit — целевой уровень прибыли; Entry — цена входа в позицию; Stop Loss — уровень ограничения убытков; RRR — соотношение потенциальной прибыли к риску. Для коротких позиций формула инвертируется, но логика остается идентичной. RRR 2:1 означает, что потенциальная прибыль в два раза превышает риск на сделку. Метрика рассчитывается в пунктах, пипсах или процентах в зависимости от актива. Для фьючерсов используется денежное выражение с учетом размера контракта. Унификация расчета позволяет сравнивать стратегии на разных инструментах. Практический расчет на примере сделки Рассмотрим покупку акции по цене $100: Stop Loss установлен на $97 (риск $3 на акцию); Take Profit установлен на $109 (потенциальная прибыль $9 на акцию); RRR = ($109 - $100) / ($100 - $97) = 9 / 3 = 3:1. При позиции 100 акций риск составляет $300, потенциальная прибыль — $900. Если винрейт стратегии 40%, математическое ожидание положительное: 0.4 × $900 - 0.6 × $300 = $360 - $180 = $180 на серию из 10 сделок. Расчет предполагает достижение уровней Take Profit и Stop Loss без частичных выходов. Реальное исполнение сделок включает проскальзывание, особенно при волатильных движениях. Для внутридневных стратегий проскальзывание снижает фактический RRR на 5-15%. Предустановка соотношения риск/прибыль (RRR) и адаптивный подход Фиксированное соотношение риск/прибыль делает проверку стратегии на исторических данных проще и обеспечивает единый подход к управлению рисками. В этом случае стратегия использует одно и то же отношение для всех сделок, независимо от ситуации на рынке. Такой метод хорошо работает в системах с большим количеством сделок и высокой частотой операций. Адаптивное соотношение риск/прибыль учитывает волатильность и текущую рыночную структуру. Стоп-ордер на ограничение убытка (Stop Loss) размещается за уровнями поддержки или сопротивления, либо на расстоянии 2–3 диапазонов истинного среднего (ATR — Average True Range) от точки входа. Цель по прибыли (Take Profit) выбирается по техническим уровням или по расширениям Фибоначчи (Fibonacci). Этот метод требует больше логики и настроек, но лучше подстраивается под рынок. Гибридный подход объединяет оба метода. Минимальное соотношение риск/прибыль задается фиксированным, например 1.5:1. Но дальше оно изменяется с учетом рыночного контекста. Если технический уровень позволяет поставить цель с отношением 3:1 при том же уровне Stop Loss, система выбирает более дальнюю цель. Взаимосвязь винрейта и RRR Винрейт и RRR определяют математическое ожидание стратегии. Комбинация этих метрик показывает, будет ли система генерировать прибыль в долгосрочной перспективе. Математическое ожидание стратегии Формула математического ожидания (expectancy): E = (Winrate × Avg_Win) - ((1 - Winrate) × Avg_Loss) где: Winrate — доля прибыльных сделок (в десятичной форме); Avg_Win — средняя прибыль на прибыльную сделку; Avg_Loss — средний убыток на убыточную сделку; E — математическое ожидание на сделку. Положительное математическое ожидание — необходимое условие прибыльности. Стратегия с E > 0 генерирует прибыль при достаточном количестве сделок, даже с периодами просадок. Выражение математического ожидания через RRR упрощает анализ: E = (Winrate × RRR × Risk) - ((1 - Winrate) × Risk) где: Risk — размер риска на сделку в денежном выражении; RRR — соотношение средней прибыли к среднему убытку. Формула показывает, что стратегия с винрейтом 40% и RRR 2:1 имеет нулевое математическое ожидание: 0.4 × 2 - 0.6 = 0.8 - 0.6 = 0.2 (или положительное, если учитывать размер риска). Минимальный требуемый винрейт Для любого заданного соотношения риск/прибыль (RRR — Risk/Reward Ratio) существует минимальный винрейт, при котором стратегия не приносит ни прибыли, ни убытка. Он рассчитывается по формуле: Winrate_min = 1 / (1 + RRR) где: RRR — соотношение риск/прибыль; Winrate_min — минимальный винрейт при ожидаемой прибыли, равной нулю. Минимальный винрейт меняется в зависимости от RRR: RRR 1:1 → нужен винрейт выше 50%; RRR 1:2 → нужен винрейт выше 33,3%; RRR 1:3 → нужен винрейт выше 25%; RRR 2:1 → нужен винрейт выше 66,7%; RRR 3:1 → нужен винрейт выше 75%. Эта формула предполагает равномерное распределение доходных и убыточных сделок. Но в реальности результаты распределены неравномерно, поэтому расчеты стоит корректировать и ориентироваться не на средние значения, а на медианные. Комиссии и проскальзывание тоже сдвигают точку безубыточности. Например, при комиссии 0,1% на вход и выход фактическое соотношение риск/прибыль уменьшается. Если стратегия работает с RRR 2:1 и риском 100 долларов, то комиссия 0,20 доллара на позицию объемом 10 000 долларов снижает реальное RRR до 1,96:1. Профили стратегий: высокий винрейт vs высокий RRR Стратегии делятся на два основных профиля в зависимости от комбинации винрейт-RRR. Профиль 1: Высокий винрейт (60-80%), низкий RRR (0.5:1 - 1.5:1) Такой профиль характерен для стратегий возврата к среднему (mean reversion) и для скальпинга. Частые и небольшие прибыли перекрывают редкие, но крупные убытки. Основные риски: возможны сильные просадки во время устойчивых трендов, высокая зависимость результата от комиссий и дополнительный эмоциональный стресс при удержании убыточных позиций. Профиль 2: Низкий винрейт (30-45%), высокий RRR (2:1 - 5:1) Такой профиль характерен для стратегий следования за трендом (trend following) и стратегий пробоя (breakout). Редкие крупные прибыли перекрывают длинные серии небольших убытков. Основные риски: длительные периоды без прибыльных сделок, высокие требования к психологической устойчивости и необходимость достаточно большого капитала, чтобы выдерживать просадки. Промежуточный профиль: Умеренный винрейт (45-55%), умеренный RRR (1.5:1 - 2.5:1) Это сбалансированный подход, который объединяет черты обоих профилей. Он формирует более стабильную кривую капитала с меньшей волатильностью доходности и хорошо подходит для диверсифицированных портфелей стратегий. Рис. 1: Зависимость математического ожидания от винрейта и соотношения риск/доходность (RRR). Слева - матожидание стратегии в зависимости от винрейта при разных значениях RRR. Правый график демонстрирует минимальный требуемый винрейт для достижения безубыточности при заданном RRR Применение метрик в разработке стратегий Процент прибыльных сделок (винрейт) и соотношение риск/прибыль используются на всех этапах создания торговых стратегий — от первичной проверки идеи до настройки параметров в рабочей среде. Грамотная интерпретация этих метрик помогает избежать системных ошибок и переоптимизации. Оптимизация сочетания винрейта и RRR Оптимизация начинается с анализа исторического распределения прибыли и убытков. Гистограмма результатов (P&L — Profit and Loss) показывает асимметрию и наличие выбросов. Если у стратегии длинный правый хвост — редкие, но большие прибыли — то важно снижать количество убыточных сделок, улучшая фильтры входа. Далее тестируются параметры стоп-ордера на ограничение убытков (Stop Loss) и цели по прибыли (Take Profit). Их подбирают в виде матрицы значений. Stop Loss изменяется от 0,5% до 3% с шагом 0,5%, а Take Profit — от 1% до 6% с тем же шагом. Для каждой пары вычисляется математическое ожидание, коэффициент Шарпа (Sharpe ratio) и максимальная просадка. Оптимальной считается комбинация, которая дает максимальное ожидаемое значение при приемлемой глубине просадки. Рис. 2: Комплексный анализ оптимизации комбинации Winrate-Risk/Reward Ratio. Слева-направо: (1) Распределение P&L показывает различия между стратегиями с симметричным профилем и длинным правым хвостом. (2) Тепловая карта демонстрирует математическое ожидание для всех комбинаций Stop-loss/Take-Profit с отметкой оптимума. (3) 3D поверхность визуализирует пространство параметров. (4) Адаптивная оптимизация иллюстрирует корректировку stop-loss в зависимости от волатильности. (5) Walk-forward анализ выявляет деградацию производительности cстратегии на out-sample периодах. (6) Сравнение ключевых метрик для различных комбинаций параметров Адаптивная оптимизация учитывает рыночный режим: При низкой волатильности Stop Loss уменьшается — это улучшает RRR без снижения винрейта; При высокой волатильности Stop Loss увеличивается, чтобы снизить число преждевременных закрытий позиций. Переключение между режимами выполняется на основе диапазона истинного среднего (ATR — Average True Range) или исторической волатильности. Проверка стабильности проводится через пошаговый анализ (walk-forward): Данные делятся на обучающие периоды (in-sample) и тестовые периоды (out-of-sample); Параметры подбираются на in-sample и затем применяются к out-of-sample; Если винрейт падает более чем на 10% или RRR уменьшается более чем на 20%, это говорит о переоптимизации стратегии. Распространенные ошибки в интерпретации Ошибка 1: Игнорирование размера выборки Винрейт 75% на 12 сделках не имеет статистической значимости. Доверительный интервал при таком объеме составляет примерно ±25%. Минимальный размер выборки для оценки винрейта — около 30 сделок, а для действительно надежных выводов нужно не меньше 100. Ошибка 2: Фокус только на винрейте Высокий винрейт при низком соотношении риск/прибыль (RRR) приводит к профилю «сбор монет перед катком». Стратегия может давать стабильный доход месяцами, а затем один крупный убыток уничтожает весь капитал. Это часто встречается в стратегиях продажи опционов без защиты (хеджирования), либо торговле без стоп-лоссов и по Мартингейлу. Ошибка 3: Использование средних значений вместо медианных Распределение прибыли и убытков (P&L — Profit and Loss) обычно несимметрично. Средняя прибыль 500 долларов может быть результатом одной сделки на +5000 и девяти сделок на +50. Медианная прибыль показывает более типичный и реалистичный результат. Ошибка 4: Пренебрежение корреляцией между сделками Математическое ожидание предполагает, что сделки независимы. Однако стратегии с усреднением позиций или парным трейдингом имеют коррелированные результаты. Серии убытков появляются чаще, чем предсказывает биномиальная модель. Ошибка 5: Использование статичных параметров в изменяющейся среде Соотношение риск/прибыль 2:1 (RRR 2:1), оптимальное в 2024 году, может стать менее эффективным в 2025 из-за изменения волатильности. Пересмотр параметров раз в квартал помогает избежать деградации стратегии. Поводом для пересмотра служит падение винрейта более чем на 15% от его исторического среднего уровня. Влияние комиссий и проскальзывания Транзакционные издержки влияют на винрейт и соотношение риск/прибыль (RRR) неравномерно. Фиксированная комиссия $1 на сделку сильнее снижает винрейт стратегий с маленькой средней прибылью. Например, для скальпинга со средней прибылью $5 комиссия $1 уменьшает фактический доход на 20%, а для swing-трейдинга со средней прибылью $100 влияние составит всего 1%. Проскальзывание зависит от ликвидности инструмента и способа исполнения ордера. Рыночные ордера на низколиквидных активах могут давать проскальзывание 0,1–0,5% от цены входа. Лимитные ордера устраняют проскальзывание, но снижают вероятность исполнения (fill rate). Недозаполненные ордера эквивалентны пропущенным прибыльным сделкам, что снижает винрейт стратегии. Вот почему для реалистичной оценки стратегии важно провести моделирование транзакционных издержек в бэктестинге. Консервативная модель предполагает: проскальзывание 0,05% для ликвидных акций, 0,1–0,2% для фьючерсов и 0,2–0,5% для криптовалют. Для внутридневных стратегий с 10+ сделками в день издержки могут превышать 2% от оборота. Мониторинг и адаптация Постоянный мониторинг винрейта и RRR выявляет деградацию стратегии до критических просадок. Систематическое отклонение метрик от исторических значений сигнализирует о структурных изменениях рынка или технических проблемах в исполнении. Отслеживание динамики метрик Для эффективного управления стратегией важно не только рассчитывать винрейт и соотношение риск/прибыль (RRR), но и регулярно отслеживать их динамику. Это позволяет вовремя заметить изменения в работе стратегии и корректировать ее параметры до появления значительных убытков. Rolling winrate Скользящий винрейт рассчитывается на скользящем окне последних N сделок. Для краткосрочных стратегий используют окно 50–100 сделок, для среднесрочных — 30–50. Снижение скользящего винрейта на 15% от исторического среднего сигнализирует о необходимости анализа причин: изменение волатильности, смена рыночного режима или ухудшение исполнения ордеров. Cumulative winrate Кумулятивный винрейт показывает долгосрочный тренд эффективности. График строится как процент прибыльных сделок с начала периода. Стабильная стратегия демонстрирует отклонение винрейта ±5% от среднего. Резкие скачки указывают на аномальную активность или технические сбои. Рис. 3: Мониторинг винрейта на примере стратегии с деградацией после 200-й сделки. Метод Rolling winrate выявляет изменения быстрее Cumulative winrate, что позволяет оперативно реагировать на проблемы Мониторинг соотношения риск/прибыль (RRR) требует раздельного отслеживания планового и реализованного значения: Плановый RRR — это отношение Take Profit к Stop Loss на момент входа; Реализованный RRR — фактическое соотношение средней прибыли к среднему убытку. Расхождение более 20% сигнализирует о проблемах исполнения или необходимости корректировки уровней. Статистическая значимость изменений Случайные флуктуации винрейта неизбежны даже для стабильной стратегии. Биномиальный тест определяет, является ли наблюдаемое отклонение статистически значимым или результатом случайности. Для винрейта 55% на выборке 100 сделок стандартное отклонение составляет: σ = √(p × (1 - p) / n) = √(0.55 × 0.45 / 100) = 0.0497 ≈ 5% где: p — исторический винрейт (в десятичной форме); n — количество сделок; σ — стандартное отклонение винрейта. Доверительный интервал 95% (2σ): 55% ± 10%. Например, наблюдаемый винрейт 48% на 100 сделках находится в пределах случайных отклонений. Винрейт 40% уже выходит за пределы доверительного интервала и требует расследования. Для соотношения риск/прибыль значимость оценивают с помощью t-теста для сравнения средних. Нулевая гипотеза: средний RRR текущего периода равен историческому. Если p-value < 0,05, гипотеза отвергается — это указывает на значимое изменение RRR. Для того, чтобы оперативно выявлять деградацию стратегии и снижать потери, используют последовательный анализ. Этот метод оценивает метрики после каждой сделки, позволяя принимать решения до того, как убытки станут значительными. В частности, метод SPRT (Sequential Probability Ratio Test) проверяет, насколько текущий винрейт отличается от исторического. Критический порог p-value 0,01 минимизирует ложные срабатывания и помогает быстро реагировать на изменения. Корректировка параметров стратегии При снижении винрейта торговой стратегии не следует поспешно менять параметры или делать выводы об ее эффективности. Сначала нужно проанализировать причины — это может быть временное изменение волатильности, смена рыночного режима или структурные проблемы самой стратегии. Только после выявления источника падения следует принимать решение о корректировках. Так, к примеру, временное падение winrate из-за изменения волатильности не требует вмешательства. А вот систематическое снижение на протяжении 2–3 месяцев сигнализирует о структурных проблемах стратегии. Подходы к восстановлению винрейта: Ужесточение фильтров входа. Добавление условий по объему, спреду или волатильности снижает число сделок, но повышает качество сигналов; Корректировка таймфрейма. Переход на более крупный таймфрейм уменьшает рыночный шум и улучшает точность сигналов; Адаптация к рыночному режиму. Использование индикаторов трендовости (ADX, R-squared) помогает фильтровать периоды с низкой предсказуемостью; Обновление обучающих данных. Для стратегий на базе машинного обучения (ML) периодическое переобучение на свежих данных компенсирует изменения рыночных паттернов. Снижение соотношения риск/прибыль (RRR) корректируется через изменение уровней Take Profit и Stop Loss: При росте волатильности Stop Loss расширяется на 20–30%, чтобы предотвратить преждевременные закрытия позиций. Take Profit изменяется пропорционально для сохранения базового RRR; Альтернативный вариант — динамический трейлинг-стоп (trailing stop), который адаптирует уровни к текущим рыночным условиям. A/B тестирование параметров помогает минимизировать риск ухудшения результатов. Капитал делится на две части: одна работает с текущими параметрами, другая — с новыми. После 50–100 сделок сравниваются коэффициент Шарпа (Sharpe ratio), максимальная просадка и математическое ожидание. Параметры с лучшими результатами применяются ко всему капиталу. Заключение Показатели Процент прибыльных сделок (винрейт) и Соотношение риск/прибыль (RRR) составляют основу оценки торговых стратегий. Понимание их взаимосвязи позволяет превращать субъективные суждения о качестве системы в объективные показатели. Например, стратегия с винрейтом 35% и RRR 3:1 может показывать лучшие результаты, чем система с винрейтом 65% и RRR 0,8:1, по ключевым метрикам: математическому ожиданию, стабильности кривой капитала и устойчивости к просадкам. Эффективная работа с этими метриками требует сочетания теоретических знаний и практического опыта. Формулы задают направление, но реальные рынки вносят корректировки через проскальзывание, комиссии и несимметричное распределение результатов. Только комплексный подход к анализу метрик и постоянная адаптация стратегии позволяют превращать знания о рынке в устойчивое преимущество и эффективное управление капиталом. ### Портфель максимальной диверсификации (Maximum Diversification Portfolio) Портфель максимальной диверсификации (Maximum Diversification Portfolio) представляет альтернативу классическим подходам к формированию инвестиционных портфелей. В отличие от метода Марковица, который требует оценки ожидаемой доходности активов, максимальная диверсификация фокусируется исключительно на структуре риска. Метод строится на максимизации диверсификационного коэффициента — отношения взвешенной волатильности отдельных активов к волатильности портфеля. Подход особенно востребован институциональными инвесторами, поскольку устраняет необходимость прогнозирования доходностей и снижает чувствительность к ошибкам оценки параметров. Концепция появилась после наблюдений за тем, как ведут себя связи между активами. В периоды рыночного напряжения корреляции растут. В результате эффект диверсификации ослабевает именно тогда, когда он особенно нужен. Портфель максимальной диверсификации элегантно решает эту проблему. Он напрямую усиливает выгоду от диверсификации за счет оптимизации весов активов. Теоретические основы максимальной диверсификации Метод максимальной диверсификации базируется на фундаментальном свойстве портфелей: волатильность диверсифицированного портфеля всегда ниже средневзвешенной волатильности входящих в него активов. Величина этого снижения зависит от корреляционной структуры активов. Задача оптимизации состоит в поиске таких весов, которые максимизируют разницу между индивидуальными рисками активов и риском портфеля. Концепция диверсификационного коэффициента Диверсификационный коэффициент (diversification ratio) определяется как: DR = (w₁σ₁ + w₂σ₂ + ... + wₙσₙ) / σₚ где: wᵢ — вес i-го актива в портфеле; σᵢ — волатильность i-го актива; σₚ — волатильность портфеля. Числитель представляет взвешенную сумму индивидуальных волатильностей, знаменатель — реализованную волатильность портфеля. Для полностью коррелированных активов коэффициент равен 1, для независимых активов он превышает 1 тем больше, чем ниже корреляции. Максимальное значение достигается при отрицательных корреляциях между активами. Экономический смысл диверсификационного коэффициента: он показывает, во сколько раз портфельный риск ниже среднего риска составляющих активов. Значение DR = 1.5 означает, что диверсификация снизила риск на 33% относительно наивного взвешивания по волатильности. Отличия от портфеля минимальной дисперсии Портфель минимальной дисперсии (Minimum variance portfolio) минимизирует абсолютное значение портфельной волатильности. Целевая функция имеет вид: min σₚ² = min (w'Σw) где: w — вектор весов активов; Σ — ковариационная матрица доходностей; w'Σw — квадратичная форма, дающая дисперсию портфеля. Портфель максимальной диверсификации максимизирует относительную меру — отношение средневзвешенной волатильности к портфельной. Это приводит к различным результатам: портфель минимальной дисперсии концентрируется в активах с низкой абсолютной волатильностью, даже если они высоко коррелированы. Портфель максимальной диверсификации распределяет веса в пользу активов с низкими корреляциями, независимо от их индивидуальной волатильности. На практике различия заметны в том, как распределяются веса. Портфель минимальной дисперсии в паре «акции–облигации» направит большую часть капитала в облигации, потому что их колебания ниже. Портфель максимальной диверсификации даст акциям больший вес. Их связь с облигациями слабая, поэтому они сильнее повышают эффект диверсификации. Связь с премией за корреляционный риск (correlation risk premium) Эмпирические исследования показывают: чем выше диверсификационный коэффициент портфеля, тем выше его будущая доходность. Это объясняется премией за корреляционный риск. Инвесторы требуют дополнительную доходность от активов, которые сильно движутся вместе с рынком и почти не дают диверсификации. Поэтому активы с высокой корреляцией должны приносить более высокую ожидаемую прибыль, чтобы компенсировать этот риск. Активы с низкой корреляцией помогают защитить портфель во время рыночных просадок. Такие активы встречаются редко, поэтому возникает структурный дефицит инструментов, способных давать диверсификацию. Этот дефицит влияет на их оценку. Портфель максимальной диверсификации систематически находит и включает такие активы. Благодаря этому он получает доступ к премии за корреляционный риск. Механизм основан на том, как меняются корреляции. В спокойные периоды связи между активами ослабляются, а в кризисы усиливаются. Портфель, созданный на основе исторически низких корреляций, получает преимущество в обычных рыночных условиях. В стрессовые периоды рост корреляций уменьшает эффект диверсификации, но изначально высокий диверсификационный коэффициент дает портфелю запас прочности. Математическая формулировка задачи оптимизации Построение портфеля максимальной диверсификации представляет собой задачу нелинейной оптимизации с квадратичными ограничениями. Ее нельзя решить аналитически. Это связано с тем, что целевая функция включает квадратный корень из квадратичной формы в знаменателе, поэтому требуются численные методы. Целевая функция Целевая функция оптимизации записывается как максимизация диверсификационного коэффициента: max DR(w) = (w'σ) / √(w'Σw) где: w — вектор весов активов (оптимизируемая переменная); σ — вектор индивидуальных волатильностей активов; Σ — ковариационная матрица доходностей; w'σ — скалярное произведение, дающее взвешенную сумму волатильностей; w'Σw — квадратичная форма, дающая дисперсию портфеля. Числитель целевой функции линеен по весам, знаменатель представляет стандартное отклонение портфеля. Максимизация эквивалентна минимизации обратной величины, что иногда используется в численных решателях для улучшения сходимости. Альтернативная формулировка через минимизацию: min -DR(w) = -(w'σ) / √(w'Σw) Выбор формулировки зависит от выбранного метода оптимизации. Алгоритм последовательного наименьшего квадрата с ограничениями (Sequential Least Squares Programming, SLSQP) устойчиво решает обе версии задачи. Ограничения и их влияние на решение В стандартной постановке задачи используются три типа ограничений. 1. Бюджетное ограничение: ∑wᵢ = 1 2. Ограничение на длинные позиции (long-only): wᵢ ≥ 0 для всех i 3. Дополнительные границы на веса: wᵢ,min ≤ wᵢ ≤ wᵢ,max Бюджетное ограничение гарантирует, что весь капитал полностью инвестирован. Условие long-only делает стратегию проще в реализации и соответствует требованиям многих институциональных инвесторов. Границы на веса защищают портфель от чрезмерной концентрации в отдельных активах. Ограничения сильно влияют на результат оптимизации. Без границ на веса оптимизатор может сосредоточить капитал в нескольких слабо коррелированных активах, что создает риск концентрации. Типичные границы: Максимальный вес 20–30% для портфелей из 10–20 активов. Максимальный вес 5–10% для портфелей из 50+ активов. Жесткие ограничения снижают максимальный диверсификационный коэффициент, но делают портфель более устойчивым к ошибкам в оценке параметров. Например, портфель с ограничением максимального веса 15% показывает более стабильные веса при ребалансировке, чем портфель без ограничений. import numpy as np import pandas as pd import matplotlib.pyplot as plt from scipy.optimize import minimize # Генерация синтетических данных для демонстрации np.random.seed(42) n_assets = 5 n_periods = 252 # Создание корреляционной матрицы с различными уровнями корреляции correlation_matrix = np.array([ [1.00, 0.30, 0.10, -0.05, 0.20], [0.30, 1.00, 0.50, 0.15, 0.25], [0.10, 0.50, 1.00, 0.20, 0.10], [-0.05, 0.15, 0.20, 1.00, 0.05], [0.20, 0.25, 0.10, 0.05, 1.00] ]) # Волатильности активов (годовые) volatilities = np.array([0.15, 0.20, 0.25, 0.18, 0.22]) # Визуализация корреляционной матрицы fig, ax = plt.subplots(figsize=(10, 8)) im = ax.imshow(correlation_matrix, cmap='RdYlGn', vmin=-1, vmax=1, aspect='auto') # Настройка осей ax.set_xticks(np.arange(n_assets)) ax.set_yticks(np.arange(n_assets)) ax.set_xticklabels([f'Актив {i+1}' for i in range(n_assets)]) ax.set_yticklabels([f'Актив {i+1}' for i in range(n_assets)]) # Добавление значений корреляций for i in range(n_assets): for j in range(n_assets): text = ax.text(j, i, f'{correlation_matrix[i, j]:.2f}', ha="center", va="center", color="black", fontsize=11) # Colorbar cbar = plt.colorbar(im, ax=ax) cbar.set_label('Корреляция', rotation=270, labelpad=20) ax.set_title('Корреляционная матрица активов для Maximum Diversification Portfolio', fontsize=12, pad=20) plt.tight_layout() plt.show() Рис. 1: Корреляционная структура активов определяет потенциал диверсификации. Актив 4 с отрицательной корреляцией к активу 1 и низкими корреляциями к остальным обеспечивает максимальный диверсификационный эффект Практическая реализация Построение портфеля максимальной диверсификации требует точной оценки ковариационной матрицы и устойчивого численного решения задачи оптимизации. Качество этих оценок напрямую влияет на стабильность весов при ребалансировке и на эффективность стратегии на временных рядах вне обучающей выборки. Подготовка данных Ковариационная матрица оценивается по историческим доходностям активов. Стандартный подход использует выборочную ковариационную матрицу: Σ̂ = (1/(T-1)) ∑(rₜ - μ̂)(rₜ - μ̂)' где: rₜ — вектор доходностей в момент t; μ̂ — вектор выборочных средних доходностей; T — число наблюдений. Длина исторического окна влияет на баланс между стабильностью оценок и адаптивностью к изменениям рынка. Окно в 252 торговых дня (1 год) подходит для большинства случаев. Более короткие окна, например 126 дней, делают портфель чувствительнее к недавним изменениям корреляций, однако при этом увеличивают шум в оценках. Проблема сингулярности возникает, когда число активов близко к числу наблюдений или активы линейно зависимы. Регуляризация через сжатие (shrinkage) к диагональной или константной корреляционной матрице решает эту проблему. Формула расчета: Σ̂shrink = λΣ̂target + (1-λ)Σ̂sample Коэффициент λ выбирается с помощью кросс-валидации или аналитически по формуле Ледуа-Вульфа (Ledoit-Wolf). Для ликвидных активов типичные значения λ — 0.1–0.3. Решение задачи оптимизации Численное решение задачи максимизации диверсификационного коэффициента выполняется градиентными методами. Алгоритм SLSQP обрабатывает нелинейные ограничения и обеспечивает надежную сходимость для таких задач. def calculate_portfolio_volatility(weights, cov_matrix): """Расчет волатильности портфеля""" return np.sqrt(weights @ cov_matrix @ weights) def diversification_ratio(weights, volatilities, cov_matrix): """Расчет диверсификационного коэффициента""" weighted_vol = weights @ volatilities portfolio_vol = calculate_portfolio_volatility(weights, cov_matrix) return weighted_vol / portfolio_vol def negative_diversification_ratio(weights, volatilities, cov_matrix): """Целевая функция для минимизации (отрицательный DR)""" return -diversification_ratio(weights, volatilities, cov_matrix) def optimize_max_diversification(volatilities, cov_matrix, weight_bounds=(0, 0.30)): """ Оптимизация портфеля максимальной диверсификации Параметры: - volatilities: вектор волатильностей активов - cov_matrix: ковариационная матрица - weight_bounds: границы весов для каждого актива """ n_assets = len(volatilities) # Начальное приближение - равновзвешенный портфель initial_weights = np.ones(n_assets) / n_assets # Ограничения constraints = [ {'type': 'eq', 'fun': lambda w: np.sum(w) - 1} # Сумма весов = 1 ] # Границы весов bounds = tuple(weight_bounds for _ in range(n_assets)) # Оптимизация result = minimize( negative_diversification_ratio, initial_weights, args=(volatilities, cov_matrix), method='SLSQP', bounds=bounds, constraints=constraints, options={'ftol': 1e-9, 'maxiter': 1000} ) return result.x, -result.fun # Построение ковариационной матрицы из корреляционной D = np.diag(volatilities) cov_matrix = D @ correlation_matrix @ D # Оптимизация Maximum Diversification Portfolio md_weights, md_ratio = optimize_max_diversification(volatilities, cov_matrix) # Для сравнения - равновзвешенный портфель equal_weights = np.ones(n_assets) / n_assets equal_dr = diversification_ratio(equal_weights, volatilities, cov_matrix) # Для сравнения - minimum variance портфель def portfolio_variance(weights, cov_matrix): return weights @ cov_matrix @ weights mv_result = minimize( portfolio_variance, equal_weights, args=(cov_matrix,), method='SLSQP', bounds=tuple((0, 0.30) for _ in range(n_assets)), constraints=[{'type': 'eq', 'fun': lambda w: np.sum(w) - 1}] ) mv_weights = mv_result.x mv_dr = diversification_ratio(mv_weights, volatilities, cov_matrix) # Визуализация результатов fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) # График весов активов x = np.arange(n_assets) width = 0.25 ax1.bar(x - width, equal_weights * 100, width, label='Равновзвешенный', alpha=0.8) ax1.bar(x, mv_weights * 100, width, label='Minimum Variance', alpha=0.8) ax1.bar(x + width, md_weights * 100, width, label='Maximum Diversification', alpha=0.8) ax1.set_xlabel('Активы') ax1.set_ylabel('Вес в портфеле (%)') ax1.set_title('Распределение весов в различных портфелях') ax1.set_xticks(x) ax1.set_xticklabels([f'Актив {i+1}' for i in range(n_assets)]) ax1.legend() ax1.grid(axis='y', alpha=0.3) # График характеристик портфелей portfolios = ['Равновзвешенный', 'Minimum\nVariance', 'Maximum\nDiversification'] dr_values = [equal_dr, mv_dr, md_ratio] vol_values = [ calculate_portfolio_volatility(equal_weights, cov_matrix), calculate_portfolio_volatility(mv_weights, cov_matrix), calculate_portfolio_volatility(md_weights, cov_matrix) ] ax2_twin = ax2.twinx() bars1 = ax2.bar(np.arange(3) - 0.2, dr_values, 0.4, label='Diversification Ratio', color='steelblue', alpha=0.8) bars2 = ax2_twin.bar(np.arange(3) + 0.2, [v * 100 for v in vol_values], 0.4, label='Волатильность (%)', color='coral', alpha=0.8) ax2.set_xlabel('Тип портфеля') ax2.set_ylabel('Diversification Ratio', color='steelblue') ax2_twin.set_ylabel('Волатильность (%)', color='coral') ax2.set_title('Сравнение характеристик портфелей') ax2.set_xticks(np.arange(3)) ax2.set_xticklabels(portfolios) ax2.tick_params(axis='y', labelcolor='steelblue') ax2_twin.tick_params(axis='y', labelcolor='coral') ax2.grid(axis='y', alpha=0.3) # Легенда lines1, labels1 = ax2.get_legend_handles_labels() lines2, labels2 = ax2_twin.get_legend_handles_labels() ax2.legend(lines1 + lines2, labels1 + labels2, loc='upper left') plt.tight_layout() plt.savefig('portfolio_comparison.png', dpi=150, bbox_inches='tight') plt.show() Рис. 2: Визуализация распределения весов. Портфель максимальной диверсификации (Maximum Diversification Portfolio) дает большие веса активам с низкой корреляцией (например, Актив 4). Портфель минимальной дисперсии (Minimum Variance) концентрируется на активах с низкой абсолютной волатильностью (например, Актив 1) Алгоритм начинает с равновзвешенного портфеля как начального приближения. Метод оптимизации SLSQP итеративно корректирует веса, вычисляя градиент целевой функции и проверяя ограничения. Параметр ftol=1e-9 устанавливает критерий сходимости по изменению функции, обеспечивая высокую точность решения. Интерпретация результатов Анализ оптимальных весов выявляет активы, вносящие наибольший вклад в диверсификацию. Активы с высокими весами обладают комбинацией низких корреляций с остальным портфелем и умеренной собственной волатильностью. Актив с отрицательной корреляцией к другим получает повышенный вес даже при высокой индивидуальной волатильности. Величина диверсификационного коэффициента (DR) показывает эффективность диверсификации: Для портфелей из акций одного региона типичные значения DR — 1.3–1.5; Межрегиональные или мультиактивные портфели достигают DR 1.5–2.0; Портфели с альтернативными активами (товары, хедж-фонды) могут превышать DR 2.0. Сравнение с портфелем минимальной дисперсии (Minimum Variance) показывает компромисс между снижением абсолютного риска и увеличением диверсификационного эффекта. Портфель минимальной дисперсии имеет более низкую волатильность, но сосредоточен в низкорисковых активах. Портфель максимальной диверсификации распределяет риск равномернее, что снижает зависимость от точности оценки параметров отдельных активов. Сравнение с альтернативными стратегиями Эмпирическая оценка эффективности Maximum Diversification Portfolio требует сравнения с конкурирующими подходами к построению портфелей. Бэктестинг на исторических данных выявляет сильные и слабые стороны метода в различных рыночных режимах. Бэктест на исторических данных Тестовая выборка включает четыре стратегии построения портфеля: 1. Равновзвешенный портфель (Equal Weight): wᵢ = 1/n для всех активов. Простейший подход, не требует оценки параметров и обеспечивает базовый уровень диверсификации. 2. Портфель минимальной дисперсии (Minimum Variance Portfolio): Минимизирует w'Σw с учетом ограничений на веса. Фокусируется на снижении абсолютной волатильности портфеля. 3. Портфель максимальной диверсификации (Maximum Diversification Portfolio): Максимизирует (w'σ)/√(w'Σw). Целевой метод данного исследования. 4. Портфель «среднее-волатильность» (Mean-Variance Portfolio, Марковиц): Максимизирует w'μ − λw'Σw где: μ — вектор ожидаемых доходностей; λ — коэффициент неприятия риска. Требует прогнозирования доходностей. Методология бэктестинга: Ребалансировка ежемесячно с расчетом весов на последний торговый день месяца; Историческое окно (lookback period) 252 торговых дня для оценки ковариационной матрицы; Транзакционные издержки 10 базисных пунктов на сделку; Для портфелей, построенных через mean-variance, используются исторические средние доходности за lookback period. import numpy as np import pandas as pd import matplotlib.pyplot as plt from scipy.optimize import minimize # Параметры симуляции np.random.seed(42) n_assets = 5 n_days = 1260 # 5 лет торговых дней dates = pd.date_range(start='2023-01-01', periods=n_days, freq='B') # Корреляционная матрица и волатильности (пример) correlation_matrix = np.array([ [1.0, 0.2, 0.1, 0.0, 0.1], [0.2, 1.0, 0.1, 0.0, 0.1], [0.1, 0.1, 1.0, 0.2, 0.1], [0.0, 0.0, 0.2, 1.0, 0.1], [0.1, 0.1, 0.1, 0.1, 1.0] ]) volatilities = np.array([0.2, 0.15, 0.25, 0.1, 0.18]) mean_returns = np.array([0.0003, 0.0002, 0.0004, 0.0001, 0.0003]) # Генерация доходностей с заданной корреляционной структурой L = np.linalg.cholesky(correlation_matrix) returns_data = np.random.randn(n_days, n_assets) @ L.T * volatilities / np.sqrt(252) + mean_returns returns_df = pd.DataFrame(returns_data, index=dates, columns=[f'Актив_{i+1}' for i in range(n_assets)]) # Функция бэктеста def backtest_strategy(returns_df, strategy_func, lookback=252, rebalance_freq='M', transaction_cost=0.001): """ Бэктест стратегии формирования портфеля Параметры: - returns_df: датафрейм с доходностями активов - strategy_func: функция расчета весов портфеля - lookback: период для оценки параметров - rebalance_freq: частота ребалансировки ('M' - месячная) - transaction_cost: транзакционные издержки (доля от оборота) """ portfolio_values = [100] # Начальная стоимость портфеля portfolio_returns = [] weights_history = [] # Даты ребалансировки rebal_dates = returns_df.resample(rebalance_freq).last().index[1:] current_weights = None for date in returns_df.index[lookback:]: daily_return = returns_df.loc[date].values # Ребалансировка if date in rebal_dates or current_weights is None: historical_returns = returns_df.loc[:date].iloc[-lookback:] new_weights = strategy_func(historical_returns) # Транзакционные издержки if current_weights is not None: turnover = np.sum(np.abs(new_weights - current_weights)) cost = turnover * transaction_cost else: cost = 0 current_weights = new_weights weights_history.append((date, new_weights)) # Доходность портфеля portfolio_return = np.sum(current_weights * daily_return) if date in rebal_dates and current_weights is not None: portfolio_return -= cost portfolio_returns.append(portfolio_return) portfolio_values.append(portfolio_values[-1] * (1 + portfolio_return)) return np.array(portfolio_values), np.array(portfolio_returns), weights_history # Функции стратегий def equal_weight_strategy(returns_df): n = len(returns_df.columns) return np.ones(n) / n def portfolio_variance(w, cov_matrix): return w.T @ cov_matrix @ w def minimum_variance_strategy(returns_df): cov_matrix = returns_df.cov().values * 252 n = len(returns_df.columns) initial = np.ones(n) / n result = minimize( portfolio_variance, initial, args=(cov_matrix,), method='SLSQP', bounds=tuple((0, 0.30) for _ in range(n)), constraints=[{'type': 'eq', 'fun': lambda w: np.sum(w) - 1}] ) return result.x def optimize_max_diversification(volatilities, cov_matrix): """ Простейшая оптимизация для Maximum Diversification """ n = len(volatilities) initial = np.ones(n) / n def objective(w): return -np.sum(w * volatilities) / np.sqrt(w @ cov_matrix @ w) result = minimize( objective, initial, method='SLSQP', bounds=tuple((0, 0.30) for _ in range(n)), constraints=[{'type': 'eq', 'fun': lambda w: np.sum(w) - 1}] ) return result.x, -result.fun def max_diversification_strategy(returns_df): cov_matrix = returns_df.cov().values * 252 volatilities = returns_df.std().values * np.sqrt(252) weights, _ = optimize_max_diversification(volatilities, cov_matrix) return weights def mean_variance_strategy(returns_df, risk_aversion=2): mean_returns = returns_df.mean().values * 252 cov_matrix = returns_df.cov().values * 252 n = len(returns_df.columns) initial = np.ones(n) / n def objective(w): return -(w @ mean_returns - risk_aversion * w @ cov_matrix @ w) result = minimize( objective, initial, method='SLSQP', bounds=tuple((0, 0.30) for _ in range(n)), constraints=[{'type': 'eq', 'fun': lambda w: np.sum(w) - 1}] ) return result.x # Запуск бэктестов strategies = { 'Equal Weight': equal_weight_strategy, 'Minimum Variance': minimum_variance_strategy, 'Maximum Diversification': max_diversification_strategy, 'Mean-Variance': mean_variance_strategy } results = {} for name, func in strategies.items(): values, returns, weights = backtest_strategy(returns_df, func) results[name] = {'values': values, 'returns': returns, 'weights': weights} # Вывод ключевых результатов print("Итоговая стоимость портфеля для каждой стратегии:") for name, res in results.items(): final_value = res['values'][-1] total_return = (final_value / res['values'][0] - 1) * 100 annualized_vol = np.std(res['returns']) * np.sqrt(252) * 100 print(f"{name}: Конечная стоимость = {final_value:.2f}, " f"Общая доходность = {total_return:.2f}%, " f"Годовая волатильность = {annualized_vol:.2f}%") # Построение графика стоимости портфелей plt.figure(figsize=(12,6)) for name, res in results.items(): plt.plot(res['values'], label=name) plt.title("Динамика стоимости портфеля") plt.xlabel("Дни") plt.ylabel("Стоимость портфеля") plt.legend() plt.grid(True) plt.show() Итоговая стоимость портфеля для каждой стратегии: Equal Weight: Конечная стоимость = 114.55, Общая доходность = 14.55%, Годовая волатильность = 9.49% Minimum Variance: Конечная стоимость = 106.77, Общая доходность = 6.77%, Годовая волатильность = 8.17% Maximum Diversification: Конечная стоимость = 108.15, Общая доходность = 8.15%, Годовая волатильность = 8.30% Mean-Variance: Конечная стоимость = 96.95, Общая доходность = -3.05%, Годовая волатильность = 10.80% Рис. 3: Динамика стоимости портфелей по 4-м стратегиям: равновзвешенной (Equal Weight), минимальной дисперсии (Minimum Variance), максимальной диверсификации (Maximum Diversification) и портфеля «среднее-волатильность» (Mean-Variance) за 5 лет торговых дней. График иллюстрирует различия в росте капитала и волатильности между стратегиями Представленный выше код реализует полный цикл бэктестинга с ежемесячной ребалансировкой. Функция `backtest_strategy` вычисляет новые веса на конец каждого месяца, используя скользящее окно исторических данных. Транзакционные издержки учитываются через turnover — сумму абсолютных изменений весов активов. Метрики эффективности Для оценки стратегий используются стандартные показатели доходности с учетом риска (risk-adjusted performance): Sharpe Ratio (коэффициент Шарпа): Коэффициент Шарпа - это отношение избыточной доходности к волатильности. Он показывает доходность на единицу риска и рассчитывается по формуле: SR = (Rₚ - Rf) / σₚ где: Rₚ — средняя доходность портфеля; Rf — безрисковая ставка; σₚ — волатильность доходности портфеля. Maximum Drawdown (максимальная просадка): Метрика показывает максимальное падение стоимости портфеля от пика до дна. То есть худший исторический сценарий для инвестора. Формула расчета: MDD = min((Vₜ - Vₚₑₐₖ) / Vₚₑₐₖ) Calmar Ratio (коэффициент Калмара): Отношение годовой доходности к максимальной просадке. Лучше отражает риск больших потерь, чем Sharpe Ratio. def calculate_metrics(returns, values): """Расчет метрик эффективности портфеля""" # Annualized return total_return = (values[-1] / values[0]) - 1 n_years = len(values) / 252 annual_return = (1 + total_return) ** (1 / n_years) - 1 # Volatility volatility = np.std(returns) * np.sqrt(252) # Sharpe Ratio (risk-free rate = 2%) risk_free_rate = 0.02 sharpe = (annual_return - risk_free_rate) / volatility # Maximum Drawdown cumulative = np.cumprod(1 + returns) running_max = np.maximum.accumulate(cumulative) drawdown = (cumulative - running_max) / running_max max_drawdown = np.min(drawdown) # Calmar Ratio calmar = annual_return / abs(max_drawdown) if max_drawdown != 0 else 0 return { 'Annual Return': annual_return, 'Volatility': volatility, 'Sharpe Ratio': sharpe, 'Max Drawdown': max_drawdown, 'Calmar Ratio': calmar } import matplotlib.pyplot as plt # Расчет метрик для всех стратегий metrics_df = pd.DataFrame() for name, data in results.items(): metrics = calculate_metrics(data['returns'], data['values']) metrics_df[name] = pd.Series(metrics) # Визуализация результатов fig, axes = plt.subplots(2, 2, figsize=(15, 10)) # 1. Динамика стоимости портфелей ax1 = axes[0, 0] for name, data in results.items(): # Убираем начальное значение для согласования длины с датами values_plot = data['values'][1:] dates_plot = returns_df.index[252:252 + len(values_plot)] ax1.plot(dates_plot, values_plot, label=name, linewidth=2) ax1.set_ylabel('Стоимость портфеля') ax1.set_title('Динамика стоимости портфелей') ax1.legend() ax1.grid(alpha=0.3) # 2. Просадки для Maximum Diversification ax2 = axes[0, 1] returns_md = results['Maximum Diversification']['returns'] cumulative_md = np.cumprod(1 + returns_md) running_max_md = np.maximum.accumulate(cumulative_md) drawdown_md = (cumulative_md - running_max_md) / running_max_md dates_dd = returns_df.index[252:252 + len(drawdown_md)] ax2.fill_between(dates_dd, drawdown_md * 100, 0, alpha=0.3, color='red') ax2.plot(dates_dd, drawdown_md * 100, color='darkred', linewidth=1.5) ax2.set_ylabel('Просадка (%)') ax2.set_title('Просадки Maximum Diversification Portfolio') ax2.grid(alpha=0.3) # 3. Сравнение Sharpe и Calmar Ratio ax3 = axes[1, 0] metrics_plot = metrics_df.loc[['Sharpe Ratio', 'Calmar Ratio']] metrics_plot.T.plot(kind='bar', ax=ax3, width=0.7) ax3.set_ylabel('Значение метрики') ax3.set_title('Сравнение risk-adjusted метрик') ax3.set_xticklabels(ax3.get_xticklabels(), rotation=0, ha='right') ax3.legend(title='Метрика') ax3.grid(axis='y', alpha=0.3) # 4. Распределение дневных доходностей ax4 = axes[1, 1] for name, data in results.items(): ax4.hist(data['returns'] * 100, bins=50, alpha=0.5, label=name) ax4.set_xlabel('Дневная доходность (%)') ax4.set_ylabel('Частота') ax4.set_title('Распределение дневных доходностей') ax4.legend() ax4.grid(alpha=0.3) plt.tight_layout() plt.show() # Вывод таблицы метрик print("Сравнительная таблица метрик эффективности:") print(metrics_df.round(4)) Рис. 4: Визуализация разных стратегий диверсификации портфелей. График показывает динамику стоимости портфелей, просадки, распределение дневных доходностей и сравнительные показатели Sharpe и Calmar Ratio для 4-х стратегий. Maximum Diversification Portfolio демонстрирует стабильный рост с умеренными просадками и сбалансированным профилем «риск–доходность» Сравнительная таблица метрик эффективности: Equal Weight Minimum Variance Maximum Diversification Mean-Variance Annual Return 0.0345 0.0165 0.0198 -0.0077 Volatility 0.0949 0.0817 0.0830 0.1080 Sharpe Ratio 0.1527 -0.0429 -0.0030 -0.2565 Max Drawdown -0.1859 -0.2125 -0.2052 -0.2896 Calmar Ratio 0.1856 0.0776 0.0963 -0.0266 Результаты бэктестинга показывают заметные различия между стратегиями. Maximum Diversification Portfolio обычно превосходит равновзвешенный портфель по Sharpe Ratio благодаря систематическому распределению капитала в пользу слабо коррелированных активов. По сравнению с Minimum Variance портфелем, она обеспечивает более высокую доходность при умеренно большей волатильности. Mean-Variance портфель сильно зависит от качества прогнозов доходности. На коротких исторических окнах оценки ожидаемой доходности содержат шум, что ведет к нестабильным весам и низкой эффективности на тестовых данных. Maximum Diversification Portfolio этого недостатка избегает, так как использует только вторые моменты распределения доходностей. Практические аспекты применения Внедрение Maximum Diversification Portfolio в торговую систему требует решения операционных вопросов: выбора частоты ребалансировки, управления транзакционными издержками и оценки робастности стратегии к изменениям параметров. Ребалансировка портфеля Частота пересчета весов влияет на баланс между адаптивностью к изменениям корреляций и транзакционными издержками. Ежемесячная ребалансировка обычно обеспечивает оптимальный компромисс для ликвидных активов. Недельная пересборка портфеля повышает оборот капитала в 4–5 раз при незначительном улучшении эффективности. Транзакционные издержки включают: спреды bid-ask (2–10 б.п. для ликвидных акций и 20–50 б.п. для менее ликвидных), влияние на рынок (market impact), которое растет с размером сделки, и брокерские комиссии, обычно незначительные для институциональных инвесторов. Оценка оптимальной частоты ребалансировки сравнивает прирост Sharpe Ratio от более частого пересчета с затратами на оборот. Например, если месячная ребалансировка дает SR = 0.85, а недельная — 0.87, но годовой оборот увеличивается с 150% до 600%, дополнительные издержки 4.5% перевешивают небольшой выигрыш в SR. Альтернатива календарной ребалансировке — метод Threshold-based rebalance: пересчет весов выполняется только при отклонении текущих весов от целевых более чем на 5%. Этот подход снижает оборачиваемость активов в портфеле в спокойные периоды и ускоряет реакцию портфеля во время рыночных стрессов. Робастность к выбору исторического окна Длина периода оценки (lookback period) влияет на баланс между точностью статистических оценок и адаптивностью к изменениям рыночного режима: Короткие окна (63–126 дней) быстро реагируют на недавние шоки, но дают более шумные оценки; Длинные окна (504–756 дней) сглаживают краткосрочные колебания корреляций, но хуже реагируют на новые рыночные условия. Тестирование стратегии на разных lookback period позволяет оценить стабильность метода и чувствительность стратегий к выбору окна. Рис. 5: Влияние lookback period на коэффициент Шарпа и волатильность. Стабильность показателей при изменении lookback period показывает робастность стратегии к выбору параметров. Период в 252 торговых дня обеспечивает оптимальный баланс между адаптивностью к изменениям рынка и стабильностью оценок Результаты показывают умеренную чувствительность стратегии к длине периода оценки. Различия в Sharpe Ratio между 252 и 504 днями обычно не превышают 0.05–0.10, что меньше статистической погрешности оценки. Период в 126 дней повышает волатильность весов при ребалансировке и увеличивает оборачиваемость портфеля на 30–50%. Практическая рекомендация: использовать lookback 252 дня для портфелей из ликвидных активов при нормальном торговом режиме. В периоды структурных изменений рынка, например кризисов или смены монетарной политики, временное сокращение окна до 126 дней повышает адаптивность стратегии. Ограничения метода Чувствительность к ошибкам оценки корреляций: шум в выборочных корреляциях, особенно при коротких исторических рядах, может завышать веса некоторых активов; Рост числа параметров: для n(n+1)/2 параметров. При небольшом lookback соотношение наблюдений к параметрам низкое, что снижает точность оценок; Методы снижения чувствительности: сжатие (shrinkage) к структурированной целевой матрице (constant correlation, factor model), регуляризация весов (максимальный вес актива, секторная концентрация), факторные модели, робастные оценки (Ledoit-Wolf, Minimum Covariance Determinant); Игнорирование ожидаемой доходности: стратегия может давать высокие веса низкодоходным активам, если они обеспечивают декорреляцию. При систематической недооценке риска отдельных классов активов возможна завышенная экспозиция; Комбинирование с прогнозами доходности: можно включать оба критерия (диверсификация и доходность) с весовыми коэффициентами. Фреймворк Блэка-Литтермана (Black-Litterman) позволяет учитывать экспертные оценки доходности. Заключение Портфель максимальной диверсификации представляет собой прагматичный подход к управлению капиталом, полностью независимый от прогнозов доходности. Метод опирается на корреляционную структуру рынка и перераспределяет капитал в пользу слабо коррелированных активов. Эмпирические исследования подтверждают устойчивую связь между высоким уровнем диверсификационного коэффициента и последующей доходностью с учетом риска, особенно выраженной в периоды рыночного стресса. Практическая ценность метода проявляется в его устойчивости к ошибкам оценки параметров по сравнению с классическим mean-variance подходом. Отказ от прогнозирования ожидаемой доходности снижает риск построения портфеля на основе зашумленных данных. Регулярная ребалансировка поддерживает оптимальную корреляционную экспозицию, адаптируя портфель к изменениям рыночных условий. Метод применим как самостоятельная стратегия в институциональном управлении активами и как компонент мультистратегических систем, где диверсификационный критерий дополняет другие сигналы формирования портфеля. ### Портфель минимальной волатильности (Minimum Variance Portfolio) Портфель минимальной волатильности (Minimum Variance Portfolio, MVP) — метод построения инвестиционного портфеля, где целевая функция оптимизации направлена исключительно на минимизацию риска, без учета ожидаемой доходности активов. Подход решает задачу поиска такой комбинации весов активов, при которой волатильность портфеля достигает минимального значения при заданных ограничениях. Классическая теория портфеля Марковица требует оценки двух параметров: ожидаемой доходности и ковариационной матрицы. MVP устраняет необходимость прогнозирования доходностей, используя только ковариационную матрицу. Это ключевое упрощение, поскольку ошибки в оценке ожидаемой доходности значительно превышают ошибки в оценке ковариаций. Результат — более стабильные веса активов и устойчивость к переобучению. Эмпирические исследования демонстрируют аномалию низкой волатильности: портфели с минимальным риском показывают доходность, сопоставимую или превышающую рыночную, при существенно меньшей волатильности. Эффект объясняется систематическими искажениями в ценообразовании активов: инвесторы переплачивают за высокорисковые инструменты из-за ограничений на использование кредитного плеча и поведенческих факторов. Теоретические основы минимизации волатильности Построение портфеля минимальной волатильности базируется на квадратичной оптимизации с линейными ограничениями. Математическая формализация задачи определяет структуру решения и накладывает требования на исходные данные. Математический базис MVP Целевая функция оптимизации представляет квадратичную форму весов активов и ковариационной матрицы. Ее формула: σ²(w) = w^T Σ w → min где: w — вектор весов активов в портфеле (размерность n×1); Σ — ковариационная матрица доходностей (размерность n×n); σ²(w) — дисперсия доходности портфеля. Ковариационная матрица содержит дисперсии активов на главной диагонали и ковариации между парами активов в недиагональных элементах. Матрица должна быть положительно определенной для существования единственного решения. На практике при малом числе наблюдений или высокой корреляции активов матрица становится вырожденной или близкой к вырожденной, что требует регуляризации. Стандартные ограничения задачи: ∑ wᵢ = 1 (полное инвестирование капитала) wᵢ ≥ 0 (запрет коротких позиций) 0 ≤ wᵢ ≤ wₘₐₓ (ограничение концентрации) где: wᵢ — вес i-го актива; wₘₐₓ — максимально допустимый вес одного актива. Дополнительные ограничения могут включать секторальные лимиты, требования к ликвидности или минимальный порог веса для сокращения транзакционных издержек. Отличия от портфеля Марковица Классический подход Марковица максимизирует отношение избыточной доходности к риску, что требует оценки вектора ожидаемых доходностей μ. Целевая функция в этом случае: (μ^T w - rբ) / √(w^T Σ w) → max где: μ — вектор ожидаемых доходностей активов; rբ — безрисковая ставка. Портфель минимальной волатильности исключает ожидаемую доходность (μ) из процесса оптимизации. Это убирает главный источник ошибок: стандартное отклонение оценки доходности актива пропорционально 1/√T, где T — число наблюдений. Для обычного горизонта в 252 торговых дня ошибка составляет около 6% в год. Ковариационная матрица оценивается точнее, с погрешностью порядка 1/T. В результате веса портфеля минимальной волатильности (MVP) меняются медленнее при обновлении данных. Результаты исторических тестов показывают, что оборот такого портфеля в 2–3 раза ниже, чем у портфеля с максимальным коэффициентом Шарпа (Sharpe ratio). Это важно для стратегий с частой ребалансировкой, где комиссии могут съедать значительную часть доходности. Рис. 1: Структура ковариационной матрицы и индивидуальные волатильности активов. Корреляции определяют диверсификационный потенциал портфеля Методы построения портфеля минимальной волатильности Существует 2 основных подхода к решению задачи оптимизации: аналитический и численный. Выбор метода зависит от наличия ограничений на веса активов и вычислительных требований. Аналитическое решение Если в задаче нет ограничений на знак весов (допускаются короткие позиции по торговле активом), решение можно записать в явном виде. Используя метод Лагранжа для минимизации дисперсии портфеля (σ²(w)) при условии, что сумма весов равна 1 (w^T 1 = 1), получаем формулу для оптимальных весов: w* = (Σ⁻¹ 1) / (1^T Σ⁻¹ 1) где: Σ⁻¹ — обратная ковариационная матрица; 1 — вектор из единиц (размерность n×1); 1^T Σ⁻¹ 1 — скаляр нормировки. Вектор весов пропорционален сумме строк обратной ковариационной матрицы и нормируется так, чтобы их сумма была равна 1. Активы с низкой дисперсией и слабой корреляцией с другими активами получают больший вес. И решение в данном случае одно и уникально, если ковариационная матрица Σ не вырождена. Аналитическое решение работает быстро для портфелей с 100–200 активами. Основная вычислительная сложность связана с обращением матрицы и составляет O(n³). Для больших портфелей эффективнее использовать численные методы с разреженными матрицами. Ограничение этого подхода — допущение отрицательных весов. На реальных рынках короткие позиции требуют маржинального обеспечения, могут быть ограничены по доступности бумаг для заимствования и создают дополнительные издержки. Если разрешены только длинные позиции (long-only), приходится использовать численные методы оптимизации. Численная оптимизация с ограничениями Метод последовательных наименьших квадратов с ограничениями (Sequential Least Squares Programming, SLSQP) решает задачу квадратичного программирования с линейными и нелинейными ограничениями. Алгоритм работает итеративно: в окрестности текущей точки целевая функция аппроксимируется квадратичной моделью, а ограничения — линейными функциями. Для портфеля с длинными позициями (long-only) типичная постановка выглядит так: Minimize: w^T Σ w При условиях: ∑ wᵢ = 1; wᵢ ≥ 0; wᵢ ≤ 0.3. Ограничение сверху (0.3) предотвращает чрезмерную концентрацию в одном активе. Порог 30% — стандартная практика в институциональном управлении, которая помогает сохранять диверсификацию и одновременно формировать значимые позиции. Для работы с вырожденными ковариационными матрицами нужна регуляризация. Основные методы: 1. Сжатие по Ледуа и Вольфу (Ledoit-Wolf): Σ̃ = δF + (1-δ)Σ где: F — целевая матрица (обычно диагональная или с равной корреляцией); δ — коэффициент сжатия, который оценивается по данным. Метод минимизирует среднеквадратичную ошибку оценки ковариации. 2. Добавление регуляризационного члена к целевой функции: σ²(w) + λ||w||₂² где λ — параметр регуляризации. Такой подход аналогичен гребневой регрессии (ridge regression) и штрафует слишком большие веса. Практическая реализация на Python Построение портфеля минимальной волатильности включает загрузку данных, расчет ковариационной матрицы и численную оптимизацию. Код лучше организовать модульно для повторного использования компонентов. Подготовка данных Для анализа используем исторические цены активов. Доходности считаем как логарифмические изменения цен. Такой подход делает доходности аддитивными во времени и более симметричными. import yfinance as yf import numpy as np import pandas as pd # Тикеры портфеля из 5 секторальных ETF tickers = ['XLF', 'XLE', 'XLK', 'XLV', 'XLI'] # Финансы, Энергетика, Технологии, Здравоохранение, Промышленность # Загрузка скорректированных цен закрытия data = yf.download(tickers, start='2022-11-01', end='2025-11-01', progress=False, auto_adjust=True) # Если вернулся DataFrame с мультииндексом колонок, выбираем только цены закрытия if isinstance(data.columns, pd.MultiIndex): data = data['Close'] # Расчет логарифмических доходностей returns = np.log(data / data.shift(1)).dropna() # Ковариационная матрица (годовая, 252 торговых дня) cov_matrix_annual = returns.cov() * 252 print(f"Период данных: {returns.index[0].date()} - {returns.index[-1].date()}") print(f"Число наблюдений: {len(returns)}") print(f"\nГодовые волатильности активов (%):") for ticker in tickers: vol = np.sqrt(cov_matrix_annual.loc[ticker, ticker]) * 100 print(f"{ticker}: {vol:.2f}%") Период данных: 2022-11-02 - 2025-10-31 Число наблюдений: 752 Годовые волатильности активов (%): XLF: 17.03% XLE: 22.74% XLK: 23.84% XLV: 13.61% XLI: 16.19% Ковариационная матрица оценивается по выборочным ковариациям и аннуализируется для года. Для дневных данных используем множитель 252 торговых дней. Такая оценка предполагает, что доходности стабильны на исторической выборке. Это допущение может нарушаться в периоды крупных изменений на рынке. Оптимизация портфеля Функция оптимизации принимает ковариационную матрицу и ограничения, возвращает оптимальные веса и характеристики портфеля. from scipy.optimize import minimize def optimize_minimum_variance(cov_matrix, max_weight=0.5): """ Оптимизация портфеля минимальной волатильности Parameters: ----------- cov_matrix : pd.DataFrame Ковариационная матрица доходностей max_weight : float Максимальный вес одного актива Returns: -------- dict : оптимальные веса и метрики портфеля """ n_assets = len(cov_matrix) # Целевая функция: волатильность портфеля def portfolio_volatility(weights, cov_matrix): return np.sqrt(weights @ cov_matrix @ weights) # Ограничения constraints = [ {'type': 'eq', 'fun': lambda w: np.sum(w) - 1} # сумма весов = 1 ] # Границы весов bounds = tuple((0, max_weight) for _ in range(n_assets)) # Начальное приближение: равные веса initial_weights = np.array([1/n_assets] * n_assets) # Оптимизация result = minimize( portfolio_volatility, initial_weights, args=(cov_matrix,), method='SLSQP', bounds=bounds, constraints=constraints, options={'ftol': 1e-9, 'maxiter': 1000} ) if not result.success: raise ValueError(f"Оптимизация не сошлась: {result.message}") optimal_weights = result.x portfolio_vol = result.fun return { 'weights': pd.Series(optimal_weights, index=cov_matrix.index), 'volatility': portfolio_vol, 'success': result.success } # Оптимизация MVP mvp_result = optimize_minimum_variance(cov_matrix_annual, max_weight=0.5) print("\nОптимальные веса портфеля минимальной волатильности:") for ticker, weight in mvp_result['weights'].items(): print(f"{ticker}: {weight*100:.2f}%") print(f"\nВолатильность портфеля: {mvp_result['volatility']*100:.2f}%") Оптимальные веса портфеля минимальной волатильности: XLE: 8.83% XLF: 11.57% XLI: 29.61% XLK: 0.00% XLV: 50.00% Волатильность портфеля: 13.10% Функция использует метод SLSQP с высокой точностью сходимости (ftol=1e-9). Начальное приближение — равновзвешенный портфель, что ускоряет сходимость при умеренной корреляции активов. Для портфелей с сильными кластерами корреляций эффективнее инициализировать веса результатом иерархической кластеризации. Параметр max_weight=0.5 ограничивает концентрацию. Без него оптимизатор часто назначает 60–80% портфеля активу с минимальной волатильностью, что увеличивает специфический риск и снижает диверсификацию. # Визуализация структуры портфеля fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) # Веса активов weights_pct = mvp_result['weights'] * 100 colors = plt.cm.Set3(range(len(weights_pct))) bars = ax1.barh(range(len(weights_pct)), weights_pct, color=colors, alpha=0.8) ax1.set_yticks(range(len(weights_pct))) ax1.set_yticklabels(weights_pct.index) ax1.set_xlabel('Weight (%)', fontsize=10) ax1.set_title('Minimum Variance Portfolio Weights', fontsize=11, pad=10) ax1.grid(axis='x', alpha=0.3) # Добавление значений на график for i, (idx, val) in enumerate(weights_pct.items()): ax1.text(val + 1, i, f'{val:.1f}%', va='center', fontsize=9) # Сравнение волатильностей individual_vols = np.sqrt(np.diag(cov_matrix_annual)) * 100 portfolio_vol = mvp_result['volatility'] * 100 x_pos = np.arange(len(tickers) + 1) vols_to_plot = list(individual_vols) + [portfolio_vol] labels_plot = tickers + ['MVP'] colors_bars = ['steelblue'] * len(tickers) + ['darkgreen'] bars = ax2.bar(x_pos, vols_to_plot, color=colors_bars, alpha=0.7) ax2.set_xticks(x_pos) ax2.set_xticklabels(labels_plot, rotation=45, ha='right') ax2.set_ylabel('Volatility (%)', fontsize=10) ax2.set_title('Individual vs Portfolio Volatility', fontsize=11, pad=10) ax2.grid(axis='y', alpha=0.3) # Линия для обозначения эффекта диверсификации ax2.axhline(y=portfolio_vol, color='red', linestyle='--', linewidth=1.5, alpha=0.7, label='Portfolio Vol') ax2.legend(fontsize=9) plt.tight_layout() plt.show() Рис. 2: Структура портфеля минимальной волатильности и сравнение с индивидуальными волатильностями активов. Диверсификация снижает риск ниже минимального риска отдельных компонентов Сравнение с равновесным портфелем Равновзвешенный портфель (1/N) — наивная стратегия, присваивающая каждому активу одинаковый вес. Исследования показывают, что 1/N часто превосходит оптимизированные портфели на коротких горизонтах из-за ошибок оценки параметров. MVP должен демонстрировать преимущество на достаточно длинных периодах. # Равновзвешенный портфель equal_weights = np.array([1/len(tickers)] * len(tickers)) equal_portfolio_vol = np.sqrt(equal_weights @ cov_matrix_annual @ equal_weights) # Расчет исторических доходностей портфелей mvp_weights_array = mvp_result['weights'].values mvp_returns = (returns @ mvp_weights_array) equal_returns = (returns @ equal_weights) # Кумулятивная доходность mvp_cumulative = (1 + mvp_returns).cumprod() equal_cumulative = (1 + equal_returns).cumprod() # Метрики def calculate_metrics(returns_series): total_return = (1 + returns_series).prod() - 1 annual_return = (1 + total_return) ** (252 / len(returns_series)) - 1 annual_vol = returns_series.std() * np.sqrt(252) sharpe = annual_return / annual_vol cumulative = (1 + returns_series).cumprod() running_max = cumulative.expanding().max() drawdown = (cumulative - running_max) / running_max max_drawdown = drawdown.min() return { 'Annual Return': annual_return, 'Annual Volatility': annual_vol, 'Sharpe Ratio': sharpe, 'Max Drawdown': max_drawdown } mvp_metrics = calculate_metrics(mvp_returns) equal_metrics = calculate_metrics(equal_returns) print("\nСравнение портфелей:") print("-" * 60) print(f"{'Метрика':<25} {'MVP':>15} {'1/N':>15}") print("-" * 60) for key in mvp_metrics.keys(): mvp_val = mvp_metrics[key] equal_val = equal_metrics[key] if 'Return' in key or 'Volatility' in key or 'Drawdown' in key: print(f"{key:<25} {mvp_val:>14.2%} {equal_val:>14.2%}") else: print(f"{key:<25} {mvp_val:>14.2f} {equal_val:>14.2f}") print("-" * 60) # Визуализация кумулятивной доходности fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8)) # Кумулятивная доходность ax1.plot(mvp_cumulative.index, (mvp_cumulative - 1) * 100, label='Minimum Variance', linewidth=2, color='darkgreen') ax1.plot(equal_cumulative.index, (equal_cumulative - 1) * 100, label='Equal Weight', linewidth=2, color='steelblue', alpha=0.7) ax1.set_ylabel('Cumulative Return (%)', fontsize=10) ax1.set_title('Portfolio Performance Comparison', fontsize=11, pad=10) ax1.legend(fontsize=10) ax1.grid(True, alpha=0.3) # Просадки mvp_cum = (1 + mvp_returns).cumprod() equal_cum = (1 + equal_returns).cumprod() mvp_running_max = mvp_cum.expanding().max() equal_running_max = equal_cum.expanding().max() mvp_dd = (mvp_cum - mvp_running_max) / mvp_running_max * 100 equal_dd = (equal_cum - equal_running_max) / equal_running_max * 100 ax2.fill_between(mvp_dd.index, mvp_dd, 0, alpha=0.5, color='darkgreen', label='MVP Drawdown') ax2.fill_between(equal_dd.index, equal_dd, 0, alpha=0.5, color='steelblue', label='1/N Drawdown') ax2.set_xlabel('Date', fontsize=10) ax2.set_ylabel('Drawdown (%)', fontsize=10) ax2.set_title('Portfolio Drawdowns', fontsize=11, pad=10) ax2.legend(fontsize=10) ax2.grid(True, alpha=0.3) plt.tight_layout() plt.show() Сравнение портфелей: ------------------------------------------------------------ Метрика MVP 1/N ------------------------------------------------------------ Annual Return 9.24% 13.97% Annual Volatility 13.10% 14.88% Sharpe Ratio 0.71 0.94 Max Drawdown -13.96% -17.30% ------------------------------------------------------------ Рис. 3: Сравнение производительности портфелей минимальной волатильности (Minimum Variance, MVP) и равновзвешенного (Equal Weight, 1/N). Верхняя часть — кумулятивная доходность, нижняя — просадки портфелей В представленном выше примере метрики рассчитываются на полном историческом периоде. Годовая доходность (Annual Return) аннуализируется геометрически. Коэффициент Шарпа (Sharpe Ratio) не учитывает безрисковую ставку для упрощения сравнения. Максимальная просадка (Max Drawdown) показывает наибольшее падение от пика и важна для оценки рисков редких, сильных потерь. Портфель минимальной волатильности (Minimum Volatility Portfolio, MVP) изначально устроен так, чтобы колебаться меньше рынка. Его доходность зависит от того, проявилась ли премия за риск низковолатильных активов. Когда рынок растет, активы с высокой волатильностью обычно приносят больший доход и обгоняют такую стратегию. Зато на падающем или стабильном рынке портфель минимальной волатильности часто показывает лучшие результаты. Проблемы и решения Практическое применение MVP сталкивается с тремя основными проблемами: Ошибки оценки ковариационной матрицы; Концентрация весов в небольшом числе активов; Высокая чувствительность к изменениям данных. Давайте рассмотрим их подробнее. Ошибки оценки ковариационной матрицы Выборочная ковариационная матрица — несмещенная, но высоковариативная оценка истинной матрицы. При n активах и T наблюдениях, когда n/T > 0.1, собственные значения матрицы систематически искажены: максимальное собственное значение завышено, минимальное — занижено. Сжатие методом Ledoit-Wolf оценивает оптимальный коэффициент δ минимизацией ожидаемой квадратичной ошибки: δ* = argmin E[||δF + (1-δ)Σ - Σtrue||²] Метод аналитически вычисляет δ* из данных без кросс-валидации. Для месячных данных типичные значения δ = 0.2-0.4, для дневных данных δ = 0.05-0.15. Альтернатива — экспоненциальное взвешивание наблюдений. Недавние данные получают больший вес: Σ = ∑ λⁱ⁻¹ (rᵢ - μ)(rᵢ - μ)ᵀ / ∑ λⁱ⁻¹ где λ — параметр распада (обычно 0.94-0.97 для дневных данных). Подход адаптируется к изменениям режима волатильности быстрее, чем равновзвешенная оценка. Концентрация весов и нестабильность Без ограничений оптимизатор часто присваивает большие веса активам с низкой оценочной волатильностью. Ошибки в оценке усиливают концентрацию: если истинная волатильность актива 20%, а оценочная 18%, его вес может увеличиться в 2–3 раза. Практические ограничения концентрации: Максимальный вес 20–40% для небольших портфелей (5–10 активов); Максимальный вес 10–20% для крупных портфелей (20+ активов); Минимальный вес 2–5% для снижения оборота, веса ниже порога обнуляются. Нестабильность весов проявляется при ребалансировке: малые изменения ковариаций могут перераспределять 10–15% капитала между активами. Решения: Пороговая ребалансировка: обновляем портфель только если отклонение текущих весов от целевых превышает 5–10%. Включение транзакционных издержек в целевую функцию через штраф за изменение весов. Эмпирические результаты Исследования на данных развитых рынков показывают, что портфель минимальной волатильности (MVP) стабильно превосходит капитализационно-взвешенные индексы по соотношению риск–доходность. Например, исследования Clarke, de Silva и Thorley (2006) показали, что на акциях S&P 500 за 1968–2005 годы MVP имел коэффициент Шарпа (Sharpe ratio) 0.86 против 0.39 у рыночного индекса, при волатильности 12% против 15%. Аномалия низкой волатильности объясняется факторной экспозицией MVP: портфель имеет отрицательную бету к рынку (0.6–0.8) и положительную экспозицию к факторам size и value. Премия за низкую волатильность оценивается в 3–5% годовых после учета факторов Fama-French. Эффективность MVP зависит от рыночного режима: В периоды высокой волатильности (VIX > 25) преимущество усиливается — доходность с учетом риска превышает рыночную в 1.5–2 раза; На растущих рынках с низкой волатильностью (VIX < 15) абсолютная доходность может отставать на 5–7% годовых, но Sharpe ratio остается выше за счет меньшего риска. Просадки MVP на 30–40% меньше рыночных в кризисные периоды (2008, 2020). Максимальная просадка за 2000–2024 годы составила около 25% против 55% для S&P 500. Время восстановления после просадок также значительно короче — 12–18 месяцев против 3–5 лет для широкого рынка. Заключение Портфель минимальной волатильности решает ключевую проблему классической оптимизации Марковица — высокую чувствительность к ошибкам в оценке ожидаемой доходности. Исключение прогнозных параметров из целевой функции делает веса активов более стабильными и снижает риск переобучения. Эмпирические данные подтверждают устойчивую премию за низкую волатильность на горизонтах от 3 лет, что делает метод практичным для консервативных инвесторов. Практическая реализация требует внимания к деталям: Регуляризация ковариационной матрицы методами Ledoit-Wolf или экспоненциального взвешивания помогает избежать вырожденности; Ограничения концентрации и пороговая ребалансировка контролируют транзакционные издержки, которые без этих мер могут полностью нивелировать теоретическое преимущество стратегии. В результате MVP предлагает надежный подход к построению портфеля, обеспечивая привлекательную доходность с учетом риска при минимальных предположениях о будущем поведении рынка. ### Случайный лес (Random Forest): механика алгоритма, Бутстрэп-агрегирование, out-of-bag оценка Random Forest или Случайный лес — это ансамблевый алгоритм машинного обучения, объединяющий множество деревьев решений для повышения точности и устойчивости предсказаний. Алгоритм был предложен Лео Брейманом в 2001 году и с тех пор стал одним из наиболее используемых методов в задачах классификации и регрессии. Основное преимущество Random Forest — способность снижать дисперсию модели без существенного увеличения смещения. Одиночное дерево решений склонно к переобучению: оно запоминает шум в обучающих данных и плохо обобщает на новые примеры. Ансамбль деревьев, обученных на разных подвыборках данных с разными подмножествами признаков, усредняет индивидуальные ошибки и дает более стабильный результат. В финансовом и количественном анализе Случайный лес применяется для прогнозирования направления движения цен, оценки вероятности дефолта, скоринга и ранжирования активов. Алгоритм устойчив к выбросам, работает с разнородными признаками без нормализации и предоставляет встроенную оценку важности переменных. Архитектура Random Forest Ансамбль Random Forest состоит из независимо обученных деревьев решений, каждое из которых голосует за итоговое предсказание. В задачах классификации итоговый класс определяется большинством голосов, в регрессии — усреднением предсказаний всех деревьев. Независимость деревьев достигается за счет двух механизмов рандомизации: бутстрэп-выборки наблюдений и случайного отбора признаков при каждом разбиении. Базовые деревья решений Дерево решений — древовидная структура, где каждый внутренний узел содержит условие разбиения по одному из признаков, а листья хранят итоговые предсказания. Построение дерева происходит рекурсивно: алгоритм выбирает признак и порог, минимизирующие выбранный критерий неопределенности, и разделяет данные на две части. Для классификации стандартные критерии разбиения: Gini impurity — измеряет вероятность неправильной классификации случайно выбранного элемента; Entropy (Information Gain) — оценивает снижение энтропии после разбиения; Log Loss — используется для вероятностных предсказаний. Для регрессии применяются MSE (Mean Squared Error) и MAE (Mean Absolute Error). MSE штрафует большие отклонения сильнее, MAE более устойчив к выбросам. В модели машинного обучения Random Forest деревья обычно выращиваются до максимальной глубины без ранней остановки. Переобучение отдельных деревьев компенсируется усреднением по ансамблю. Это отличает алгоритм Случайный лес от одиночных деревьев, где глубина и минимальное количество примеров в листе требуют тщательной настройки. Формирование ансамбля Ансамбль формируется из N независимых деревьев (типичные значения — от 100 до 1000). Каждое дерево обучается на своей бутстрэп-выборке и использует случайное подмножество признаков при каждом разбиении. Эти два источника случайности обеспечивают декорреляцию деревьев — ключевое условие эффективности ансамбля. Итоговое предсказание для классификации получается по формуле: ŷ = mode(h₁(x), h₂(x), ..., hₙ(x)) где: ŷ — предсказанный класс; hᵢ(x) — предсказание i-го дерева для входа x; mode — наиболее частый класс среди всех деревьев; n — количество деревьев в ансамбле. Для регрессии используется среднее арифметическое: ŷ = (1/n) × Σhᵢ(x) Дисперсия ансамбля снижается пропорционально количеству деревьев при условии их независимости. На практике деревья коррелированы (обучаются на пересекающихся данных), поэтому снижение дисперсии имеет предел. После определенного числа деревьев (обычно 200-500) качество выходит на плато. import numpy as np import matplotlib.pyplot as plt from sklearn.ensemble import RandomForestClassifier from sklearn.datasets import make_classification from sklearn.model_selection import cross_val_score # Генерация синтетических данных X, y = make_classification( n_samples=1000, n_features=20, n_informative=10, n_redundant=5, random_state=42 ) # Исследование влияния количества деревьев на качество n_estimators_range = [10, 25, 50, 100, 200, 300, 500, 750, 1000] mean_scores = [] std_scores = [] for n_est in n_estimators_range: rf = RandomForestClassifier(n_estimators=n_est, random_state=42, n_jobs=-1) scores = cross_val_score(rf, X, y, cv=5, scoring='accuracy') mean_scores.append(scores.mean()) std_scores.append(scores.std()) # Визуализация fig, ax = plt.subplots(figsize=(10, 6)) ax.errorbar(n_estimators_range, mean_scores, yerr=std_scores, fmt='o-', color='#1a1a1a', capsize=4, capthick=1.5, markersize=6, linewidth=1.5) ax.axhline(y=max(mean_scores), color='#666666', linestyle='--', linewidth=1, alpha=0.7) ax.set_xlabel('Количество деревьев', fontsize=11) ax.set_ylabel('Accuracy (CV)', fontsize=11) ax.set_title('Зависимость качества от числа деревьев в ансамбле', fontsize=12) ax.grid(True, alpha=0.3) ax.set_xlim(0, 1050) plt.tight_layout() plt.show() print(f"Лучший результат: {max(mean_scores):.4f} при {n_estimators_range[np.argmax(mean_scores)]} деревьях") Лучший результат: 0.9360 при 50 деревьях Рис. 1: Зависимость верности (accuracy) от количества деревьев в Random Forest. График демонстрирует типичную картину: резкий рост качества при увеличении числа деревьев от 10 до 100, затем выход на плато. Вертикальные отрезки показывают стандартное отклонение по фолдам кросс-валидации — с ростом ансамбля оно снижается, что указывает на стабилизацию модели Бутстрэп-агрегирование (Бэггинг, Bagging) Bagging (Bootstrap Aggregating) — метод построения ансамбля, где каждая базовая модель обучается на бутстрэп-выборке из исходного датасета. Бутстрэп-выборка формируется случайным отбором n наблюдений с возвращением из исходных n наблюдений. В результате часть примеров попадает в выборку несколько раз, часть не попадает вовсе. Модель Случайного леса расширяет классический бэггинг добавлением случайного отбора признаков при каждом разбиении узла. Это дополнительно декоррелирует деревья и повышает эффективность ансамбля. Механизм формирования выборок При формировании бутстрэп-выборки размером n из исходного датасета размером n вероятность того, что конкретное наблюдение не попадет в выборку: P(не выбрано) = (1 - 1/n)ⁿ где: n — размер исходного датасета; 1/n — вероятность выбора конкретного наблюдения на одном шаге. При n → ∞ эта вероятность стремится к 1/e ≈ 0.368. На практике около 36.8% наблюдений не попадают в каждую конкретную бутстрэп-выборку. Эти наблюдения называются out-of-bag (OOB) и используются для оценки качества модели без отдельного валидационного множества. Бутстрэп обеспечивает разнообразие обучающих множеств при ограниченном объеме данных. Каждое дерево видит немного разную версию датасета, что приводит к разным структурам деревьев и разным границам решений. Усреднение по таким деревьям сглаживает индивидуальные ошибки. Рис. 2: Свойства бутстрэп-выборки. Левая панель показывает распределение доли OOB-наблюдений при n=1000: эмпирическое среднее близко к теоретическому значению 1/e ≈ 0.368. Правая панель демонстрирует сходимость: с ростом размера выборки средняя доля OOB приближается к теоретическому пределу Случайный отбор признаков В классическом бэггинге каждое дерево рассматривает все признаки при выборе разбиения. Если в данных есть сильный предиктор, большинство деревьев будут использовать его в корневом узле, что приведет к высокой корреляции между деревьями и снизит эффект усреднения. Метод случайного леса элегантно решает эту проблему: при каждом разбиении узла алгоритм случайно выбирает подмножество из m признаков и ищет лучшее разбиение только среди них. Рекомендуемые значения m: Классификация: m = √p (корень из общего числа признаков); Регрессия: m = p/3 (треть от общего числа признаков). Меньшее значение m увеличивает случайность и снижает корреляцию между деревьями, однако может пропустить информативные признаки в конкретном узле. Большее m приближает поведение к обычному бэггингу. Параметр max_features в scikit-learn контролирует это значение. Комбинация бутстрэп-выборок и случайного отбора признаков обеспечивает достаточную декорреляцию деревьев для эффективного снижения дисперсии. При p признаках и m = √p вероятность того, что два дерева выберут один и тот же признак для разбиения корня, составляет примерно m/p = 1/√p — существенно меньше единицы при большом числе признаков. Out-of-Bag оценка Out-of-Bag (OOB) оценка — встроенный механизм валидации Random Forest, использующий наблюдения, не попавшие в бутстрэп-выборку каждого дерева. Для каждого наблюдения предсказание формируется только теми деревьями, которые не видели это наблюдение при обучении. Это дает несмещенную оценку ошибки обобщения без необходимости выделять отдельное валидационное множество. Принцип работы OOB Алгоритм OOB-оценки: Для каждого наблюдения xᵢ определить множество деревьев Tᵢ, в чьи бутстрэп-выборки оно не попало; Получить предсказания от деревьев из Tᵢ; Агрегировать предсказания (голосование для классификации, среднее для регрессии); Вычислить метрику качества по всем OOB-предсказаниям. В среднем каждое наблюдение попадает в OOB примерно для 36.8% деревьев. При 500 деревьях это около 184 деревьев на наблюдение — достаточно для стабильной агрегации. Out-Of-Bag-оценка асимптотически эквивалентна leave-one-out кросс-валидации, но вычисляется без дополнительных затрат: все необходимые предсказания получаются в процессе обучения. Это делает OOB практичным инструментом для быстрой оценки качества и подбора гиперпараметров. Ограничение OOB: при малом количестве деревьев (<50) оценка может быть нестабильной из-за недостаточного числа деревьев для каждого наблюдения. Рекомендуется использовать OOB при n_estimators >= 100. Практическое применение OOB Out-Of-Bag-оценка полезна в нескольких сценариях: Быстрая валидация без разбиения данных — особенно ценно при ограниченном объеме выборки; Мониторинг переобучения при увеличении сложности модели; Предварительный отбор гиперпараметров перед финальной кросс-валидацией; Оценка важности признаков через permutation importance на OOB-данных. В scikit-learn OOB включается параметром oob_score=True. После обучения доступны атрибуты oob_score_ (метрика качества) и oob_decision_function_ (вероятности классов для классификации) или oob_prediction_ (предсказания для регрессии). import numpy as np import matplotlib.pyplot as plt from sklearn.ensemble import RandomForestClassifier from sklearn.datasets import make_classification from sklearn.model_selection import cross_val_score # Генерация данных X, y = make_classification( n_samples=2000, n_features=30, n_informative=15, n_redundant=5, n_classes=2, random_state=42 ) # Сравнение OOB и кросс-валидации при разном числе деревьев n_estimators_range = [50, 100, 150, 200, 300, 500] oob_scores = [] cv_scores_mean = [] cv_scores_std = [] for n_est in n_estimators_range: rf = RandomForestClassifier( n_estimators=n_est, oob_score=True, random_state=42, n_jobs=-1 ) rf.fit(X, y) oob_scores.append(rf.oob_score_) cv_scores = cross_val_score(rf, X, y, cv=5, scoring='accuracy') cv_scores_mean.append(cv_scores.mean()) cv_scores_std.append(cv_scores.std()) # Визуализация сравнения fig, ax = plt.subplots(figsize=(10, 6)) ax.plot(n_estimators_range, oob_scores, 'o-', color='#1a1a1a', linewidth=2, markersize=8, label='OOB Score') ax.errorbar(n_estimators_range, cv_scores_mean, yerr=cv_scores_std, fmt='s--', color='#666666', capsize=4, linewidth=2, markersize=7, label='5-Fold CV') ax.set_xlabel('Количество деревьев', fontsize=11) ax.set_ylabel('Accuracy', fontsize=11) ax.set_title('Сравнение OOB-оценки и кросс-валидации', fontsize=12) ax.legend(fontsize=10) ax.grid(True, alpha=0.3) plt.tight_layout() plt.show() # Числовое сравнение print("Сравнение OOB и CV:") print(f"{'Деревья':<10} {'OOB':<10} {'CV Mean':<10} {'Разница':<10}") for i, n_est in enumerate(n_estimators_range): diff = abs(oob_scores[i] - cv_scores_mean[i]) print(f"{n_est:<10} {oob_scores[i]:<10.4f} {cv_scores_mean[i]:<10.4f} {diff:<10.4f}") Рис. 3: Сравнение OOB-оценки и 5-fold кросс-валидации. OOB-оценка (сплошная линия) близка к результатам кросс-валидации (пунктир) и даже превышает ее, при этом вычисляется без дополнительного разбиения данных. Расхождение между методами обычно не превышает 0.5-1%, что делает OOB надежным инструментом для быстрой валидации Сравнение OOB и CV: Деревья OOB CV Mean Разница 50 0.8905 0.8995 0.0090 100 0.9100 0.9050 0.0050 150 0.9125 0.9065 0.0060 200 0.9150 0.9100 0.0050 300 0.9190 0.9100 0.0090 500 0.9210 0.9075 0.0135 Параметры настройки Алгоритм Случайный лес имеет меньше важных параметров, чем градиентный бустинг (Gradient Boosting). Но их грамотная настройка все равно влияет на качество модели и скорость обучения. Все параметры можно разделить на две группы: Параметры всего набора деревьев: количество деревьев и количество признаков; Параметры отдельных деревьев: глубина и минимальное число примеров в одном листе. Ключевые гиперпараметры Параметры ансамбля: n_estimators — количество деревьев. Увеличение числа деревьев монотонно улучшает качество до выхода на плато и никогда не приводит к переобучению (в отличие от бустинга). Ограничение сверху — вычислительные ресурсы и время обучения. Для большинства задач 200-500 деревьев достаточно. max_features — количество признаков для выбора при каждом разбиении. Значения по умолчанию в scikit-learn: 'sqrt' для классификации, 1.0 (все признаки) для регрессии. Уменьшение max_features снижает корреляцию между деревьями и уменьшает время обучения, но может потребовать увеличения n_estimators для компенсации. Параметры деревьев: max_depth — максимальная глубина дерева. None (без ограничения) — значение по умолчанию, деревья растут до полного разделения или до достижения min_samples_split. Ограничение глубины ускоряет обучение и может служить регуляризацией. min_samples_split — минимальное число примеров для разбиения узла. Увеличение этого параметра приводит к более грубым деревьям и служит формой регуляризации. min_samples_leaf — минимальное число примеров в листе. Аналогично min_samples_split, однако еще контролирует конечные узлы. Таблица типичных значений параметров: Параметр Значение по умолчанию Диапазон для поиска Влияние на модель n_estimators 100 100-1000 Качество ↑, время ↑ max_features sqrt (классиф.) 0.3-1.0 или sqrt, log2 Декорреляция деревьев max_depth None 10-50 или None Регуляризация min_samples_split 2 фев.20 Регуляризация min_samples_leaf 1 01.окт Сглаживание предсказаний Стратегии подбора параметров Базовый подход — начать со значений по умолчанию и увеличивать n_estimators до стабилизации OOB-score. Для большинства задач этого достаточно. Модель случайного леса устойчива к переобучению, поэтому агрессивная регуляризация для нее редко требуется. При необходимости тонкой настройки: Зафиксировать n_estimators=500 для стабильных оценок; Перебрать max_features: [0.3, 0.5, 'sqrt', 0.7, 1.0]; Если есть признаки переобучения — добавить ограничения глубины или min_samples_leaf; После выбора структуры — увеличить n_estimators если позволяют ресурсы. Для задач с большим числом признаков (>100) уменьшение max_features до 0.3-0.5 часто улучшает качество и ускоряет обучение. Для задач с малым числом признаков (<20) значение по умолчанию sqrt обычно оптимально. В финансовых приложениях с высоким уровнем шума рекомендую увеличивать min_samples_leaf до 5-10 для более консервативных предсказаний и меньшей чувствительности к аномалиям в данных. Реализация на Python Scikit-learn предоставляет эффективную реализацию Random Forest через классы RandomForestClassifier и RandomForestRegressor. Реализация поддерживает параллельное обучение деревьев (параметр n_jobs), что ускоряет работу на многоядерных процессорах. Базовая модель Пример построения модели для задачи бинарной классификации с оценкой качества и анализом важности признаков: import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report, roc_auc_score, roc_curve from sklearn.datasets import make_classification # Генерация данных с именованными признаками X, y = make_classification( n_samples=3000, n_features=20, n_informative=8, n_redundant=4, n_clusters_per_class=2, random_state=42 ) feature_names = [f'feature_{i}' for i in range(X.shape[1])] X = pd.DataFrame(X, columns=feature_names) # Разбиение на train/test X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.25, random_state=42, stratify=y ) # Обучение модели с OOB-оценкой rf_model = RandomForestClassifier( n_estimators=300, max_features='sqrt', oob_score=True, random_state=42, n_jobs=-1 ) rf_model.fit(X_train, y_train) # Оценка качества y_pred = rf_model.predict(X_test) y_proba = rf_model.predict_proba(X_test)[:, 1] print(f"OOB Score: {rf_model.oob_score_:.4f}") print(f"Test ROC-AUC: {roc_auc_score(y_test, y_proba):.4f}") print("\nClassification Report:") print(classification_report(y_test, y_pred)) # Визуализация важности признаков и ROC-кривой fig, axes = plt.subplots(1, 2, figsize=(14, 5)) # Feature Importance importances = pd.Series(rf_model.feature_importances_, index=feature_names) importances_sorted = importances.sort_values(ascending=True)[-10:] importances_sorted.plot(kind='barh', ax=axes[0], color='#2a2a2a') axes[0].set_xlabel('Importance', fontsize=11) axes[0].set_title('Top-10 Feature Importances', fontsize=12) # ROC Curve fpr, tpr, _ = roc_curve(y_test, y_proba) axes[1].plot(fpr, tpr, color='#1a1a1a', linewidth=2, label=f'ROC-AUC = {roc_auc_score(y_test, y_proba):.3f}') axes[1].plot([0, 1], [0, 1], 'k--', linewidth=1, alpha=0.5) axes[1].set_xlabel('False Positive Rate', fontsize=11) axes[1].set_ylabel('True Positive Rate', fontsize=11) axes[1].set_title('ROC Curve', fontsize=12) axes[1].legend(fontsize=10) axes[1].grid(True, alpha=0.3) plt.tight_layout() plt.show() OOB Score: 0.8871 Test ROC-AUC: 0.9555 Classification Report: precision recall f1-score support 0 0.88 0.94 0.91 374 1 0.93 0.88 0.90 376 accuracy 0.91 750 macro avg 0.91 0.91 0.91 750 weighted avg 0.91 0.91 0.91 750 Рис. 4: Результаты базовой модели Random Forest. Левая панель показывает топ-10 признаков по важности (Gini importance) — информативные признаки имеют значимо более высокие значения. Правая панель — ROC-кривая на тестовой выборке, демонстрирующая разделяющую способность модели Представленный выше код демонстрирует стандартный пайплайн работы со Случайным лесом: Разбиение данных; Обучение с OOB-оценкой; Расчет метрик на тесте; Анализ важности признаков. Feature importance в Random Forest вычисляется как среднее снижение Gini impurity по всем узлам, где признак используется для разбиения, взвешенное на долю примеров, достигающих этого узла. Оптимизация гиперпараметров Для систематического поиска оптимальных параметров в обучении модели Случайного леса используется GridSearchCV или RandomizedSearchCV. При большом пространстве параметров RandomizedSearchCV эффективнее — он сэмплирует заданное число комбинаций вместо полного перебора. import numpy as np import matplotlib.pyplot as plt from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import RandomizedSearchCV, learning_curve from sklearn.datasets import make_classification # Данные X, y = make_classification( n_samples=1000, n_features=20, n_informative=10, random_state=42 ) # Пространство поиска параметров param_distributions = { 'n_estimators': [50, 100, 150], 'max_features': [0.3, 0.5, 0.7], 'max_depth': [10, 20, None], 'min_samples_split': [2, 5, 10], 'min_samples_leaf': [1, 2, 4] } # Randomized Search rf = RandomForestClassifier(random_state=42) random_search = RandomizedSearchCV( rf, param_distributions=param_distributions, n_iter=30, cv=3, scoring='roc_auc', random_state=42, verbose=1 ) random_search.fit(X, y) print("\nЛучшие параметры:") for param, value in random_search.best_params_.items(): print(f" {param}: {value}") print(f"\nЛучший CV ROC-AUC: {random_search.best_score_:.4f}") # Learning Curve best_rf = random_search.best_estimator_ train_sizes, train_scores, val_scores = learning_curve( best_rf, X, y, train_sizes=[0.2, 0.4, 0.6, 0.8, 1.0], cv=3, scoring='roc_auc' ) # Визуализация fig, ax = plt.subplots(figsize=(10, 6)) train_mean = train_scores.mean(axis=1) train_std = train_scores.std(axis=1) val_mean = val_scores.mean(axis=1) val_std = val_scores.std(axis=1) ax.fill_between(train_sizes, train_mean - train_std, train_mean + train_std, alpha=0.2, color='#1a1a1a') ax.fill_between(train_sizes, val_mean - val_std, val_mean + val_std, alpha=0.2, color='#666666') ax.plot(train_sizes, train_mean, 'o-', color='#1a1a1a', linewidth=2, label='Train') ax.plot(train_sizes, val_mean, 's-', color='#666666', linewidth=2, label='Validation') ax.set_xlabel('Размер обучающей выборки', fontsize=11) ax.set_ylabel('ROC-AUC', fontsize=11) ax.set_title('Learning Curve (оптимизированная модель)', fontsize=12) ax.legend(fontsize=10) ax.grid(True, alpha=0.3) plt.tight_layout() plt.show() Fitting 3 folds for each of 30 candidates, totalling 90 fits Лучшие параметры: n_estimators: 150 min_samples_split: 2 min_samples_leaf: 1 max_features: 0.3 max_depth: None Лучший CV ROC-AUC: 0.9790 Рис. 5: Кривая обучения (Learning curve) для оптимизированной модели Random Forest. График показывает зависимость качества от размера обучающей выборки. Сближение кривых train и validation указывает на отсутствие переобучения. Если кривые расходятся — модель переобучена, требуется регуляризация или больше данных Использование случайного поиска (RandomizedSearchCV) с 30 итерациями обычно достаточно, чтобы найти подходящую область значений параметров. После этого можно выполнить перебор по сетке (GridSearchCV) в узком диапазоне вокруг найденных параметров, чтобы точно настроить модель. Кривая обучения (learning curve) помогает понять, нужны ли дополнительные данные. Если качество на проверочной выборке растет по мере увеличения объема данных, то значит, больше данных улучшит модель. Если кривая выходит на плато, текущего объема данных уже достаточно. Заключение Модель машинного обучения Random Forest сочетает простоту использования с высокой предсказательной силой: Механизм бутстрэп-агрегирования и случайного отбора признаков создает ансамбль декоррелированных деревьев, где индивидуальные ошибки взаимно компенсируются; OOB-оценка предоставляет встроенную валидацию без разбиения данных — ценное свойство при ограниченном объеме выборки; Алгоритм устойчив к выбросам, не требует нормализации признаков и редко переобучается при увеличении числа деревьев. Эти свойства делают Random Forest надежным baseline-решением для задач классификации и регрессии. В количественном анализе модель успешно применяется для ранжирования активов, оценки вероятностей событий и построения ансамблей с другими алгоритмами. Понимание внутренней механики — от формирования бутстрэп-выборок до влияния max_features на корреляцию деревьев — позволяет осознанно настраивать модель под специфику конкретной задачи. ### Матрица ковариаций: проблемы выборочной оценки Матрица ковариаций занимает центральное место в портфельной оптимизации, риск-менеджменте и построении торговых стратегий. Оценка этой матрицы по историческим данным кажется тривиальной задачей, но на практике приводит к серьезным проблемам. Выборочная оценка работает корректно только при выполнении жестких условий: Большой объем данных относительно числа активов; Стационарность процессов; Отсутствие выбросов. В реальности эти условия нарушаются систематически. Основная проблема: выборочная матрица ковариаций содержит избыточный шум, который искажает структуру зависимостей между активами. Этот шум усиливается при обращении (инвертировании) матрицы, что критично для задач оптимизации портфеля. Результат — неустойчивые веса активов, высокая чувствительность к малым изменениям данных, деградация производительности на новых данных. В этой статье мы рассмотрим методы регуляризации ковариационной матрицы, разработанные для борьбы с шумом, нестационарностью и малым отношением объема данных к размерности. Мы проанализируем спектральные искажения выборочной оценки через призму теории случайных матриц, а затем подробно разберем три ключевых подхода: сжатие по Ледуа–Вольфу и его улучшенную версию OAS, разреженную оценку матрицы точности с помощью Graphical Lasso, а также структурированные модели на основе факторов. Размерность против объема выборки Качество выборочной оценки матрицы ковариаций определяется соотношением: N/T где: N — число активов; T — длина временного ряда. Классическая статистика требует T >> N для получения надежных оценок. В количественных финансах это условие нарушается повсеместно. Портфель из 100 акций требует оценки матрицы размерности 100×100 — это 5050 уникальных элементов. При использовании дневных данных за 2 года (≈500 наблюдений) соотношение T/N = 5. Этого недостаточно для стабильной оценки. Проблема усугубляется для стратегий на высокочастотных данных или при работе с большими вселенными активов. Следствия недостаточного объема выборки: Собственные значения матрицы смещены: наименьшие занижены, наибольшие завышены; Собственные векторы содержат значительную шумовую компоненту; Матрица становится плохо обусловленной, число обусловленности растет экспоненциально; Обратная матрица нестабильна и усиливает шумовые компоненты. Теория случайных матриц (Random Matrix Theory, RMT) формализует эту проблему. Для случая, когда истинные ковариации отсутствуют (данные независимы), выборочная матрица все равно демонстрирует разброс собственных значений. Распределение собственных значений чисто случайной матрицы описывается законом Марченко-Пастура. Любые собственные значения выборочной матрицы, попадающие в диапазон этого распределения, неотличимы от шума. Рассмотрим следующий пример: import numpy as np import matplotlib.pyplot as plt from scipy.stats import gaussian_kde # Генерация искусственных данных np.random.seed(42) T = 500 # число наблюдений N = 100 # число активов # Генерируем независимые данные (истинные ковариации = 0) X = np.random.randn(T, N) # Выборочная матрица ковариаций C = np.cov(X.T, bias=True) eigenvalues = np.linalg.eigvalsh(C) # Теоретические границы Марченко-Пастура Q = N / T lambda_min = (1 - np.sqrt(Q))**2 lambda_max = (1 + np.sqrt(Q))**2 # Визуализация fig, ax = plt.subplots(figsize=(10, 6)) # Гистограмма собственных значений ax.hist(eigenvalues, bins=50, density=True, alpha=0.6, color='steelblue', edgecolor='black', label='Выборочные собственные значения') # Теоретические границы ax.axvline(lambda_min, color='red', linestyle='--', linewidth=2, label=f'Граница MP: λ_min = {lambda_min:.3f}') ax.axvline(lambda_max, color='red', linestyle='--', linewidth=2, label=f'Граница MP: λ_max = {lambda_max:.3f}') ax.set_xlabel('Собственные значения', fontsize=12) ax.set_ylabel('Плотность', fontsize=12) ax.set_title(f'Распределение собственных значений выборочной матрицы\nN={N}, T={T}, Q=N/T={Q:.2f}', fontsize=13) ax.legend(fontsize=10) ax.grid(True, alpha=0.3) plt.tight_layout() plt.show() Рис. 1: Распределение собственных значений выборочной матрицы ковариаций для независимых данных. Красные линии показывают теоретические границы Марченко-Пастура. Все собственные значения внутри диапазона [λ_min, λ_max] являются шумом, несмотря на их ненулевые величины. Этот код демонстрирует, как ведут себя собственные значения выборочной матрицы ковариаций, если данные на самом деле полностью независимы. Сначала создаются искусственные ряды: 100 «активов», для каждого из которых генерируется 500 случайных нормальных наблюдений. В такой конструкции истинные корреляции равны нулю, но выборочная матрица ковариаций все равно будет содержать ненулевые элементы — не потому что в данных есть структура, а потому что мы работаем с конечной выборкой. Это ключевой момент: даже если в действительности нет никакой зависимости, статистический шум создает иллюзию корреляций. После генерации данных вычисляется выборочная матрица ковариаций и ее собственные значения. Затем эти значения сравниваются с теоретическими границами распределения Марченко–Пастура. Это распределение описывает, какие собственные значения можно ожидать только из-за шума, если у набора активов нет реальных связей. Шум и смещение в оценках Выборочная матрица ковариаций имеет формулу: Σ̂ = (1/T)X^T X где X — матрица доходностей размера T×N, которая представляет несмещенную оценку истинной матрицы Σ в асимптотике T→∞. На конечных выборках оценка ковариационной матрицы содержит два источника ошибок — случайный шум и систематическое смещение. Случайная ошибка особенно заметна при малом отношении T/N: дисперсия элементов выборочной матрицы порядка 1/T приводит к сильным флуктуациям ковариаций. Для пар активов с низкой истинной корреляцией ситуация ухудшается — шум легко становится сопоставим с сигналом или даже превышает его. Систематическое смещение проявляется в спектре выборочной матрицы: крайние собственные значения систематически уходят от своих истинных значений. В частности, наибольшее собственное значение в среднем завышено и стремится к величине: σ²(1 + √Q)² где σ² — дисперсия шума, Q = N/T. Наименьшие собственные значения, напротив, систематически занижены, что может приводить к вырожденности матрицы или к численной неустойчивости при ее обращении. Рис. 2: Визуализация погрешностей оценок ковариаций. Слева — сравнение истинных и выборочных собственных значений. Выборочные крайние значения смещены: максимальное завышено, минимальные занижены. Справа — распределение относительной ошибки оценки матрицы по 100 симуляциям. Типичная ошибка составляет 15-25% от нормы истинной матрицы Для портфельной оптимизации ключевое значение имеет обратная матрица Σ̂^(-1), используемая в задаче Марковица и ее модификациях. При обращении матрицы влияние малых собственных значений усиливается, а именно они содержат наибольшую долю шума. В результате оптимизационный алгоритм может выдавать экстремальные веса активов, пытаясь использовать фиктивные корреляции, возникшие из-за статистических флуктуаций в данных. Неустойчивость собственных значений Собственное разложение матрицы ковариаций вычисляется по формуле: Σ = VΛV^T где: Λ — диагональная матрица собственных значений; V — матрица собственных векторов. Собственное разложение матриц играет ключевую роль в анализе главных компонент (PCA) и построении факторных моделей. Шум в выборочной оценке искажает как собственные значения, так и направления собственных векторов. Проблема неустойчивости собственных значений заключается в том, что небольшие изменения в данных могут вызывать непропорционально сильные искажения в спектральном разложении. Это особенно заметно, когда несколько собственных значений расположены близко друг к другу: шум легко меняет их порядок и связанные с ними собственные векторы. В задачах снижения размерности это приводит к тому, что выбор главных компонент становится нестабильным и чувствительным к случайным флуктуациям. Число обусловленности матрицы κ(Σ) = λ_max / λ_min показывает, насколько сильно матрица реагирует на такие возмущения. У выборочной матрицы ковариаций оно почти всегда велико, потому что малые собственные значения систематически занижены: Если κ превышает 10³, матрица уже считается плохо обусловленной; При значениях выше 10⁶ — практически вырожденной. Для реальных финансовых данных такое поведение типично: число обусловленности часто лежит в диапазоне 10⁴–10⁶. Последствия высокого числа обусловленности: Численная неустойчивость при решении линейных систем с матрицей Σ; Большие ошибки округления при инвертировании матрицы; Экстремальная чувствительность обратной матрицы Σ^(-1) к шуму; Нереалистичные веса в задачах портфельной оптимизации. Эти эффекты особенно выражены в классической оптимизации по среднему и дисперсии (mean-variance optimization). Оптимальные веса рассчитываются как: w = (1/λ)Σ^(-1)μ где: μ — вектор ожидаемых доходностей; λ — параметр неприятия риска. Малейшие ошибки в оценке μ сильно усиливаются через обратную матрицу, что может приводить к весам, превышающим ±500% от капитала. Такие портфели требуют огромного кредитного плеча и практически неосуществимы. Методы регуляризации: через сжатие (shrinkage) Регуляризация матрицы ковариаций направлена на уменьшение ошибки оценки за счет введения небольшого смещения в обмен на снижение дисперсии. Основная идея заключается в том, что выборочная матрица Σ̂ смешивается с некоторой целевой матрицей (target) F, обладающей желаемыми свойствами (например, низким числом обусловленности или структурированностью). В результате получается shrinkage-оценка вида: Σ_shrink = δF + (1-δ)Σ̂ где δ ∈ [0,1] — коэффициент сжатия. Выбор целевой матрицы F определяется априорными знаниями о структуре данных. Простейший вариант: F = σ²I где: σ² — средняя дисперсия активов; I — единичная матрица. Это соответствует предположению о независимости активов с одинаковой волатильностью. Альтернативы: диагональная матрица с индивидуальными дисперсиями, одно-факторная модель, постоянная корреляция между всеми активами. Ключевой вопрос: как выбрать оптимальное значение δ? Слишком малое δ оставляет избыточный шум, слишком большое — вводит чрезмерное смещение и теряет информацию о корреляциях. Оптимальный коэффициент минимизирует ожидаемую ошибку оценки, но зависит от неизвестной истинной матрицы. Сжатие методом Ледуа-Вольфа (Ledoit-Wolf shrinkage) Метод Ledoit-Wolf (2004) предлагает аналитическое решение для определения оптимального коэффициента сжатия. Основная идея заключается в минимизации ожидаемой квадратичной ошибки между сжатой оценкой матрицы ковариаций и ее истинным значением. Главное достижение метода состоит в том, что формула для δ выражается через наблюдаемые данные и не требует знания истинной матрицы. Оптимальный коэффициент сжатия задается как: δ* = max(0, min(1, β/α)) где: β — ожидаемая квадратичная ошибка выборочной оценки; α — квадратичное расстояние между выборочной оценкой и целевой матрицей. Параметр β оценивается через асимптотические свойства выборочной матрицы и учитывает дисперсию элементов Σ̂. Параметр α вычисляется напрямую как ||Σ̂ - F||². Формула гарантирует, что δ* ∈ [0,1]. В качестве целевой матрицы Ледуа и Вольф рекомендуют использовать: F = tr(Σ̂)/N · I То есть скалярную матрицу с одинаковой средней дисперсией на диагонали. Такая структура соответствует случаю некоррелированных активов с одинаковой волатильностью и обеспечивает баланс между простотой и универсальностью метода. import numpy as np import matplotlib.pyplot as plt def ledoit_wolf_shrinkage(X): """ Реализация Ledoit-Wolf shrinkage для матрицы ковариаций X: матрица данных (T x N) """ T, N = X.shape # Центрирование данных X_centered = X - X.mean(axis=0) # Выборочная матрица ковариаций S = (X_centered.T @ X_centered) / T # Целевая матрица: скалярная с средней дисперсией mu = np.trace(S) / N F = mu * np.eye(N) # Вычисление delta # alpha: квадратичное расстояние между S и F alpha = np.linalg.norm(S - F, 'fro')**2 # beta: ожидаемая квадратичная ошибка S # Упрощенная оценка для демонстрации X_centered_sq = X_centered**2 sample_var = (X_centered_sq.T @ X_centered_sq) / T beta = 0 for i in range(N): for j in range(N): if i == j: beta += sample_var[i, i] - S[i, i]**2 else: beta += sample_var[i, j] - S[i, j]**2 beta = beta / T # Оптимальный коэффициент сжатия delta = max(0.0, min(1.0, beta / alpha if alpha > 0 else 0.0)) # Shrinkage-оценка S_shrink = delta * F + (1 - delta) * S return S_shrink, delta, S, F # Генерация данных с факторной структурой np.random.seed(42) T = 200 N = 50 # Истинная матрица с факторной структурой n_factors = 3 factor_loadings = np.random.randn(N, n_factors) * 0.4 true_cov = factor_loadings @ factor_loadings.T + np.eye(N) * 0.3 # Генерация данных L = np.linalg.cholesky(true_cov) X = np.random.randn(T, N) @ L.T # Применение Ledoit-Wolf S_shrink, delta, S_sample, F = ledoit_wolf_shrinkage(X) # Визуализация fig, axes = plt.subplots(2, 2, figsize=(14, 12)) # Истинная матрица im1 = axes[0, 0].imshow(true_cov, cmap='RdBu_r', aspect='auto', vmin=-1, vmax=1) axes[0, 0].set_title('Истинная матрица ковариаций', fontsize=12) axes[0, 0].set_xlabel('Актив', fontsize=10) axes[0, 0].set_ylabel('Актив', fontsize=10) plt.colorbar(im1, ax=axes[0, 0]) # Выборочная матрица im2 = axes[0, 1].imshow(S_sample, cmap='RdBu_r', aspect='auto', vmin=-1, vmax=1) axes[0, 1].set_title('Выборочная матрица', fontsize=12) axes[0, 1].set_xlabel('Актив', fontsize=10) axes[0, 1].set_ylabel('Актив', fontsize=10) plt.colorbar(im2, ax=axes[0, 1]) # Shrinkage-оценка im3 = axes[1, 0].imshow(S_shrink, cmap='RdBu_r', aspect='auto', vmin=-1, vmax=1) axes[1, 0].set_title(f'Ledoit-Wolf shrinkage (δ={delta:.3f})', fontsize=12) axes[1, 0].set_xlabel('Актив', fontsize=10) axes[1, 0].set_ylabel('Актив', fontsize=10) plt.colorbar(im3, ax=axes[1, 0]) # Сравнение ошибок error_sample = np.linalg.norm(S_sample - true_cov, 'fro') error_shrink = np.linalg.norm(S_shrink - true_cov, 'fro') metrics = ['Выборочная', 'LW Shrinkage'] errors = [error_sample, error_shrink] colors = ['red', 'green'] bars = axes[1, 1].bar(metrics, errors, color=colors, alpha=0.7, edgecolor='black') axes[1, 1].set_ylabel('Ошибка Фробениуса', fontsize=11) axes[1, 1].set_title('Сравнение качества оценки', fontsize=12) axes[1, 1].grid(True, alpha=0.3, axis='y') # Добавление значений на столбцы for bar, error in zip(bars, errors): height = bar.get_height() axes[1, 1].text(bar.get_x() + bar.get_width()/2., height, f'{error:.3f}', ha='center', va='bottom', fontsize=11, fontweight='bold') plt.tight_layout() plt.show() print(f"Коэффициент сжатия δ: {delta:.4f}") print(f"Ошибка выборочной оценки: {error_sample:.4f}") print(f"Ошибка LW shrinkage: {error_shrink:.4f}") print(f"Снижение ошибки: {(1 - error_shrink/error_sample)*100:.1f}%") Рис. 3: Сравнение матриц ковариаций. Верхний ряд: истинная матрица и зашумленная выборочная оценка. Нижний ряд: результат сжатия методом Ледуа-Вольфа и сравнение ошибок. Метод снижает ошибку оценки до 30% в зависимости от соотношения T/N Коэффициент сжатия δ: 0.0427 Ошибка выборочной оценки: 2.6546 Ошибка LW shrinkage: 2.5288 Снижение ошибки: 4.7% Практические свойства метода: Автоматический выбор коэффициента δ без кросс-валидации; Вычислительная эффективность O(N²T); Гарантированное улучшение относительно выборочной оценки в среднеквадратичном смысле. Метод особенно эффективен при T/N < 10 — режим, типичный для портфелей из десятков активов при использовании нескольких лет дневных данных. Oracle Approximating Shrinkage (OAS) Метод Oracle Approximating Shrinkage (OAS, Chen et al., 2009) развивает подход Ledoit-Wolf, предлагая более точную аппроксимацию оптимального коэффициента сжатия. Основная идея заключается в учете асимптотических свойств собственных значений выборочной матрицы в режиме N/T → const, что позволяет получить улучшенные оценки для малых и средних выборок. Ключевое отличие сжатия OAS от сжатия Ледуа-Вольфа состоит в том, что минимизируется не среднеквадратичная ошибка самой матрицы, а среднеквадратичная ошибка ее обратной матрицы. Это особенно важно для задач, где Σ^(-1) напрямую — например, при портфельной оптимизации, линейном дискриминантном анализе (LDA) или расчете расстояния Махаланобиса. Формула OAS для коэффициента сжатия: δ_OAS = ((1-ρ²)tr(Σ̂²) + tr(Σ̂)²) / ((T+1-ρ)(tr(Σ̂²) - tr(Σ̂)²/N)) где: ρ = N/T — соотношение размерности к объему выборки; tr(Σ̂) — след выборочной матрицы; tr(Σ̂²) — след квадрата выборочной матрицы. Параметр ρ явно учитывает влияние размерности задачи. При ρ → 0 (T >> N) коэффициент δ_OAS → 0, и shrinkage-оценка сходится к выборочной. При ρ → 1 (N ≈ T) сжатие усиливается, что предотвращает вырожденность матрицы. Рис. 4: Сравнение методов сжатия для оценки матрицы ковариаций и ее обратной матрицы. Слева — зависимость коэффициентов сжатия от соотношения N/T для методов LW и OAS. При малых T (высоких ρ) OAS применяет более сильное сжатие, адаптируясь к повышенной шумности выборочной матрицы и предотвращая вырожденность. Справа — сравнение ошибок оценки обратной матрицы. OAS демонстрирует преимущество в режиме N/T > 0.1, что типично для задач портфельной оптимизации Метод сжатия OAS показывает лучшие результаты по сравнению с подходом Ледуа-Вольфа в задачах, где требуется обращение матрицы ковариаций. Эффект особенно заметен при ρ > 0.2, когда выборочная матрица приближается к вырожденной. Для портфелей из 50–100 активов с 1–2 годами дневных данных метод OAS часто оказывается наиболее предпочтительным. Graphical Lasso и структурированные оценки Метод Graphical Lasso (GLasso) решает задачу регуляризации через разреженность матрицы точности: Θ = Σ^(-1) Метод накладывает L1-штраф на недиагональные элементы Θ, стимулируя обнуление слабых зависимостей. В результате получается разреженная структура, где Θ_ij = 0 интерпретируется как условная независимость между активами i и j при фиксированных остальных переменных. Задача оптимизации GLasso формулируется так: minimize -log det(Θ) + tr(SΘ) + λ||Θ||₁ где: S — выборочная матрица ковариаций; λ ≥ 0 — параметр регуляризации; ||Θ||₁ — сумма абсолютных значений недиагональных элементов. Параметр λ контролирует степень разреженности: λ = 0 дает выборочную оценку Θ̂ = S^(-1), большие λ приводят к диагональной матрице (полная независимость). Выбор λ осуществляется через кросс-валидацию или информационные критерии (AIC, BIC). GLasso позволяет интерпретировать зависимости между активами в виде графа: вершины — это активы, а ребра соединяют условно зависимые пары. Разреженная структура упрощает интерпретацию, снижает число параметров для оценки и улучшает обусловленность матрицы. Такой подход особенно эффективен для выявления прямых связей между активами, исключая косвенные корреляции через общие факторы. Подход структурированных оценок (Structured estimators) расширяет идею GLasso, вводя априорные знания о возможной структуре зависимостей. Это позволяет учитывать заранее известные связи или ограничения, улучшая стабильность и интерпретируемость оценок. Примеры таких структур включают: Блочно-диагональная матрица для активов, сгруппированных по секторам, что отражает сильные внутри-секторные зависимости; Факторная структура Σ = BB^T + D, где B — матрица факторных загрузок, D — диагональная матрица уникальной дисперсии; Матрица Тёплица для временных рядов с затухающей автокорреляцией, учитывающая зависимость наблюдений на разных лагах; Ранговые ограничения для приближений с низким рангом, позволяющие снижать число параметров и выделять основные источники совместной изменчивости. Метод Factor model shrinkage комбинирует факторный анализ и регуляризацию. Матрица ковариаций представляется как: Σ = FF^T + Ψ где: F — k факторов (k << N); Ψ — диагональная матрица несистематических рисков. Факторы обычно оцениваются с помощью PCA, после чего применяется регуляризация, что обеспечивает устойчивую и интерпретируемую структуру матрицы ковариаций. Такой подход позволяет значительно снизить число параметров для оценки с N(N+1)/2 для полной матрицы до Nk + N для факторной модели. from sklearn.covariance import GraphicalLassoCV import numpy as np import matplotlib.pyplot as plt np.random.seed(42) # Параметры N, T = 20, 100 # Истинная разреженная матрица точности precision = np.eye(N) * 2 for i in range(0, N-1, 4): for j in range(i, min(i+4, N)): for k in range(j+1, min(i+4, N)): precision[j, k] = precision[k, j] = -0.6 true_cov = np.linalg.inv(precision) X = np.random.randn(T, N) @ np.linalg.cholesky(true_cov).T # Graphical Lasso с кросс-валидацией model = GraphicalLassoCV(cv=5, max_iter=100).fit(X) estimated_precision = model.precision_ lambda_optimal = model.alpha_ # Выборочная матрица точности sample_precision = np.linalg.inv(np.cov(X.T, bias=True)) # Визуализация 3-х матриц fig, axes = plt.subplots(1, 3, figsize=(16, 5)) titles = ['Истинная матрица точности Θ', 'Выборочная оценка Θ', f'GLasso оценка (λ={lambda_optimal:.3f})'] matrices = [precision, sample_precision, estimated_precision] for ax, mat, title in zip(axes, matrices, titles): im = ax.imshow(mat, cmap='RdBu_r', aspect='auto', vmin=-2, vmax=2) ax.set_title(title, fontsize=12) ax.set_xlabel('Актив', fontsize=10) ax.set_ylabel('Актив', fontsize=10) plt.colorbar(im, ax=ax) plt.tight_layout() plt.show() # Метрики качества def sparsity(matrix, threshold=0.1): return np.sum(np.abs(matrix) < threshold) / matrix.size print(f"\nОптимальный параметр λ: {lambda_optimal:.4f}") print(f"\nРазреженность матриц точности:") print(f"Истинная: {sparsity(precision, 0.1):.2%}") print(f"Выборочная: {sparsity(sample_precision, 0.1):.2%}") print(f"GLasso: {sparsity(estimated_precision, 0.1):.2%}") print(f"\nОшибки оценки:") print(f"Выборочная: {np.linalg.norm(sample_precision - precision, 'fro'):.3f}") print(f"GLasso: {np.linalg.norm(estimated_precision - precision, 'fro'):.3f}") Рис. 5: Сравнение матриц точности на одинаковых данных: слева — истинная матрица Θ, посередине — выборочная оценка Θ, справа — оценка с использованием Graphical Lasso с кросс-валидацией (оптимальный λ) Оптимальный параметр λ: 0.0945 Разреженность матриц точности: Истинная: 80.00% Выборочная: 24.00% GLasso: 78.50% Ошибки оценки: Выборочная: 6.337 GLasso: 2.223 Код генерирует искусственные данные с заранее заданной разреженной структурой зависимостей между активами и оценивает матрицу точности Θ тремя способами: Использует истинную матрицу (заданную вручную); Выборочную оценку (обратная матрица ковариаций); Graphical Lasso с кросс-валидацией для автоматического подбора параметра регуляризации λ. После чего визуализируются все три матрицы, а также вычисляются метрики разреженности и ошибки оценки обратной матрицы по норме Фробениуса. Результаты показывают, что Graphical Lasso успешно восстанавливает разреженную структуру матрицы точности, близкую к истинной: оптимальный коэффициент λ≈0.0945 обеспечивает высокую разреженность (78,5%) и существенно снижает ошибку оценки (2,223) по сравнению с выборочной оценкой, которая менее разрежена (24%) и обладает большей ошибкой (6,337). Это демонстрирует эффективность регуляризации для выделения значимых зависимостей между активами. Применение методов зависит от контекста: Структурированные оценки применяются в случаях, когда известна экономическая или рыночная структура (например сектора, географические регионы или ключевые факторы риска). Блочно-диагональная матрица для секторов предполагает сильные корреляции внутри каждого сектора и отсутствие прямых связей между ними. Факторная модель особенно оправдана для фондовых рынков, где поведение активов определяется общими факторами, такими как рыночный риск, размер компании или стиль инвестирования (value/growth). Выбор конкретной структуры требует глубокого понимания экономики и особенностей рынка и не всегда может быть универсально применим. Практические рекомендации Выбор метода регуляризации зависит от задачи, объема данных, вычислительных ресурсов и требований к интерпретируемости. Универсального решения нет, поэтому важно учитывать соотношение T/N, структуру данных и целевую метрику качества, чтобы сузить выбор до 2–3 подходов. Методы различаются по вычислительной сложности: Ledoit-Wolf и OAS требуют O(N²T) операций и подходят для портфелей до нескольких сотен активов; GLasso выполняет O(N³) операций на итерацию, что ограничивает его использование при N > 500; Structured estimators с факторной моделью масштабируются лучше, если число факторов k значительно меньше N. Как выбирать метод по задаче: Портфельная оптимизация: ключевой критерий — точность обратной матрицы Σ^(-1). OAS минимизирует ошибку обратной матрицы и эффективен для средних портфелей (N = 50–200, T/N < 10). Ledoit-Wolf проще в реализации, но уступает по точности; Управление рисками и VaR: важна точность самой Σ, а не Σ^(-1). Ledoit-Wolf хорошо справляется с минимизацией среднеквадратичной ошибки. Для больших портфелей (N > 200) факторная модель сжатия эффективнее снижает число параметров и нагрузку на вычисления; Стратегии возврата к среднему и статистический арбитраж: GLasso выявляет прямые зависимости между активами, исключая косвенные корреляции через общие факторы. Разреженная структура упрощает выбор пар для парного трейдинга. Параметр λ подбирается через кросс-валидацию; Инжиниринг признаков для ML: собственные векторы регуляризованной Σ дают устойчивые факторы риска для предиктивных моделей. Регуляризация проводится только на обучающей выборке, тестовые данные не участвуют в подборе параметров. Для динамических стратегий, где матрица пересчитывается регулярно (ежедневно или еженедельно), на первый план выходит скорость вычислений. Здесь предпочтительнее метод Ledoit-Wolf, так как он обеспечивает оптимальный баланс между быстротой и точностью. OAS работает немного медленнее, но дает более точные результаты. GLasso требует подбора параметра λ при каждом обновлении, что заметно увеличивает вычислительную нагрузку. Диагностика качества оценки Оценка качества регуляризованной матрицы ковариаций проводится через несколько косвенных индикаторов, так как прямое сравнение с истинной матрицей невозможно. Первый показатель — число обусловленности κ(Σ̂) = λ_max/λ_min. Для выборочной матрицы κ обычно ≈ 10⁴–10⁶, регуляризация должна снижать его до 10²–10³. Значения выше 10⁴ указывают на недостаточное сжатие, ниже 50 — на чрезмерное, с потерей информации о корреляциях. Спектр собственных значений отражает распределение дисперсии. Выборочная матрица показывает завышенные максимальные и близкие к нулю минимальные значения. Сжатие выравнивает спектр, увеличивая малые и снижая большие собственные значения; это удобно визуализировать на графике с логарифмической шкалой (log-scale). Рис. 6: Сравнение методов оценки ковариационной матрицы (Выборочная оценка, Ledoit–Wolf и OAS). Вверху слева показано число обусловленности (лог. шкала), вверху справа — относительные ошибки Σ и Σ⁻¹. Внизу слева приведены спектры собственных значений, а справа — сводная таблица метрик Валидация на отложенной выборке (out-of-sample) — ключевой критерий для прогнозных задач. Данные делятся на обучающую и тестовую выборки, оценка проводится на обучении, а качество проверяется на тесте через логарифмическое правдоподобие: LL = -0.5 · (log det(Σ̂) + tr(Σ_test · Σ̂^(-1))) где Σ_test — выборочная матрица тестовой выборки. Наилучшей считается матрица с максимальным LL на тестовой выборке, а многократная кросс-валидация повышает устойчивость оценки. Типичные ошибки и как их избежать При работе с оценкой ковариационных матриц в портфельных задачах часто допускаются системные методологические ошибки, которые значительно ухудшают результаты на реальных данных. Ниже приведены наиболее распространенные проблемы и рекомендации, как избежать искажения оценок, переобучения и ухудшения эффективности в продакшене. Утечка данных из будущего (Look-ahead bias). Возникает, когда параметры shrinkage или λ подбираются на всем датасете, включая будущее. Правильный подход — фиксированное окно обучения: параметры выбираются только на этом окне и применяются к следующему периоду; Игнорирование нестационарности. Корреляции со временем меняются: в кризисы растут, в фазах роста снижаются. Матрица, оцененная на длительном периоде (например, 5 лет), плохо отражает текущие условия. Используйте экспоненциальное взвешивание, более короткие окна (6–12 месяцев) и мониторинг стабильности; Слабая регуляризация при малом T/N. При T/N ≈ 1 выборочная матрица остается плохо обусловленной даже после применения Ledoit–Wolf. Проверяйте число обусловленности, усиливайте регуляризацию (ручная настройка δ) или переходите к OAS/GLasso; Избыточная регуляризация. При δ → 1 матрица почти диагональна, связь между активами исчезает, а реализованная волатильность превышает прогнозируемую. Решение — уменьшить δ или использовать менее агрессивную целевую матрицу; Некорректный выбор целевой матрицы. Скалярная F = μI предполагает одинаковую волатильность всех активов — это неверно для смешанных или секторальных портфелей. Предпочтительнее диагональная или блочно-диагональная структура, учитывающая индивидуальные дисперсии и внутрисекторные связи; Игнорирование выбросов. Единичные экстремальные доходности искажают ковариации. Сжатие смягчает эффект, но не устраняет его полностью. Рекомендуется прибегать к винзоризации данных (1–5%), использовать устойчивые методы оценки и делать фильтрацию аномалий; Отсутствие мониторинга после деплоя. Оптимальные в прошлом режиме рынка параметры со временем теряют актуальность. Регулярный пересчет (ежемесячно/ежеквартально) и контроль метрик позволяют вовремя выявлять необходимость рекалибровки. Заключение Оценка ковариационных матриц остается ключевой задачей в количественных финансах: от ее качества зависит устойчивость портфелей, корректность моделей риска и результативность торговых стратегий. При ограниченном объеме данных выборочная матрица ковариаций нестабильна, поэтому регуляризация обязательна для получения надежных оценок. Три метода сжатия: Ledoit-Wolf, OAS и Graphical Lasso предлагают разные балансы между скоростью, точностью и интерпретируемостью. Оптимальный выбор зависит от контекста: OAS подходит для портфельной оптимизации при малых T/N, GLasso — для изучения структуры зависимостей, а факторные модели — для широких вселенных активов. Таким образом, эффективная работа с ковариационными матрицами требует сочетания статистических методов, инженерной дисциплины и понимания рыночных режимов. Только интеграция этих аспектов обеспечивает устойчивые результаты и стабильность моделей в условиях меняющейся рыночной среды. ### Библиотека SciPy: оптимизация, статистика и численные методы в Python SciPy представляет собой фундаментальную библиотеку для научных и инженерных вычислений в Python. Она расширяет возможности NumPy, добавляя алгоритмы оптимизации, статистического анализа, обработки сигналов и численных методов. В отличие от базовых операций с массивами, SciPy предоставляет готовые решения для сложных математических задач: от поиска экстремумов функций до проверки статистических гипотез. Для специалистов в области финансового и количественного анализа SciPy решает три класса задач: Оптимизация торговых стратегий и портфелей через модуль scipy.optimize; Статистический анализ доходностей и распределений рисков с помощью scipy.stats; Обработка временных рядов и заполнение пропусков данных инструментами scipy.interpolate и scipy.signal. Библиотека интегрируется с pandas, NumPy и визуализационными инструментами, формируя единую экосистему для анализа данных. Производительность SciPy обеспечивается реализацией критических участков кода на C, C++ и Fortran. Это позволяет выполнять вычислительно сложные операции со скоростью, сопоставимой с низкоуровневыми языками, сохраняя при этом удобство Python-интерфейса. Библиотека активно развивается с 2001 года и поддерживает стабильный API, что минимизирует риски при обновлении версий в продакшене. Установка и настройка окружения Корректная установка SciPy требует понимания зависимостей и особенностей компиляции нативных расширений. Библиотека опирается на BLAS и LAPACK для операций линейной алгебры, что напрямую влияет на производительность вычислений. Установка базовой версии Установка через pip подходит для большинства сценариев использования: pip install scipy Для работы с финансовыми данными рекомендуется устанавливать SciPy в связке с основными библиотеками анализа: pip install scipy pandas yfinance matplotlib Conda-установка предпочтительна при необходимости оптимизированных математических библиотек. Anaconda и Miniconda поставляются с Intel MKL (Math Kernel Library), обеспечивающей ускорение операций линейной алгебры на 2-5x по сравнению с OpenBLAS: conda install scipy Проверка установки и версии выполняется импортом библиотеки: import scipy print(scipy.__version__) 1.16.3 На момент ноября 2025 актуальная версия — 1.16.x. Версии 1.11+ включают улучшения производительности для операций с разреженными матрицами и расширенный функционал статистических тестов. Зависимости и совместимость с NumPy SciPy требует установки и импорта библиотеки NumPy, которая является ее обязательной зависимостью. Совместимость версий крайне важна: например, использование SciPy 1.14 вместе с NumPy 1.23 может привести к ошибкам при работе с отдельными типами данных. В документации SciPy указаны рекомендуемые комбинации версий, и лучше всего обновлять обе библиотеки одновременно. Ниже перечислены основные зависимости SciPy: NumPy — базовые операции с массивами и линейная алгебра; BLAS/LAPACK — низкоуровневые операции линейной алгебры (автоматически устанавливаются через pip/conda); компилятор C/C++ — требуется только при сборке из исходников. Для проверки используемой математической библиотеки: import scipy scipy.show_config() rectory: /opt/_internal/cpython-3.12.11/lib/python3.12/site-packages/scipy_openblas32/include lib directory: /opt/_internal/cpython-3.12.11/lib/python3.12/site-packages/scipy_openblas32/lib name: scipy-openblas openblas configuration: OpenBLAS 0.3.29.dev DYNAMIC_ARCH NO_AFFINITY Haswell MAX_THREADS=64 pc file directory: /project version: 0.3.29.dev lapack: detection method: pkgconfig found: true include directory: /opt/_internal/cpython-3.12.11/lib/python3.12/site-packages/scipy_openblas32/include lib directory: /opt/_internal/cpython-3.12.11/lib/python3.12/site-packages/scipy_openblas32/lib name: scipy-openblas openblas configuration: OpenBLAS 0.3.29.dev DYNAMIC_ARCH NO_AFFINITY Haswell MAX_THREADS=64 pc file directory: /project version: 0.3.29.dev pybind11: detection method: config-tool include directory: unknown name: pybind11 version: 3.0.1 Команда выводит информацию о версиях BLAS/LAPACK и путях к библиотекам. При использовании OpenBLAS вместо Intel MKL производительность операций с большими матрицами может снижаться на 30-40%. Для высоконагруженных вычислений в продакшене стоит использовать Anaconda-дистрибутив с Intel MKL. Конфликты версий проявляются предупреждениями при импорте или ошибками вида "incompatible API version". Решение: обновить NumPy и SciPy до последних совместимых версий через pip install --upgrade или переустановить окружение через conda. Архитектура библиотеки SciPy организована как коллекция независимых модулей, каждый из которых решает определенный класс задач. Модульная структура позволяет импортировать только необходимый функционал, снижая накладные расходы на загрузку библиотеки. Основные модули SciPy Библиотека включает 16 основных модулей, из которых для финансового анализа наиболее востребованы семь: scipy.optimize — поиск минимумов и максимумов функций, решение уравнений, подбор параметров. Применяется для оптимизации портфелей, калибровки моделей ценообразования опционов, поиска оптимальных параметров торговых стратегий. scipy.stats — статистические распределения, тесты гипотез, описательная статистика. Используется для анализа доходностей, проверки нормальности распределений, расчета Value at Risk, тестирования стационарности временных рядов. scipy.interpolate — интерполяция и аппроксимация данных, построение сплайнов. Решает задачи заполнения пропусков в котировках, построения кривых доходности, сглаживания зашумленных данных. scipy.signal — обработка сигналов, фильтрация, спектральный анализ. Применяется для фильтрации ценовых рядов, выделения трендов, анализа цикличности рынков. scipy.linalg — расширенные операции линейной алгебры. Используется в факторных моделях, анализе главных компонент, решении систем уравнений в моделях равновесия. scipy.integrate — численное интегрирование функций и дифференциальных уравнений. Применяется в моделях непрерывного времени, расчете опционов методом Монте-Карло, решении стохастических дифференциальных уравнений. scipy.sparse — работа с разреженными матрицами. Модуль используют для обработки больших корреляционных матриц, графовых моделей связей между активами, портфельной оптимизации с тысячами инструментов. Остальные модули (scipy.fft, scipy.spatial, scipy.ndimage и другие) применяются в специфических задачах обработки изображений, пространственного анализа и преобразований Фурье. Интеграция с экосистемой научного Python Библиотека SciPy проектировалась как надстройка над NumPy, дополняющая базовую функциональность специализированными алгоритмами. Все функции SciPy принимают и возвращают NumPy-массивы, обеспечивая бесшовную интеграцию. Это позволяет комбинировать операции обеих библиотек без преобразования типов данных. Интеграция с pandas реализуется через поддержку DataFrame и Series. Многие функции SciPy корректно обрабатывают pandas-структуры, автоматически извлекая базовые NumPy-массивы. Однако для гарантированной совместимости рекомендуется явное преобразование через .values или .to_numpy(). Связка SciPy с matplotlib используется для визуализации результатов анализа. Типичный пайплайн: Загрузка данных через pandas/yfinance; Обработка через SciPy; Визуализация через matplotlib. Например, построение распределения доходностей с наложением теоретической кривой нормального распределения требует импорта всех трех библиотек. Для машинного обучения SciPy предоставляет базовые алгоритмы оптимизации, которые используются внутри scikit-learn. Функция scipy.optimize.minimize лежит в основе обучения логистической регрессии и некоторых SVM-реализаций. Однако для продвинутых ML-задач применяются специализированные библиотеки с поддержкой автоматического дифференцирования. Производительность операций зависит от используемого BLAS-бэкенда: Intel MKL автоматически распараллеливает операции линейной алгебры на несколько ядер процессора; OpenBLAS также поддерживает многопоточность, но требует явной настройки через переменные окружения OMP_NUM_THREADS или OPENBLAS_NUM_THREADS. Обзор ключевых возможностей Функциональность SciPy охватывает широкий спектр численных методов, от базовой оптимизации до продвинутого статистического анализа. Рассмотрим модули, наиболее востребованные в количественном анализе и работе с финансовыми данными. Оптимизация (scipy.optimize) Модуль scipy.optimize предоставляет алгоритмы для минимизации функций, решения уравнений и подбора параметров моделей. Основные функции делятся на три категории: локальная оптимизация, глобальная оптимизация и решение уравнений. Локальная оптимизация реализована через minimize() — универсальную функцию с выбором метода оптимизации. Поддерживаются алгоритмы BFGS, L-BFGS-B, Nelder-Mead, SLSQP и другие. Выбор метода зависит от наличия градиента и ограничений на переменные. import numpy as np from scipy.optimize import minimize def sharpe_negative(weights, returns, cov_matrix): portfolio_return = np.sum(returns * weights) portfolio_std = np.sqrt(np.dot(weights.T, np.dot(cov_matrix, weights))) return -portfolio_return / portfolio_std returns = np.array([0.12, 0.18, 0.15]) cov_matrix = np.array([ [0.04, 0.01, 0.02], [0.01, 0.09, 0.03], [0.02, 0.03, 0.06] ]) constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1} bounds = tuple((0, 1) for _ in range(3)) initial_weights = np.array([1/3, 1/3, 1/3]) result = minimize(sharpe_negative, initial_weights, args=(returns, cov_matrix), method='SLSQP', bounds=bounds, constraints=constraints) print(f"Оптимальные веса: {result.x}") print(f"Sharpe ratio: {-result.fun:.4f}") Оптимальные веса: [0.45631625 0.30448767 0.23919608] Sharpe ratio: 0.8179 Представленный пример кода демонстрирует оптимизацию портфеля из трех активов по критерию максимизации коэффициента Шарпа. Функция sharpe_negative возвращает отрицательное значение Sharpe ratio, поскольку minimize() ищет минимум. Ограничение constraints обеспечивает сумму весов равную 1, bounds задают допустимый диапазон для каждого веса. Метод SLSQP (Sequential Least Squares Programming) подходит для задач с ограничениями-равенствами и границами переменных. Глобальная оптимизация применяется для функций с множественными локальными минимумами. Основные методы: differential_evolution, basin_hopping, dual_annealing. Эти алгоритмы медленнее локальных, но находят глобальный оптимум с высокой вероятностью. Функция curve_fit() упрощает подбор параметров моделей к экспериментальным данным. Она минимизирует сумму квадратов отклонений между моделью и данными, автоматически вычисляя ковариационную матрицу параметров для оценки неопределенности. Статистический анализ (scipy.stats) Модуль scipy.stats содержит более 100 статистических распределений и десятки тестов гипотез. Функционал делится на три группы: Непрерывные и дискретные распределения; Описательная статистика; Статистические тесты. Каждое распределение реализовано как объект с методами pdf (плотность вероятности), cdf (функция распределения), ppf (обратная функция распределения), rvs (генерация случайных величин). Параметризация распределений следует стандартным математическим определениям. from scipy import stats import numpy as np returns = np.random.normal(0.001, 0.02, 1000) statistic, p_value = stats.normaltest(returns) print(f"Тест нормальности: p-value = {p_value:.4f}") statistic_ks, p_value_ks = stats.kstest(returns, 'norm', args=(returns.mean(), returns.std())) print(f"Тест Колмогорова-Смирнова: p-value = {p_value_ks:.4f}") skewness = stats.skew(returns) kurt = stats.kurtosis(returns) print(f"Асимметрия: {skewness:.4f}, Эксцесс: {kurt:.4f}") Тест нормальности: p-value = 0.0408 Тест Колмогорова-Смирнова: p-value = 0.8570 Асимметрия: 0.1802, Эксцесс: 0.1449 Код проверяет гипотезу о нормальности распределения доходностей. Функция normaltest() выполняет комбинированный тест на основе асимметрии и эксцесса. Тест Колмогорова-Смирнова сравнивает эмпирическое распределение с теоретическим нормальным. Значения p-value выше 0.05 указывают на отсутствие оснований отвергнуть гипотезу нормальности. Параметрические тесты в SciPy включают: t-test (сравнение средних); f-test (сравнение дисперсий); ANOVA (анализ вариативности). Непараметрические альтернативы: Тест Манна-Уитни; Тест Краскела-Уоллиса; Тест Уилкоксона. Непараметрические методы не требуют предположений о виде распределения и устойчивы к выбросам. Для анализа временных рядов применяется: stats.pearsonr() для расчета корреляции; stats.spearmanr() для ранговой корреляции; stats.kendalltau() для корреляции Кендалла. Ранговые корреляции обычно устойчивее к выбросам и нелинейным зависимостям. Интерполяция данных (scipy.interpolate) Модуль scipy.interpolate решает задачи восстановления пропущенных значений и построения гладких кривых по дискретным точкам. Основные подходы: Линейная интерполяция; Полиномиальная интерполяция; Сплайны. Функция interp1d() создает интерполяционный объект для одномерных данных. Параметр kind определяет метод: 'linear', 'quadratic', 'cubic' или целое число для полиномиальной интерполяции указанной степени. from scipy.interpolate import interp1d, CubicSpline import numpy as np dates = np.array([0, 5, 10, 15, 20]) prices = np.array([100, 102, 98, 103, 105]) f_linear = interp1d(dates, prices, kind='linear') f_cubic = interp1d(dates, prices, kind='cubic') cs = CubicSpline(dates, prices) dates_dense = np.linspace(0, 20, 100) prices_linear = f_linear(dates_dense) prices_cubic = f_cubic(dates_dense) prices_spline = cs(dates_dense) print(f"Цена на день 7 (линейная): {f_linear(7):.2f}") print(f"Цена на день 7 (кубическая): {f_cubic(7):.2f}") print(f"Цена на день 7 (сплайн): {cs(7):.2f}") Цена на день 7 (линейная): 100.40 Цена на день 7 (кубическая): 99.90 Цена на день 7 (сплайн): 99.90 Код демонстрирует три метода интерполяции ценового ряда с пропусками. Линейная интерполяция соединяет точки прямыми, кубическая использует полином третьей степени. CubicSpline строит кубический сплайн с непрерывными первой и второй производными, обеспечивая гладкость кривой. Сплайны предпочтительны для финансовых данных, поскольку избегают осцилляций, характерных для полиномов высоких степеней. Для двумерной интерполяции применяются griddata() и RectBivariateSpline(). Первая работает с нерегулярной сеткой точек, вторая требует прямоугольной сетки, но работает быстрее. Двумерная интерполяция используется для построения поверхностей волатильности опционов. Параметр fill_value в interp1d() определяет поведение при экстраполяции за пределы исходных данных. Значение 'extrapolate' включает линейную экстраполяцию, числовое значение возвращает константу, отсутствие параметра вызывает ошибку при выходе за границы. Обработка сигналов (scipy.signal) Модуль scipy.signal предоставляет инструменты для фильтрации, сглаживания и спектрального анализа временных рядов. Основные категории функций: Проектирование фильтров; Применение фильтров; Оконные функции; Спектральный анализ. Фильтр Савицкого-Голея (savgol_filter) сглаживает данные с сохранением важных особенностей сигнала. В отличие от скользящих средних, он минимизирует искажение пиков и впадин. from scipy.signal import savgol_filter, butter, filtfilt import numpy as np import matplotlib.pyplot as plt # Исходные данные prices = np.array([100, 102, 101, 103, 102, 105, 104, 106, 108, 107]) # Фильтр Savitzky–Golay smoothed = savgol_filter(prices, window_length=5, polyorder=2) # Фильтр Баттерворта (низкие частоты) b, a = butter(N=2, Wn=0.3, btype='low') filtered = filtfilt(b, a, prices) print(f"Исходные цены: {prices}") print(f"Сглаженные (Savitzky-Golay): {smoothed}") print(f"Фильтрованные (Butterworth): {filtered}") # Визуализация plt.figure(figsize=(10, 5)) plt.plot(prices, label="Исходные данные", linewidth=2) plt.plot(smoothed, label="Savitzky–Golay", linestyle="--") plt.plot(filtered, label="Butterworth", linestyle=":") plt.title("Сравнение работы фильтров") plt.xlabel("Индекс точки") plt.ylabel("Цена") plt.legend() plt.grid(True) plt.show() Исходные цены: [100 102 101 103 102 105 104 106 108 107] Сглаженные (Savitzky-Golay): [100.17142857 101.31428571 102.02857143 101.88571429 103.28571429 103.71428571 104.85714286 106. 106.8 107.6 ] Фильтрованные (Butterworth): [ 99.99884159 100.92514281 101.69350816 102.3638336 103.1026151 103.98990454 104.99020523 105.95798706 106.64775368 106.99689621] Рис. 1: Сравнение работы фильтров Баттеруорта и Савицкого-Голея Фильтр Савицкого-Голея применяет локальную полиномиальную регрессию к окну данных. Параметр window_length определяет размер окна (должен быть нечетным), polyorder — степень полинома. Меньшие значения polyorder дают более сильное сглаживание. Для финансовых данных типичные значения: window_length от 5 до 21, polyorder от 2 до 3. Фильтр Баттерворта (butter) проектирует низкочастотный фильтр с максимально плоской частотной характеристикой в полосе пропускания. Параметр N задает порядок фильтра (чем выше, тем резче срез), Wn — нормализованную частоту среза (от 0 до 1). Функция filtfilt() применяет фильтр дважды (вперед и назад), устраняя фазовые искажения. Спектральный анализ в SciPy реализован через periodogram() и welch(). Функция welch() разбивает сигнал на перекрывающиеся сегменты и усредняет периодограммы, снижая дисперсию оценки спектральной плотности. Это полезно для выявления циклических компонент в ценовых рядах. Функция find_peaks() обнаруживает локальные максимумы в сигнале. Параметры height, prominence, distance позволяют фильтровать значимые пики от шума. Метод нередко применяется для идентификации уровней сопротивления в ценовых графиках или точек разворота тренда. Линейная алгебра (scipy.linalg) Модуль scipy.linalg расширяет возможности numpy.linalg дополнительными алгоритмами и более стабильными численными реализациями. Ключевые области: Разложения матриц; Решение линейных систем; Матричные функции. Разложения матриц включают LU, QR, SVD (сингулярное разложение), Cholesky, Schur. Разложение Холецкого применяется для ковариационных матриц в задачах симуляции коррелированных случайных величин. SVD используется в методе главных компонент для снижения размерности данных. from scipy.linalg import cholesky, solve import numpy as np cov_matrix = np.array([ [0.04, 0.01, 0.02], [0.01, 0.09, 0.03], [0.02, 0.03, 0.06] ]) L = cholesky(cov_matrix, lower=True) print("Разложение Холецкого:") print(L) uncorrelated = np.random.standard_normal((3, 1000)) correlated = L @ uncorrelated print(f"\nКовариационная матрица смоделированных данных:") print(np.cov(correlated)) Разложение Холецкого: [[0.2 0. 0. ] [0.05 0.29580399 0. ] [0.1 0.08451543 0.20701967]] Ковариационная матрица смоделированных данных: [[0.04030301 0.00713805 0.01722653] [0.00713805 0.08353407 0.02750816] [0.01722653 0.02750816 0.0572086 ]] Разложение Холецкого представляет положительно определенную матрицу как произведение L @ L.T, где L — нижняя треугольная матрица. Это позволяет генерировать коррелированные случайные величины путем умножения некоррелированных на L. Метод очень часто используется для симуляций Монте-Карло в оценке опционов и риск-моделировании. Функция solve() решает системы линейных уравнений A @ x = b эффективнее, чем вычисление обратной матрицы через inv(). Для специальных типов матриц (симметричные, ленточные, треугольные) существуют оптимизированные варианты: solve_banded, solve_triangular. Матричные функции expm(), logm(), sqrtm() вычисляют матричную экспоненту, логарифм и квадратный корень. Матричная экспонента применяется в непрерывных марковских процессах и решении линейных дифференциальных уравнений. Интегрирование (scipy.integrate) Модуль scipy.integrate предоставляет методы численного интегрирования функций и решения обыкновенных дифференциальных уравнений. Основные функции: quad для одномерного интегрирования; dblquad и tplquad для многомерного; odeint и solve_ivp для дифференциальных уравнений. Функция quad() вычисляет определенный интеграл с адаптивным выбором шага. Возвращает значение интеграла и оценку ошибки. Для функций с особенностями (полюса, разрывы) параметр points указывает проблемные точки для корректной обработки. from scipy.integrate import quad import numpy as np from scipy.stats import lognorm def payoff_integrand(S, K, r, sigma, T, S0): mu = S0 * np.exp((r - 0.5*sigma**2) * T) pdf = lognorm.pdf(S, s=sigma*np.sqrt(T), scale=mu) return np.maximum(S - K, 0) * pdf S0, K, r, sigma, T = 100, 105, 0.05, 0.2, 1.0 def black_scholes_call(S, K, r, sigma, T): from scipy.stats import norm d1 = (np.log(S/K) + (r + sigma**2/2)*T) / (sigma*np.sqrt(T)) d2 = d1 - sigma*np.sqrt(T) return S*norm.cdf(d1) - K*np.exp(-r*T)*norm.cdf(d2) bs_price = black_scholes_call(S0, K, r, sigma, T) print("Black-Scholes:", bs_price) res, err = quad(payoff_integrand, 0, np.inf, args=(K, r, sigma, T, S0)) res *= np.exp(-r*T) print("Интегрирование:", res, "Ошибка:", err) Black-Scholes: 8.021352235143176 Интегрирование: 7.332401247475618e-10 Ошибка: 1.5239889905593884e-09 Код сравнивает аналитическую формулу Блэка-Шоулза с численным интегрированием функции выплаты по логнормальному распределению цены актива. Функция quad() автоматически выбирает точки для вычисления интеграла, обеспечивая точность порядка 10^-8. Параметр args передает дополнительные аргументы в интегрируемую функцию. Интегрирование от 0 до бесконечности корректно обрабатывается благодаря адаптивному алгоритму. Решение обыкновенных дифференциальных уравнений (ОДУ) реализовано через solve_ivp(). Функция поддерживает несколько методов: RK45 (Runge-Kutta 4-5 порядка), RK23, Radau, BDF. Методы Radau и BDF подходят для жестких систем, где явные методы теряют стабильность. Для систем ОДУ функция принимает вектор-функцию правой части и начальные условия. Параметр t_eval определяет точки, в которых требуется решение. Опция dense_output=True возвращает интерполяционный объект для получения решения в произвольных точках. Работа с разреженными матрицами (scipy.sparse) Модуль scipy.sparse предоставляет форматы хранения и операции для разреженных матриц — матриц, в которых большинство элементов равны нулю. Для больших корреляционных матриц или графов связей разреженное представление сокращает потребление памяти в десятки раз. Основные форматы: CSR (Compressed Sparse Row) - оптимален для операций по строкам и умножения матрица-вектор; CSC (Compressed Sparse Column) - эффективен для операций по столбцам; COO (Coordinate) - удобен для построения матриц; LIL (List of Lists) - для инкрементальной модификации. from scipy.sparse import csr_matrix, lil_matrix import numpy as np dense = np.array([ [1, 0, 0, 2], [0, 0, 3, 0], [4, 0, 0, 5], [0, 6, 0, 0] ]) sparse_csr = csr_matrix(dense) print(f"Размер плотной матрицы: {dense.nbytes} байт") print(f"Размер разреженной матрицы: {sparse_csr.data.nbytes + sparse_csr.indices.nbytes + sparse_csr.indptr.nbytes} байт") sparse_lil = lil_matrix((1000, 1000)) sparse_lil[0, 100] = 1 sparse_lil[500, 500] = 2 sparse_csr_converted = sparse_lil.tocsr() vector = np.random.rand(1000) result = sparse_csr_converted @ vector print(f"Результат умножения: форма {result.shape}") Размер плотной матрицы: 128 байт Размер разреженной матрицы: 92 байт Результат умножения: форма (1000,) Представленный выше код демонстрирует создание разреженной матрицы и сравнение размеров. Формат LIL используется для построения матрицы, затем конвертируется в CSR для эффективных вычислений. Умножение разреженной матрицы на вектор выполняется за O(nnz), где nnz — количество ненулевых элементов. Модуль scipy.sparse.linalg включает итерационные методы для решения линейных систем с разреженными матрицами — такие как gmres, cg и bicgstab. В отличие от них, прямые методы из scipy.linalg плохо подходят для больших разреженных задач, поскольку требуют слишком много памяти и времени. Итерационные алгоритмы при использовании подходящего предобуславливателя обычно сходятся за несколько десятков или сотен итераций. Функция scipy.sparse.csgraph работает с графами, представленными разреженными матрицами смежности. Реализованы алгоритмы поиска кратчайших путей (dijkstra, floyd_warshall), поиска связных компонент, минимального остовного дерева. Применяется в анализе сетевых связей между активами или компаниями. Ключевые функции и методы SciPy Понимание параметров и особенностей работы ключевых функций SciPy необходимо, чтобы получать корректные и воспроизводимые результаты. В этом разделе мы подробно разберем наиболее востребованные функции библиотеки, уделяя особое внимание практическим нюансам и типичным моментам, на которые стоит обращать внимание при их использовании. Методы оптимизации: minimize() и differential_evolution() Функция minimize() представляет собой унифицированный интерфейс к различным алгоритмам оптимизации. Выбор метода через параметр method определяет скорость сходимости и способность обрабатывать ограничения. Метод SLSQP (Sequential Least Squares Programming) позволяет решать задачи оптимизации с ограничениями на равенства, неравенства и границы переменных. Он требует, чтобы целевая функция и ограничения были непрерывными. Для задач средней размерности (примерно 10–100 переменных) метод обычно сходится за 50–200 итераций. Если градиент не указан пользователем, он вычисляется численно с помощью конечных разностей с шагом порядка 10^-8. Метод L-BFGS-B (Limited-memory BFGS with Bounds) хорошо подходит для задач с большим числом переменных, когда имеются только границы переменных. Он не поддерживает общие ограничения типа равенств или неравенств. Метод использует ограниченную память для аппроксимации гессиана, что делает возможным решение задач с тысячами переменных. В задачах без сложных ограничений L-BFGS-B обычно сходится быстрее, чем SLSQP. Метод trust-constr основан на подходе доверительных областей и способен эффективно обрабатывать нелинейные ограничения. Его рекомендуется использовать для плохо обусловленных задач или при наличии сложных нелинейных ограничений. Хотя каждая итерация требует большего объема вычислений, метод отличается высокой надежностью и устойчивостью при поиске решения. from scipy.optimize import minimize, differential_evolution, LinearConstraint import numpy as np # Функция дисперсии портфеля def portfolio_variance(weights, cov_matrix): return weights @ cov_matrix @ weights # Ковариационная матрица cov = np.array([[0.04, 0.006, 0.01], [0.006, 0.09, 0.02], [0.01, 0.02, 0.06]]) # Ограничение: сумма весов = 1 linear_constraint = LinearConstraint(np.ones(3), 1, 1) bounds = [(0, 1)] * 3 x0 = np.array([1/3, 1/3, 1/3]) # Решение через SLSQP result_slsqp = minimize(portfolio_variance, x0, args=(cov,), method='SLSQP', bounds=bounds, constraints=[linear_constraint]) # Решение через Differential Evolution result_de = differential_evolution(portfolio_variance, bounds, args=(cov,), constraints=[linear_constraint], seed=42) print(f"SLSQP веса: {result_slsqp.x}") print(f"SLSQP дисперсия: {result_slsqp.fun:.6f}") print(f"DE веса: {result_de.x}") print(f"DE дисперсия: {result_de.fun:.6f}") SLSQP веса: [0.54110485 0.18651425 0.27238089] SLSQP дисперсия: 0.025485 DE веса: [0.5410441 0.186568 0.2723879] DE дисперсия: 0.025485 Код сравнивает локальную оптимизацию (SLSQP) и глобальную (differential_evolution) при минимизации дисперсии портфеля. Для выпуклых задач оба метода находят одинаковое решение, однако differential_evolution обычно работает в 10–50 раз медленнее. Параметр seed обеспечивает воспроизводимость результатов стохастического алгоритма. Ограничение на сумму весов реализовано через LinearConstraint, что делает его совместимым с обоими методами. Параметр options в minimize() позволяет контролировать точность и производительность оптимизации. Среди ключевых опций: maxiter — максимальное число итераций; ftol — толерантность по изменению функции (например, ftol=1e-6 останавливает алгоритм, если функция изменяется меньше 10⁻⁶); gtol — толерантность по градиенту. Параметр jac позволяет передать аналитический градиент целевой функции. Для сложных задач аналитический градиент ускоряет оптимизацию в 5–10 раз и повышает точность. Если jac=True, функция должна возвращать кортеж (значение, градиент). Методы глобальной оптимизации Глобальная оптимизация находит минимум без предположений о выпуклости функции. Три основных метода в SciPy: differential_evolution; basin_hopping; dual_annealing. Метод Differential evolution реализует эволюционный алгоритм с популяцией кандидатов. Параметр strategy определяет стратегию мутации: 'best1bin' использует лучшего индивида, 'rand1bin' — случайного. Параметр popsize задает размер популяции как множитель от размерности задачи. Значение popsize=15 при 10 переменных означает популяцию 150 индивидов. Большая популяция улучшает исследование пространства, но замедляет сходимость. from scipy.optimize import differential_evolution, shgo import numpy as np def rastrigin(x): return 10*len(x) + sum(x**2 - 10*np.cos(2*np.pi*x)) bounds = [(-5, 5)] * 5 result_de = differential_evolution(rastrigin, bounds, seed=42, maxiter=1000, popsize=20) result_shgo = shgo(rastrigin, bounds, n=200, iters=3) print(f"DE минимум: {result_de.fun:.6f} в точке {result_de.x}") print(f"SHGO минимум: {result_shgo.fun:.6f} в точке {result_shgo.x}") DE минимум: 0.994959 в точке [-4.76610124e-09 -2.61188421e-09 -5.06715839e-09 -9.94958639e-01 -4.04226523e-09] SHGO минимум: 0.000000 в точке [-2.48724349e-11 -2.48724349e-11 -2.48724349e-11 -2.48724349e-11 -2.48724349e-11] Код минимизирует функцию Растригина — стандартный бенчмарк с множественными локальными минимумами. Глобальный минимум в точке (0,0,...,0) с значением 0. Метод Differential evolution находит решение за 1000-2000 вычислений функции. SHGO (Simplicial Homology Global Optimization) использует симплициальные разбиения и находит решение быстрее для гладких функций. Другие методы глобальной оптимизации работают по-другому: Basin hopping комбинирует случайные возмущения с локальной оптимизацией. Параметр T контролирует вероятность принятия худших решений (аналог температуры в simulated annealing). Параметр niter задает количество итераций прыжков. Метод эффективен для функций с широкими областями притяжения локальных минимумов. Dual annealing сочетает simulated annealing с локальным поиском. Параметр initial_temp определяет начальную температуру, контролирующую агрессивность исследования. Метод автоматически адаптирует температуру в процессе оптимизации. Сходимость медленнее differential evolution, но для некоторых классов функций надежнее. Статистические распределения и параметрические тесты Модуль scipy.stats реализует распределения как объекты с единым интерфейсом. Каждое распределение поддерживает методы pdf/pmf, cdf, ppf, rvs, fit. Параметризация следует стандартным математическим определениям, что требует внимания при использовании. Нормальное распределение norm задается параметрами loc (среднее) и scale (стандартное отклонение). По умолчанию loc=0, scale=1. Для генерации выборки с заданными параметрами: norm.rvs(loc=0.05, scale=0.2, size=1000). from scipy import stats import numpy as np returns = np.array([0.02, -0.01, 0.03, 0.00, -0.02, 0.04, 0.01]) mu, sigma = stats.norm.fit(returns) print(f"Оценка параметров: μ={mu:.4f}, σ={sigma:.4f}") stat, p_value = stats.shapiro(returns) print(f"Тест Шапиро-Уилка: p-value={p_value:.4f}") stat_jb, p_value_jb = stats.jarque_bera(returns) print(f"Тест Харке-Бера: p-value={p_value_jb:.4f}") t_stat, t_pvalue = stats.ttest_1samp(returns, 0) print(f"t-тест (H0: μ=0): p-value={t_pvalue:.4f}") Оценка параметров: μ=0.0100, σ=0.0200 Тест Шапиро-Уилка: p-value=0.9493 Тест Харке-Бера: p-value=0.7962 t-тест (H0: μ=0): p-value=0.2666 Метод fit() возвращает оценки параметров максимального правдоподобия. Для нормального распределения это выборочное среднее и стандартное отклонение. Тест Шапиро-Уилка проверяет нормальность для выборок размером 3-5000. Тест Харке-Бера основан на асимметрии и эксцессе, применим для больших выборок (n>50). Значения p-value < 0.05 указывают на отклонение от нормальности на уровне значимости 5%. Одновыборочный t-тест проверяет гипотезу о равенстве среднего заданному значению. Для проверки значимого отличия доходности от нуля используется ttest_1samp(returns, 0). Двухвыборочный ttest_ind() сравнивает средние двух независимых выборок, ttest_rel() — зависимых выборок (парные наблюдения). Распределение Стьюдента (t) используется для моделирования доходностей с тяжелыми хвостами. Параметр df (degrees of freedom) контролирует толщину хвостов: меньшие значения соответствуют более тяжелым хвостам. Значение df=5 моделирует распределение с эксцессом около 6, что типично для дневных доходностей акций. Непараметрические статистические методы Непараметрические тесты не требуют предположений о виде распределения данных. Они устойчивы к выбросам и применимы к распределениям произвольной формы, однако имеют меньшую мощность при нормальных данных. Тест Манна-Уитни (mannwhitneyu) сравнивает распределения двух независимых выборок. Нулевая гипотеза: распределения одинаковы. Альтернатива может быть двусторонней ('two-sided') или односторонней ('less', 'greater'). Параметр alternative определяет направление теста. from scipy import stats import numpy as np strategy_a = np.array([0.02, 0.01, 0.03, -0.01, 0.02]) strategy_b = np.array([0.01, 0.00, 0.01, 0.02, 0.00]) stat_mw, p_mw = stats.mannwhitneyu(strategy_a, strategy_b, alternative='two-sided') print(f"Тест Манна-Уитни: p-value={p_mw:.4f}") stat_ks, p_ks = stats.ks_2samp(strategy_a, strategy_b) print(f"Тест Колмогорова-Смирнова: p-value={p_ks:.4f}") corr_pearson, p_pearson = stats.pearsonr(strategy_a, strategy_b) corr_spearman, p_spearman = stats.spearmanr(strategy_a, strategy_b) print(f"Корреляция Пирсона: {corr_pearson:.4f} (p={p_pearson:.4f})") print(f"Корреляция Спирмена: {corr_spearman:.4f} (p={p_spearman:.4f})") Тест Манна-Уитни: p-value=0.3902 Тест Колмогорова-Смирнова: p-value=0.8730 Корреляция Пирсона: -0.5123 (p=0.3775) Корреляция Спирмена: -0.2163 (p=0.7268) Тест Колмогорова–Смирнова (ks_2samp) сравнивает эмпирические функции распределения двух выборок. Он чувствителен к различиям в любой части распределения — в центре, хвостах или асимметрии. В отличие от него, тест Манна–Уитни фокусируется преимущественно на различиях медиан между выборками. Корреляция Спирмена измеряет монотонную зависимость между переменными на основе рангов наблюдений. В отличие от корреляции Пирсона, она не требует линейной связи и устойчива к выбросам. Для сильно нелинейных зависимостей коэффициент Спирмена может быть выше, чем Пирсона. Значение, близкое к 1, указывает на возрастающую монотонную зависимость, а близкое к -1 — на убывающую. Тест Уилкоксона (wilcoxon) применяется для парных выборок как непараметрическая альтернатива парному t-тесту. Он проверяет, отличается ли медиана разностей между парами значений от нуля. Часто используется для сравнения результатов стратегии до и после модификации на одних и тех же активах. Функции интерполяции и сплайны Интерполяция в SciPy реализована через создание интерполяционных объектов, которые можно вызывать как функции. Основные классы: interp1d; CubicSpline; UnivariateSpline; RectBivariateSpline. CubicSpline строит кубический сплайн с непрерывными первой и второй производными. Параметр bc_type определяет граничные условия: 'not-a-knot' (по умолчанию); 'clamped' (заданные производные на краях); 'natural' (вторые производные на краях равны нулю); 'periodic' (периодическая функция). from scipy.interpolate import CubicSpline, UnivariateSpline import numpy as np import matplotlib.pyplot as plt # Данные x = np.array([0, 1, 2, 3, 4, 5]) y = np.array([1.0, 0.9, 1.1, 1.3, 1.2, 1.4]) # Интерполяции cs = CubicSpline(x, y, bc_type='natural') cs_clamped = CubicSpline(x, y, bc_type=((1, 0.0), (1, 0.0))) us = UnivariateSpline(x, y, s=0) us_smooth = UnivariateSpline(x, y, s=0.1) # Плотная сетка для графиков x_dense = np.linspace(0, 5, 200) y_cs = cs(x_dense) y_cs_clamped = cs_clamped(x_dense) y_us = us(x_dense) y_us_smooth = us_smooth(x_dense) # Печать производных и интеграла print(f"Производная в точке 2.5 (CubicSpline): {cs.derivative()(2.5):.4f}") print(f"Интеграл от 0 до 5 (CubicSpline): {cs.integrate(0, 5):.4f}") # Визуализация plt.figure(figsize=(10, 6)) plt.plot(x_dense, y_cs, label="CubicSpline (natural)", linewidth=2) plt.plot(x_dense, y_cs_clamped, label="CubicSpline (clamped)", linestyle="--", linewidth=2) plt.plot(x_dense, y_us, label="UnivariateSpline s=0", linestyle=":", linewidth=2) plt.plot(x_dense, y_us_smooth, label="UnivariateSpline s=0.1", linestyle="-.", linewidth=2) plt.scatter(x, y, color='red', zorder=5, label="Исходные точки") plt.title("Сравнение интерполяций и сплайнов") plt.xlabel("x") plt.ylabel("y") plt.legend() plt.grid(True) plt.show() Производная в точке 2.5 (CubicSpline): 0.2273 Интеграл от 0 до 5 (CubicSpline): 5.6605 Рис. 2: Сравнение интерполяций и сплайнов в SciPy Метод CubicSpline с граничными условиями 'natural' минимизирует кривизну на концах сплайна, что обеспечивает «естественное» продолжение кривой за пределы исходных точек. Граничные условия 'clamped' позволяют явно задавать значения производных на концах через кортеж (порядок, значение). Метод UnivariateSpline с параметром s=0 точно проходит через все исходные точки. Если s>0, включается сглаживание: сплайн минимизирует сумму квадратов отклонений от точек плюс добавляет штраф за кривизну, взвешенный коэффициентом s. Объекты CubicSpline и UnivariateSpline поддерживают методы derivative() и integrate(), что позволяет вычислять производные и интегралы сплайнов без явного дифференцирования или интегрирования вручную. Это удобно для анализа скоростей изменения и площадей под кривыми. Для двумерной интерполяции используется RectBivariateSpline, который требует данных на прямоугольной сетке. Параметры kx и ky задают степень сплайна по каждой оси (от 1 до 5). Например, kx=3, ky=3 создает бикубический сплайн. Параметр s работает аналогично UnivariateSpline и контролирует степень сглаживания кривой. Решение систем линейных уравнений Модуль scipy.linalg предоставляет специализированные решатели для различных типов матриц. Выбор функции на основе структуры матрицы ускоряет вычисления в десятки раз. Функция solve() решает линейную систему A⋅x=b с помощью LU-разложения. Для симметричных положительно определенных матриц можно использовать solve(A, b, assume_a='pos'), что применяет разложение Холецкого и работает примерно в два раза быстрее. Параметр assume_a задает тип матрицы и принимает следующие значения: 'gen' — общая матрица; 'sym' — симметричная; 'her' — эрмитова; 'pos' — положительно определенная. Использование правильного значения assume_a позволяет ускорить вычисления и повысить численную стабильность. from scipy.linalg import solve, solve_banded, cho_factor, cho_solve import numpy as np A = np.array([[4, 1, 0], [1, 4, 1], [0, 1, 4]]) b = np.array([1, 2, 3]) x_general = solve(A, b) x_pos = solve(A, b, assume_a='pos') print(f"Решение (общее): {x_general}") print(f"Решение (положительно определенная): {x_pos}") c, low = cho_factor(A) x_cho = cho_solve((c, low), b) print(f"Решение (Холецкий): {x_cho}") ab = np.array([[0, 1, 1], [4, 4, 4], [1, 1, 0]]) x_band = solve_banded((1, 1), ab, b) print(f"Решение (ленточная): {x_band}") Решение (общее): [0.17857143 0.28571429 0.67857143] Решение (положительно определенная): [0.17857143 0.28571429 0.67857143] Решение (Холецкий): [0.17857143 0.28571429 0.67857143] Решение (ленточная): [0.17857143 0.28571429 0.67857143] Функции cho_factor() и cho_solve() разделяют разложение и решение системы. При многократном решении с одной матрицей A и разными правыми частями b разложение выполняется один раз, экономя время. Формат хранения разложения включает кортеж (c, low), где c — матрица разложения, low — флаг нижнего треугольника. Функция solve_banded() решает системы с ленточными матрицами. Параметр (l, u) указывает количество поддиагоналей и наддиагоналей. Матрица передается в специальном сжатом формате: строки содержат диагонали. Для трехдиагональной матрицы (l=1, u=1) требуется массив размера 3×n. Решение трехдиагональной системы размера 10000 через solve_banded() выполняется в 1000 раз быстрее общего solve(). Итерационные методы из scipy.sparse.linalg применяются для очень больших разреженных систем. Функция cg() (Conjugate Gradient) решает симметричные положительно определенные системы. Функция gmres() обрабатывает несимметричные системы. Параметр tol задает относительную точность решения, maxiter — максимум итераций. Параметры SciPy и их влияние на результаты Корректный выбор параметров функций SciPy определяет точность, скорость и надежность вычислений. Рассмотрим наиболее важные параметры основных групп функций. Параметры оптимизаторов Толерантности ftol, gtol, xtol контролируют критерии остановки оптимизации. Параметр ftol задает относительное изменение целевой функции для остановки. Значение ftol=1e-6 означает остановку при |f(x_k) - f(x_{k-1})| / max(|f(x_k)|, 1) < 1e-6. Для финансовых задач ftol=1e-8 обеспечивает точность, достаточную для большинства приложений. Параметр gtol определяет норму градиента для остановки. Значение gtol=1e-5 требует ||grad f(x)|| < 1e-5. Малые значения gtol увеличивают число итераций, но гарантируют близость к стационарной точке. Для невыпуклых задач достижение малого градиента не гарантирует глобальный оптимум. Параметр maxiter ограничивает количество итераций. Для SLSQP типичные значения 100-500, для L-BFGS-B до 15000. Превышение maxiter без достижения сходимости указывает на проблемы: плохое начальное приближение, плохая обусловленность, невыпуклость или некорректность постановки задачи. from scipy.optimize import minimize import numpy as np def rosenbrock(x): return sum(100*(x[1:] - x[:-1]**2)**2 + (1 - x[:-1])**2) x0 = np.array([0, 0, 0]) result_default = minimize(rosenbrock, x0, method='L-BFGS-B') print(f"Итерации (default): {result_default.nit}, функция: {result_default.fun:.6f}") result_strict = minimize(rosenbrock, x0, method='L-BFGS-B', options={'ftol': 1e-10, 'gtol': 1e-8, 'maxiter': 1000}) print(f"Итерации (strict): {result_strict.nit}, функция: {result_strict.fun:.6f}") result_loose = minimize(rosenbrock, x0, method='L-BFGS-B', options={'ftol': 1e-4, 'gtol': 1e-3, 'maxiter': 50}) print(f"Итерации (loose): {result_loose.nit}, функция: {result_loose.fun:.6f}") Итерации (default): 25, функция: 0.000000 Итерации (strict): 25, функция: 0.000000 Итерации (loose): 21, функция: 0.000002 Функция Розенброка является стандартным тестом для оптимизаторов благодаря своей узкой параболической «долине». Глобальный минимум достигается в точке (1,1,1) с значением функции равным 0. Строгие настройки толерантностей (ftol, gtol) увеличивают число итераций в 2–3 раза, но повышают точность оптимизации с 10^-6 до 10^-10. Ослабленные толерантности сокращают время вычислений, однако снижают точность до 10^-4. Параметр disp=True включает вывод информации о процессе оптимизации. Это полезно при отладке, так как позволяет отслеживать значения функции, нормы градиента и причины остановки алгоритма. В продакшене обычно используют disp=False, чтобы минимизировать вывод. Начальное приближение x0 играет важную роль для локальных оптимизаторов. В задачах портфельной оптимизации равные веса 1/n часто служат разумной стартовой точкой. Для калибровки моделей можно использовать оценки из литературы или упрощенные формулы. Неподходящее x0 может привести к медленной сходимости или попаданию в локальный минимум. Настройки статистических процедур Параметр alternative в статистических тестах определяет альтернативную гипотезу: 'two-sided' (двусторонняя); 'less' (меньше); 'greater' (больше). Для проверки положительности средней доходности используется alternative='greater'. Нужно также учитывать, что двусторонний тест имеет меньшую мощность против односторонних альтернатив. Параметр nan_policy контролирует обработку пропущенных значений: 'propagate' (возвращает NaN); 'raise' (выбрасывает ошибку); 'omit' (исключает NaN из анализа). Значение 'omit' удобно для данных с пропусками, однако при этом меняет размер выборки, что влияет на мощность теста. from scipy import stats import numpy as np data1 = np.array([0.02, 0.01, 0.03, np.nan, 0.02]) data2 = np.array([0.01, 0.00, 0.01, 0.02, 0.00]) try: result_raise = stats.ttest_ind(data1, data2, nan_policy='raise') except ValueError as e: print(f"Ошибка: {e}") result_omit = stats.ttest_ind(data1, data2, nan_policy='omit') print(f"t-тест (omit): статистика={result_omit.statistic:.4f}, p-value={result_omit.pvalue:.4f}") result_propagate = stats.ttest_ind(data1, data2, nan_policy='propagate') print(f"t-тест (propagate): статистика={result_propagate.statistic}, p-value={result_propagate.pvalue}") Ошибка: The input contains nan values t-тест (omit): статистика=2.1602, p-value=0.0676 t-тест (propagate): статистика=nan, p-value=nan Приведенный код демонстрирует что: Политика 'raise' прерывает выполнение при обнаружении NaN, требуя явной предобработки данных; Политика 'omit' автоматически удаляет пропуски, уменьшая размер выборки с 5 до 4; Политика 'propagate' возвращает NaN в результатах, что полезно для пайплайнов с последующей обработкой. Параметр axis определяет направление применения функции к многомерным массивам. Значение axis=0 применяет операцию вдоль столбцов, axis=1 — вдоль строк. Для матрицы доходностей размера (время, активы) расчет средней доходности каждого актива требует axis=0. Параметр ddof (degrees of freedom, «степени свободы») при вычислении стандартного отклонения и дисперсии определяет, какой знаменатель использовать. При ddof=0 используется n−1, что дает несмещенную оценку дисперсии для выборки. Для выборочной дисперсии обычно применяют ddof=1. Обратите внимание, что в NumPy по умолчанию ddof=0, а в pandas — ddof=1, поэтому при переходе между библиотеками важно учитывать это различие, чтобы результаты были согласованы. Точность численных методов Параметры, контролирующие точность численного интегрирования и решения дифференциальных уравнений, определяют баланс между точностью и скоростью. Функция quad() принимает параметры epsabs (абсолютная ошибка) и epsrel (относительная ошибка). По умолчанию: epsabs=1.49e-8, epsrel=1.49e-8. Интегрирование останавливается при достижении: error < max(epsabs, epsrel * |integral|). Для функций с малыми значениями интеграла (порядка 1e-6) требуется уменьшение epsabs до 1e-10. from scipy.integrate import quad import numpy as np def integrand(x): return np.exp(-x**2) result_default, error_default = quad(integrand, 0, 10) print(f"Интеграл (default): {result_default:.10f}, ошибка: {error_default:.2e}") result_strict, error_strict = quad(integrand, 0, 10, epsabs=1e-12, epsrel=1e-12) print(f"Интеграл (strict): {result_strict:.10f}, ошибка: {error_strict:.2e}") result_loose, error_loose = quad(integrand, 0, 10, epsabs=1e-4, epsrel=1e-4) print(f"Интеграл (loose): {result_loose:.10f}, ошибка: {error_loose:.2e}") Интеграл (default): 0.8862269255, ошибка: 1.85e-13 Интеграл (strict): 0.8862269255, ошибка: 1.85e-13 Интеграл (loose): 0.8862269255, ошибка: 8.97e-07 Интеграл от exp(-x²) от 0 до 10 близок к √π/2 ≈ 0.886226925. Строгие толерантности дают ошибку порядка 1e-12, но требуют больше вычислений функции. Ослабленные толерантности сокращают вычисления в 3-5 раз при ошибке порядка 1e-4. Параметр limit в quad() задает максимальное количество подинтервалов для адаптивного разбиения. По умолчанию limit=50. Для функций с резкими пиками или разрывами требуется увеличение до 100-200. Превышение limit без достижения точности указывает на проблемы численной устойчивости или некорректность интеграла. Функция solve_ivp() принимает параметры rtol (относительная толерантность) и atol (абсолютная толерантность). Локальная ошибка контролируется условием error < atol + rtol * |y|. По умолчанию rtol=1e-3, atol=1e-6. Для жестких систем требуется rtol=1e-6, atol=1e-9 и метод 'Radau' или 'BDF'. Параметр max_step ограничивает шаг интегрирования. Для систем с быстрыми осцилляциями слишком большой шаг пропускает важные детали. Установка max_step в 1/10 периода осцилляций обеспечивает корректное разрешение динамики. Частые ошибки и их решения Практическое применение SciPy часто сопровождается типичными проблемами, связанными с численной нестабильностью, некорректными параметрами и особенностями алгоритмов. Ниже рассмотрены наиболее распространенные ошибки и способы их устранения. Проблемы сходимости в оптимизации Основные ошибки: Сходимость к локальному минимуму. Сообщение "Optimization terminated successfully" не гарантирует глобальный минимум. Локальные методы сходятся к ближайшей стационарной точке. Некорректный градиент. Ошибка "Positive directional derivative for linesearch" может быть вызвана неправильным аналитическим градиентом, численной нестабильностью или слишком малым шагом дифференцирования. Решения: проверить градиент через check_grad(), увеличить epsilon, использовать методы без градиента (Nelder–Mead). Плохая обусловленность задачи. Медленная сходимость или превышение maxiter связано с числом обусловленности κ = λₘₐₓ / λₘᵢₙ. Для κ > 10⁶ рекомендуется масштабирование переменных, предобуславливание или использование trust-constr. Нарушение ограничений. Слишком строгие толерантности или конфликтующие ограничения могут привести к нарушению условий. Параметр constraint_tolerance задает допустимое отклонение. Увеличение его значения улучшает сходимость, однако снижает точность ограничений. Некорректная работа со статистическими распределениями Ошибка "Domain error" в функциях распределений возникает при передаче параметров вне допустимой области. Например, параметр scale (стандартное отклонение) должен быть положительным, а параметры формы (shape) имеют специфические ограничения для каждого распределения. Чтобы избежать подобных ошибок, рекомендуется: Проверять корректность входных данных перед вызовом функций распределения; Использовать встроенные методы проверки параметров (например, scipy.stats.rv_continuous._argcheck); При необходимости ограничивать значения параметров через np.clip или условные проверки. Это помогает гарантировать корректные вычисления вероятностей, плотностей и кумулятивных функций распределения. Ошибки размерности массивов Несоответствие размерностей массивов — одна из самых частых причин ошибок при работе с многомерными данными. SciPy ожидает определенные форматы входных данных для функций линейной алгебры, статистики и интерполяции. Наиболее часто ошибки подобного рода возникают при умножении матриц с несовпадающими размерами, например, A @ x, где A — (3×4), а x — (3,), вместо (4,). Либо при передаче двумерного массива вместо одномерного в функции, ожидающей вектор, например, np.mean(array, axis=1) с неверной ориентацией. Как избежать ошибок: Всегда проверяйте размерности массивов с помощью array.shape перед операциями; Используйте методы np.reshape, np.ravel или .T для корректировки формы массивов; Для линейной алгебры предпочтительно явно указывать двумерные структуры ((n,1) для векторов-столбцов), чтобы избежать непредвиденных ошибок при матричных операциях. Численная нестабильность Потеря точности при работе с очень малыми или очень большими числами возникает из-за ограниченной точности арифметики с плавающей точкой. Стандартный тип float64 обеспечивает около 15 десятичных знаков точности и диапазон значений от 10⁻³⁰⁸ до 10³⁰⁸. Численная нестабильность может проявляться как накопление ошибок при суммировании большого числа слагаемых, деление на очень малые значения или вычитание близких чисел, приводящее к потере значащих цифр. Как снизить риск ошибок: Использовать функции с численно стабильными алгоритмами (scipy.special для специальных функций, np.dot вместо ручного суммирования); Масштабировать данные, чтобы значения находились в диапазоне ~1; При необходимости использовать более точные типы (float128 или библиотеки для произвольной точности, например, mpmath). Медленные вычисления Типичная ошибка при работе с большими массивами — использование циклов Python по элементам вместо векторных операций. Это сильно замедляет выполнение, особенно для массивов из тысяч и миллионов элементов. Решение: использовать векторизацию с функциями NumPy и SciPy, которые оптимизированы для работы с массивами целиком. Замена циклов на векторные операции ускоряет код в 10–100 раз. Дополнительные источники медленной работы: Ненужные копирования массивов; Сложные условные выражения внутри циклов; Многократные вызовы функций внутри больших циклов. Чтобы избежать этих проблем, рекомендуется применять встроенные функции (np.sum, np.mean, np.dot), методы массивов и минимизировать лишние преобразования данных. Заключение Библиотека SciPy превращает Python в мощную платформу для численных вычислений, объединяя десятки алгоритмов под единым удобным интерфейсом. Библиотека избавляет от необходимости реализовывать базовые методы с нуля и позволяет сосредоточиться на решении практических задач. От оптимизации инвестиционных портфелей до статистического анализа доходностей — инструменты SciPy покрывают большинство сценариев количественного анализа. Чтобы использовать SciPy эффективно, важно понимать ограничения и предположения алгоритмов: выбор между локальной и глобальной оптимизацией определяется природой задачи, а интерпретация статистических тестов требует проверки условий применимости. Корректность и численная стабильность результатов достигаются масштабированием данных и грамотной настройкой параметров, таких как толерантности или предобуславливание. Следование данным принципам позволяет работать с SciPy уверенно и последовательно, превращая набор функций в надежный инструмент для точных и воспроизводимых вычислений в любых проектах, от учебных задач до реального анализа данных. ### Метод нелинейного снижения размерности t-SNE Высокая размерность данных создает фундаментальную проблему - датасеты с сотнями и тысячами признаков плохо обучаются в моделях машинного обучения, плюс их невозможно визуализировать, что затрудняет понимание структуры данных, выявление паттернов и валидацию гипотез. Методы снижения размерности решают эту задачу, проецируя многомерные данные в пространство низкой размерности с сохранением важных характеристик исходного распределения. Однако не все методы снижения размерности одинаково эффективны. Линейные методы, такие как Principal Component Analysis (PCA), эффективны для данных с линейными зависимостями. PCA находит ортогональные направления максимальной дисперсии и проецирует данные на главные компоненты. Но реальные данные часто содержат нелинейные структуры: кластеры сложной формы, многообразия произвольной топологии, иерархические зависимости. В таких случаях PCA искажает структуру данных, объединяя удаленные точки и разрывая близкие. t-Distributed Stochastic Neighbor Embedding (t-SNE) представляет класс нелинейных методов снижения размерности. Алгоритм фокусируется на сохранении локальной структуры данных: точки, близкие в исходном пространстве, остаются близкими в визуализации. Метод особенно эффективен для выявления кластеров и групп схожих объектов, что делает его эффективным инструментом для исследовательского анализа данных в computer vision, natural language processing, биоинформатике и других областях. Математическая основа t-SNE Алгоритм t-SNE работает в два этапа: Построение вероятностного представления сходства в исходном пространстве; Оптимизация расположения точек в пространстве низкой размерности. Ключевая идея заключается в моделировании близости между точками через условные вероятности и минимизации расхождения между распределениями в разных пространствах. Рис. 1: Концептуальная схема работы t-SNE. Слева: исходные данные в трехмерном пространстве с тремя кластерами. В центре: процесс оптимизации через минимизацию дивергенции Кульбака-Лейблера. Справа: результат проекции в двумерное пространство с сохранением локальной структуры кластеров Вероятностное представление сходства В исходном многомерном пространстве для каждой пары точек i и j вычисляется условная вероятность p(j|i), которая отражает вероятность того, что точка i выберет точку j в качестве соседа. Вероятность моделируется через гауссово распределение с центром в точке i по формуле: p(j|i) = exp(-||xᵢ - xⱼ||² / 2σᵢ²) / Σₖ≠ᵢ exp(-||xᵢ - xₖ||² / 2σᵢ²) где: xᵢ, xⱼ — векторы признаков точек i и j в исходном пространстве; ||xᵢ - xⱼ||² — квадрат евклидова расстояния между точками; σᵢ — параметр масштаба гауссова распределения для точки i; Σₖ≠ᵢ — сумма по всем точкам кроме i (нормализация). Формула определяет, что близкие точки получают высокую вероятность, удаленные — низкую. Параметр σᵢ индивидуален для каждой точки и определяется через гиперпараметр перплексии - perplexity. Для симметризации вводится совместная вероятность: pᵢⱼ = (p(j|i) + p(i|j)) / 2N Где N — общее количество точек. Симметризация устраняет асимметрию условных вероятностей и упрощает оптимизацию. В пространстве визуализации (обычно 2D или 3D) используется распределение Стьюдента с одной степенью свободы (распределение Коши). Для точек yᵢ и yⱼ в низкоразмерном пространстве вероятность определяется как: qᵢⱼ = (1 + ||yᵢ - yⱼ||²)⁻¹ / Σₖ≠ₗ (1 + ||yₖ - yₗ||²)⁻¹ где: yᵢ, yⱼ — координаты точек в пространстве визуализации; (1 + ||yᵢ - yⱼ||²)⁻¹ — ядро распределения Стьюдента; Σₖ≠ₗ — нормализующая константа по всем парам точек. Использование распределения Стьюдента вместо нормального помогает решить проблему «скопления точек в толпу» (crowding problem): в пространстве низкой размерности просто не хватает места, чтобы корректно разнести все точки, сохранив исходные расстояния. Благодаря «тяжелым хвостам» распределения Стьюдента умеренно удаленные точки могут располагаться дальше друг от друга, не получая чрезмерного штрафа в функции потерь. Рис. 2: Роль распределения Стьюдента в t-SNE. Слева: сравнение нормального распределения и распределения Стьюдента. Тяжелые хвосты распределения Стьюдента допускают большие расстояния без сильного штрафа. Справа: решение crowding problem — при проекции в низкую размерность распределение Стьюдента позволяет точкам разместиться свободнее, избегая чрезмерного сжатия Оптимизация через дивергенцию Кульбака-Лейблера Алгоритм минимизирует расхождение между распределениями P (исходное пространство) и Q (пространство визуализации) через дивергенцию Кульбака-Лейблера (KL дивергенцию): KL(P||Q) = Σᵢ Σⱼ pᵢⱼ log(pᵢⱼ / qᵢⱼ) где: P — распределение сходства в исходном пространстве; Q — распределение сходства в пространстве визуализации; pᵢⱼ, qᵢⱼ — совместные вероятности для пары точек i, j. Дивергенция измеряет, насколько распределение Q отличается от P. Минимизация достигается градиентным спуском по координатам yᵢ в пространстве визуализации. Градиент функции потерь имеет вид: ∂KL/∂yᵢ = 4 Σⱼ (pᵢⱼ - qᵢⱼ)(yᵢ - yⱼ)(1 + ||yᵢ - yⱼ||²)⁻¹ Градиент содержит два типа сил: Притяжение близких точек (когда pᵢⱼ > qᵢⱼ); Отталкивание удаленных точек (когда pᵢⱼ < qᵢⱼ). Множитель (1 + ||yᵢ - yⱼ||²)⁻¹ обеспечивает, что сила взаимодействия убывает с расстоянием. Оптимизация выполняется итеративно с использованием градиентного спуска с momentum. На практике применяют адаптивную скорость обучения и прием раннего увеличения значений (early exaggeration): на первых итерациях вероятности pᵢⱼ искусственно завышают — обычно в 4–12 раз. Это помогает точкам быстрее формировать устойчивые кластеры и снижает риск попадания в локальные минимумы. Ключевые гиперпараметры Качество визуализации при использовании t-SNE сильно зависит от корректной настройки гиперпараметров. Неверно выбранные значения могут привести к артефактам: искусственному разделению кластеров, объединению несвязанных групп или потере общей структуры данных. Перплексия - влияние на структуру Перплексия (perplexity) определяет эффективное число соседей, которое учитывается при построении локальной окрестности каждой точки. Параметр связан с энтропией условного распределения вероятностей и задается пользователем перед запуском алгоритма. Математически перплексия вычисляется как: Perplexity = 2^H(Pᵢ) Где H(Pᵢ) — энтропия распределения вероятностей для точки i: H(Pᵢ) = -Σⱼ p(j|i) log₂ p(j|i) Другие параметры формулы: H(Pᵢ) — энтропия условного распределения точки i; p(j|i) — условная вероятность выбора точки j как соседа точки i; 2^H(Pᵢ) — экспоненциальное преобразование энтропии в число соседей. Формула показывает, что перплексия представляет эффективное число точек в локальной окрестности. Для каждой точки алгоритм подбирает параметр σᵢ методом бинарного поиска так, чтобы энтропия распределения соответствовала заданной перплексии. Выбор значения перплексии определяет баланс между локальной и глобальной структурой данных: Низкая перплексия (5-15) фокусируется на ближайших соседях, выявляет мелкие кластеры и локальные паттерны, но может разбить крупные группы на фрагменты; Высокая перплексия (50-100) учитывает более широкую окрестность, сохраняет глобальную структуру и крупные кластеры, но теряет детали локальной организации; Рекомендуемый диапазон 30-50 обеспечивает компромисс для большинства задач. Для датасетов с разным размером оптимальная перплексия масштабируется: малые выборки (100-500 точек) требуют перплексию 10-30, средние (1000-5000) — 30-50, крупные (10000+) — 50-100. Превышение перплексии над количеством точек в кластере приводит к его искусственному разделению. Рис. 3: Влияние перплексии на визуализацию t-SNE. Датасет содержит два крупных кластера (синий, оранжевый) и два мелких подкластера (зеленый, красный). При perplexity=5 крупные кластеры разбиваются на фрагменты. При perplexity=15 структура более связна, но все еще фрагментирована. При perplexity=30 достигается баланс: все группы различимы. При perplexity=50 мелкие подкластеры сливаются, детали теряются Практический подход к выбору: запустить t-SNE с несколькими значениями перплексии (например, 10, 30, 50, 100) и сравнить визуализации. Стабильные структуры, появляющиеся при разных значениях, соответствуют реальным паттернам в данных. Артефакты, зависящие от перплексии, требуют дополнительной проверки. Количество итераций и learning rate Градиентная оптимизация t-SNE требует достаточного числа итераций для сходимости. Недостаточное количество итераций оставляет точки в локальных минимумах, избыточное — увеличивает время вычислений без улучшения качества. Стандартные настройки: Минимум 250 итераций для простых датасетов с четкой структурой; 1000 итераций как базовое значение для большинства задач; 5000-10000 итераций для сложных данных с неочевидной структурой или большого размера. Мониторинг сходимости выполняется через значение функции потерь KL-дивергенции. Если потери продолжают снижаться к концу оптимизации, требуется увеличить число итераций. Learning rate контролирует размер шага в градиентном спуске. Типичные значения находятся в диапазоне 10-1000, при этом оптимальное значение зависит от размера датасета. Формула для автоматического подбора: learning_rate = max(N / early_exaggeration / 4, 50) где: N — количество точек; early_exaggeration — коэффициент преувеличения (обычно 12). Для датасета из 1000 точек это дает learning_rate ≈ 200. Слишком высокий learning rate вызывает нестабильность: точки совершают большие скачки и не успевают сформировать стабильные кластеры. Слишком низкий — замедляет сходимость и оставляет структуру недоформированной. Параметр momentum (обычно 0.5 на начальных итерациях, затем 0.8) сглаживает траектории оптимизации и ускоряет сходимость. Early exaggeration применяется на первых 50-250 итерациях: все значения pᵢⱼ умножаются на коэффициент 4-12. Преувеличение усиливает притяжение близких точек, помогая им быстро сформировать плотные группы. После отключения преувеличения оптимизация фокусируется на тонкой настройке расположения кластеров. Реализация на Python Библиотека scikit-learn предоставляет эффективную реализацию t-SNE через класс TSNE. Для большей наглядности работы алгоритма, я продемонстрирую применение метода на синтетических данных с известной структурой. import numpy as np import matplotlib.pyplot as plt from sklearn.manifold import TSNE from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA from scipy.spatial.distance import pdist, squareform # Генерация синтетических данных с 5 кластерами в 50-мерном пространстве np.random.seed(42) n_samples_per_cluster = 200 n_features = 50 n_clusters = 5 # Создаем кластеры с разной геометрией clusters = [] labels = [] # Кластер 1: плотный сферический center1 = np.random.randn(n_features) * 10 cluster1 = center1 + np.random.randn(n_samples_per_cluster, n_features) * 0.5 clusters.append(cluster1) labels.extend([0] * n_samples_per_cluster) # Кластер 2: вытянутый эллипсоид center2 = np.random.randn(n_features) * 10 cluster2 = center2 + np.random.randn(n_samples_per_cluster, n_features) cluster2[:, :10] *= 3 # Растягиваем первые 10 измерений clusters.append(cluster2) labels.extend([1] * n_samples_per_cluster) # Кластер 3: два связанных подкластера center3 = np.random.randn(n_features) * 10 subcluster3a = center3 + np.random.randn(n_samples_per_cluster // 2, n_features) * 0.7 subcluster3b = center3 + np.array([2] * n_features) + np.random.randn(n_samples_per_cluster // 2, n_features) * 0.7 cluster3 = np.vstack([subcluster3a, subcluster3b]) clusters.append(cluster3) labels.extend([2] * n_samples_per_cluster) # Кластер 4: разреженный center4 = np.random.randn(n_features) * 10 cluster4 = center4 + np.random.randn(n_samples_per_cluster, n_features) * 2 clusters.append(cluster4) labels.extend([3] * n_samples_per_cluster) # Кластер 5: высокоразмерная структура center5 = np.random.randn(n_features) * 10 cluster5 = center5 + np.random.randn(n_samples_per_cluster, n_features) * 1.2 clusters.append(cluster5) labels.extend([4] * n_samples_per_cluster) # Объединяем данные X = np.vstack(clusters) y = np.array(labels) # Стандартизация признаков scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # Визуализации fig = plt.figure(figsize=(16, 5)) # 1. PCA проекция (линейное снижение размерности) pca = PCA(n_components=2) X_pca = pca.fit_transform(X_scaled) ax1 = plt.subplot(131) colors_map = ['#2E2E2E', '#4A4A4A', '#666666', '#8C8C8C', '#B0B0B0'] for i in range(n_clusters): mask = y == i ax1.scatter(X_pca[mask, 0], X_pca[mask, 1], c=colors_map[i], label=f'Cluster {i+1}', s=20, alpha=0.7, edgecolors='white', linewidth=0.3) ax1.set_xlabel(f'PC1 ({pca.explained_variance_ratio_[0]:.1%} variance)', fontsize=10) ax1.set_ylabel(f'PC2 ({pca.explained_variance_ratio_[1]:.1%} variance)', fontsize=10) ax1.set_title('PCA проекция (линейная)\nИсходное 50D пространство', fontsize=11, fontweight='bold') ax1.legend(loc='best', fontsize=8, framealpha=0.9) ax1.grid(True, alpha=0.3) # 2. Матрица расстояний (heatmap) # Берем подвыборку для читаемости (по 40 точек из каждого кластера) sample_indices = [] for i in range(n_clusters): cluster_indices = np.where(y == i)[0] sample_indices.extend(np.random.choice(cluster_indices, 40, replace=False)) X_sample = X_scaled[sample_indices] y_sample = y[sample_indices] # Вычисляем попарные расстояния distances = squareform(pdist(X_sample, metric='euclidean')) # Сортируем по кластерам для наглядности sorted_indices = np.argsort(y_sample) distances_sorted = distances[sorted_indices][:, sorted_indices] ax2 = plt.subplot(132) im = ax2.imshow(distances_sorted, cmap='viridis', aspect='auto') ax2.set_xlabel('Индекс точки (сортировка по кластерам)', fontsize=10) ax2.set_ylabel('Индекс точки (сортировка по кластерам)', fontsize=10) ax2.set_title('Матрица попарных расстояний\nИсходное 50D пространство', fontsize=11, fontweight='bold') # Добавляем разделители между кластерами for i in range(1, n_clusters): pos = i * 40 - 0.5 ax2.axhline(pos, color='red', linewidth=1.5, alpha=0.7) ax2.axvline(pos, color='red', linewidth=1.5, alpha=0.7) plt.colorbar(im, ax=ax2, label='Евклидово расстояние') # 3. t-SNE проекция (нелинейная) tsne = TSNE(n_components=2, perplexity=50, n_iter=1000, learning_rate=200, random_state=42, init='random') X_tsne = tsne.fit_transform(X_scaled) ax3 = plt.subplot(133) for i in range(n_clusters): mask = y == i ax3.scatter(X_tsne[mask, 0], X_tsne[mask, 1], c=colors_map[i], label=f'Cluster {i+1}', s=20, alpha=0.7, edgecolors='white', linewidth=0.3) ax3.set_xlabel('t-SNE Component 1', fontsize=10) ax3.set_ylabel('t-SNE Component 2', fontsize=10) ax3.set_title('t-SNE проекция (нелинейная)\nСохранение локальной структуры', fontsize=11, fontweight='bold') ax3.legend(loc='best', fontsize=8, framealpha=0.9) ax3.grid(True, alpha=0.3) plt.tight_layout() plt.show() Рис. 4: Сравнение методов визуализации высокоразмерных данных. Слева: PCA проекция показывает линейную структуру, кластеры сильно перекрываются (объясняет только 13% дисперсии). В центре: матрица попарных расстояний в исходном 50-мерном пространстве с блочной структурой, соответствующей пяти кластерам. Справа: t-SNE визуализация четко разделяет все кластеры, сохраняя локальную структуру данных Код создает синтетический датасет из 1000 точек в 50-мерном пространстве с 5 кластерами разной геометрии: плотный сферический, вытянутый эллипсоид, кластер с двумя связанными подгруппами, разреженный и кластер со стандартной структурой. Такое разнообразие форм позволяет оценить способность t-SNE сохранять различные типы локальной структуры. Стандартизация данных с помощью StandardScaler является обязательным шагом: t-SNE чувствителен к масштабу признаков, и различия в их дисперсии могут сильно исказить вычисляемые расстояния. После стандартизации все признаки приводятся к нулевому среднему и единичной дисперсии. Визуализация демонстрирует три подхода к снижению размерности: PCA проекция показывает ограничения линейных методов: первые два главных компонента объясняют лишь 13-15% дисперсии данных, кластеры сильно перекрываются и их границы размыты. Это происходит потому, что PCA ищет направления максимальной дисперсии, игнорируя нелинейные зависимости между точками. Матрица попарных расстояний отображает структуру данных в исходном 50-мерном пространстве. Точки отсортированы по кластерам, что создает характерную блочную структуру: темные квадраты вдоль диагонали соответствуют малым внутрикластерным расстояниям, светлые области между блоками — большим межкластерным расстояниям. Красные линии разделяют кластеры. Матрица подтверждает, что в исходном пространстве кластеры действительно разделены, но визуализировать эту структуру напрямую невозможно из-за высокой размерности. t-SNE проекция с perplexity=50 четко разделяет все пять кластеров в двумерном пространстве. Алгоритм сохраняет локальную структуру: точки, близкие в исходном 50-мерном пространстве, остаются близкими на визуализации. Кластер 3 демонстрирует структуру из двух связанных подгрупп, что соответствует способу генерации данных. Разреженный кластер 4 занимает большую площадь, отражая его низкую плотность в исходном пространстве. Сравнение трех методов иллюстрирует ключевое преимущество t-SNE: способность выявлять нелинейные структуры, которые линейные методы не различают. PCA оптимален для данных с линейными зависимостями, но теряет информацию при сложной геометрии кластеров. t-SNE фокусируется на сохранении локальных окрестностей, что делает метод эффективным для визуализации данных с произвольной топологией. Практическое применение Метод t-SNE используется на этапе исследовательского анализа данных для выявления скрытых паттернов, валидации предположений о структуре данных и оценки качества признаков. Метод дополняет количественные метрики качественной визуальной интерпретацией. Анализ структуры данных и кластеризация Визуализация многомерных данных через t-SNE позволяет выявить естественные группировки объектов до применения алгоритмов кластеризации. Если на визуализации наблюдаются четко разделенные кластеры, это указывает на существование дискретных групп в исходных данных. Размытые границы или континуум точек говорят о плавных переходах между состояниями. В задачах анализа текстов t-SNE применяется к векторным представлениям документов (TF-IDF, word embeddings, document embeddings). Визуализация показывает семантическую близость текстов: документы схожей тематики формируют кластеры, выбросы указывают на уникальный контент. Это помогает оценить качество тематической модели или выявить ошибки в разметке данных. В компьютерном зрении метод визуализирует признаки, извлеченные из изображений нейросетью. Промежуточные слои CNN создают высокоразмерные представления (512-2048 измерений), которые t-SNE проецирует в 2D. Изображения одного класса должны группироваться вместе, перекрытие кластеров указывает на визуальную схожесть классов и потенциальные ошибки классификации. Биоинформатика использует t-SNE для анализа данных секвенирования: single-cell RNA-seq датасеты содержат экспрессию тысяч генов для каждой клетки. Визуализация выявляет субпопуляции клеток, траектории дифференциации и редкие типы клеток. Метод стал стандартом для представления результатов в научных публикациях по молекулярной биологии. Выбор числа кластеров для последующего применения k-means или DBSCAN основывается на визуальной оценке: подсчитывается количество четко разделенных групп на t-SNE визуализации. Этот подход более надежен, чем формальные метрики типа метода "локтя" (elbow method), особенно когда кластеры имеют разную плотность или размер. Инжиниринг признаков и валидация t-SNE служит инструментом валидации сконструированных признаков. После создания новых фич проверяется, улучшают ли они разделимость классов в задаче классификации. Исходный датасет и датасет с добавленными признаками визуализируются отдельно: если новые признаки полезны, кластеры целевых классов становятся более компактными и разделенными. Детекция аномалий усиливается визуальным анализом: выбросы на t-SNE визуализации часто соответствуют аномальным объектам. Точки, находящиеся далеко от любого кластера или образующие крошечные изолированные группы, требуют дополнительной проверки. Это помогает отличить истинные аномалии от шума в данных. Оценка качества embeddings в NLP задачах выполняется через визуализацию векторных представлений слов или предложений. Синонимы и семантически близкие слова должны располагаться рядом, антонимы — на противоположных сторонах пространства. Для word2vec или BERT embeddings t-SNE показывает, насколько хорошо модель захватила семантические отношения. Балансировка классов проверяется визуально: в несбалансированных датасетах редкие классы могут теряться на фоне мажоритарного класса. t-SNE визуализация показывает, насколько сильно перекрываются классы и есть ли у минорных классов четкая структура. Это информирует решения о применении техник ресемплинга или взвешивания классов. Ограничения t-SNE t-SNE имеет фундаментальные ограничения, которые необходимо учитывать при интерпретации результатов: Нарушение глобальной структуры. t-SNE не сохраняет глобальные расстояния: далекие кластеры могут оказаться рядом, поэтому интерпретировать нужно только локальные группы; Недетерминированность. Разные запуски дают разные проекции. Чтобы выделить устойчивые структуры, t-SNE запускают несколько раз и сравнивают результаты; Высокая вычислительная сложность. Алгоритм O(N² log N) плохо масштабируется. Для больших датасетов используют FIt-SNE, openTSNE или предварительное сжатие PCA; Нет трансформации для новых данных. Нельзя «добавить» новые точки без полного перерасчета — в отличие от PCA. Это ограничивает применение в потоковых задачах; Чувствительность к гиперпараметрам. Неверные значения перплексии или числа итераций вызывают артефакты: разрывы, слияние или искажение кластеров. Требуется подбор параметров; Метод непригоден для количественного анализа. Координаты не имеют самостоятельного значения, важны только относительные расстояния и визуальные структуры. Заключение t-SNE является мощным инструментом для эксплораторного анализа, визуализации высокоразмерных данных и выявления локальных структур. Метод особенно полезен для качественного анализа кластеров, выявления закономерностей и предварительной оценки распределений. Однако его ограничения требуют внимательного подхода. Для получения надежных результатов рекомендуется запускать t-SNE несколько раз, подбирать оптимальные параметры, использовать ускоренные реализации для больших выборок и комбинировать с методами снижения размерности, такими как PCA. При соблюдении этих правил t-SNE позволяет получать информативные и наглядные визуализации, которые помогают лучше понять структуру сложных данных и принять обоснованные решения. ### Обзор книги "Аналитика в Power BI с помощью R и Python" (Р. Уэйд) Книга "Аналитика в Power BI с помощью R и Python. Загрузка, преобразование и визуализация данных" написана Райаном Уэйдом и издана на русском языке в 2021 году издательством ДМК Пресс. Центральная идея книги — расширение возможностей Microsoft Power BI через интеграцию с языками программирования R и Python для создания пользовательских визуализаций, применения машинного обучения и обработки данных методами, недоступными в стандартных инструментах Power Query и DAX. Автор демонстрирует полный цикл работы от создания визуальных элементов до интеграции с корпоративными решениями на SQL Server. Книга решает проблему ограниченности стандартных возможностей Power BI при работе со сложной аналитикой. Встроенные визуализации и функции DAX покрывают базовые сценарии, но для продвинутого анализа текстов, геопространственной аналитики, прогнозирования временных рядов и применения кастомных моделей машинного обучения требуется программная расширяемость. Уэйд показывает, как использовать R и Python без необходимости приобретать дорогостоящую подписку Power BI Premium, работая через локальные скрипты и SQL Server Machine Learning Services. Целевая аудитория — аналитики данных и разработчики BI-решений, которые работают с большими объемами данных и хотят выйти за рамки стандартной функциональности Power BI. Требуется базовое знание Power BI Desktop, понимание концепций бизнес-аналитики и опыт работы с облачной платформой Microsoft Azure. Знание R или Python на начальном уровне желательно, хотя автор объясняет синтаксис по ходу примеров. Обложка книги "Аналитика в Power BI с помощью R и Python" Ключевые аспекты Вся книга пронизана демонстрациями возможностей Power BI выполнять скрипты R и Python как для создания визуальных элементов, так и для трансформации данных в Power Query и взаимодействия с внешними сервисами. 1. Визуализация данных средствами R и Python через ggplot2, matplotlib и seaborn Уэйд подробно разбирает грамматику графиков ggplot2, многослойную систему построения визуализаций в R. Автор предлагает шестишаговый шаблон: импорт пакетов, преобразование данных, создание базового ggplot(), добавление геометрий, определение заголовков через labs(), настройка осей через scale_x_continuous() и scale_y_continuous(). Python-визуализации в Power BI строятся на matplotlib и высокоуровневой библиотеке seaborn. Автор демонстрирует создание пузырьковых диаграмм через scatter() с параметрами размера и цвета пузырьков, горизонтальных столбчатых диаграмм через barh(), линейных графиков с множественными сериями. 2. Трансформация данных через R и Python в Power Query Power Query поддерживает выполнение R и Python скриптов для преобразования данных, недоступного через стандартные трансформации M-языка. Уэйд показывает импорт данных из нестандартных источников: чтение Excel-файлов с множественными листами через readxl в R и pandas.ExcelFile в Python, загрузка данных из API через пакеты httr и requests, работа с JSON через jsonlite и json. Расширенные трансформации включают применение регулярных выражений для извлечения паттернов из текста через str_detect() и str_replace() в R, re.search() и re.sub() в Python. Векторизованные операции с датафреймами через dplyr в R и pandas в Python позволяют эффективно фильтровать, группировать, объединять данные. Автор демонстрирует чтение нескольких файлов из директории циклом, объединение результатов в один датасет через map_dfr() в R и concat() в Python. 3. Геопространственная аналитика и геокодирование Автор посвящает отдельную главу географическому анализу через R и Python. Геокодирование преобразует адреса в координаты широты и долготы через ggmap в R с функцией mutate_geocode() и geopy в Python с провайдером Nominatim. Обратное геокодирование извлекает адреса из координат. Автор показывает интеграцию с Google Maps API через register_google() для повышения точности и снятия лимитов бесплатных сервисов. 4. Прогнозирование временных рядов и машинное обучение Для прогнозирования автор использует пакет Prophet от Facebook на языке R, который автоматически выявляет тренды, сезонность и праздничные эффекты в временных рядах. Уэйд демонстрирует настройку параметров сезонности, добавление регрессоров, интерпретацию компонентов тренда. Есть также примеры машинного обучения на Python - через scikit-learn. В книге показано как делается предобработка данных через StandardScaler для нормализации, обучение моделей классификации и регрессии, сохранение обученных моделей через joblib для повторного использования в Power BI без переобучения. Линейные регрессионные модели добавляются на диаграммы рассеяния через geom_smooth() с методами lm для линейной регрессии, loess для локальной регрессии, gam для обобщенных аддитивных моделей с настройкой доверительных интервалов. Примеры и кейсы Книга использует демонстрационные датасеты: данные переписи США через tidycensus, финансовые временные ряды для прогнозирования Prophet, географические данные городов для геокодирования и расчета расстояний. Примеры учебные, адаптированы под изучение конкретных техник, но, увы, не реальные бизнес-кейсы с end-to-end решениями. Книга содержит многочисленные скриншоты интерфейса Power BI, результаты выполнения кода R и Python, готовые визуализации с аннотациями. Баланс смещен в сторону кода: каждая глава включает 10-20 листингов с подробными комментариями к каждой строке. Визуальные схемы минимальны, акцент на воспроизводимых примерах. Инструменты актуальны на 2021 год: Power BI Desktop, R версии 3.6+, Python 3.7+, SQL Server 2017+, хотя некоторые API могут измениться в будущих версиях Azure и Cognitive Services. Полезность книги Полнота раскрытия зависит от темы: создание визуализаций и базовая интеграция описаны детально, продвинутое машинное обучение и развертывание в продакшен даны обзорно. Что раскрыто хорошо? Подробно разобрана грамматика построения нестандартных графиков в PowerBI через ggplot2 и matplotlib с объяснением  логики построения каждого элемента, что дает систематическое понимание создания дополнительных визуализаций в PowerBI. Интеграция R и Python в Power Query для трансформации данных показана на практических сценариях: чтение множественных Excel-файлов циклом, применение регулярных выражений для парсинга текста, загрузка из API, что расширяет возможности M-языка без изучения его синтаксиса. Прогнозирование временных рядов через Prophet объяснено от установки пакета до интерпретации компонентов тренда и сезонности с примерами настройки праздничных эффектов и дополнительных регрессоров, что позволяет аналитикам сразу применять подход для бизнес-задач прогнозирования продаж и спроса. Что раскрыто плохо? Производительность и оптимизация скриптов в книге не рассматриваются вообще. Нет рекомендаций по ограничению объема данных в визуальных элементах, кешированию результатов, профилированию медленных операций. Общеизвестно что PowerBI - требовательное к ресурсам ПО, и при выполнении сложных R/Python скриптов на каждом обновлении это неизбежно отразится на скорости загрузки отчетов. Управление зависимостями и развертывание в production описаны фрагментарно без методологии настройки окружений conda, управления версиями пакетов, решения конфликтов библиотек при миграции между локальными машинами, серверами и Power BI Service, что критично для корпоративных сценариев. Обработка ошибок и отладка скриптов упомянуты вскользь: нет техник логирования, перехвата исключений, валидации входных данных из Power BI, диагностики проблем с кодировками и типами данных, что оставляет разработчика беспомощным при возникновении runtime-ошибок в отчетах. Машинное обучение дано на уровне hello world без разбора feature engineering, подбора гиперпараметров, кросс-валидации, интерпретации метрик качества, мониторинга деградации моделей в production, что делает примеры демонстрационными, но недостаточными для реальных ML-пайплайнов в Power BI. Вердикт Уникальность данной книги состоит в фокусе на практической интеграции R и Python именно с Power BI, а не общих руководствах по этим языкам. Я не встречал других источников, которые бы так детально показывали создание кастомных визуализаций через ggplot2 и matplotlib специфично для контекста Power BI с учетом особенностей передачи данных и ограничений платформы. Книга стоит прочтения для расширения аналитического инструментария Power BI за пределы стандартных возможностей. Читатель получит готовые шаблоны кода для типовых задач визуализации, трансформации и анализа данных, понимание архитектуры интеграции с SQL Server ML Services и Cognitive Services, навыки создания прогнозных моделей и геопространственной аналитики. Это может быть полезно для создания продвинутых BI-решений без перехода на специализированные data science платформы. ### Обзор книги "Изучаем SQL. Генерация, выборка и обработка данных" (А. Болье, 3-е изд.) Книга "Изучаем SQL. Генерация, выборка и обработка данных" написана Аланом Болье, третье издание вышло в 2021 году на русском языке в издательстве Диалектика. Это практическое руководство по языку SQL, охватывающее полный цикл работы с реляционными базами данных от базовых запросов до продвинутых техник вроде аналитических функций, секционирования и работы с большими объемами данных. Автор фокусируется на трех основных СУБД: MySQL, Oracle Database и SQL Server, показывая различия в реализации SQL-функциональности на разных платформах. Книга решает проблему фрагментарного понимания SQL, когда разработчики знают базовые SELECT-запросы, но не владеют систематическим подходом к проектированию схем, оптимизации производительности и использованию продвинутых возможностей языка. Болье выстраивает знания от фундамента реляционной модели до прикладных техник работы с метаданными, транзакциями и аналитикой. Каждая глава заканчивается упражнениями для закрепления материала. Целевая аудитория — разработчики приложений, работающих с базами данных, администраторы БД, аналитики данных от начального до среднего уровня. Книга не требует предварительного знания SQL, начинается с основ реляционной модели и постепенно усложняется. Предполагается базовое понимание программирования и опыт работы с командной строкой для установки MySQL и выполнения примеров. Обложка книги "Изучаем SQL. Генерация, выборка и обработка данных" Ключевые аспекты Базой изложения служит реляционная модель данных, где информация структурирована в таблицы со связями между ними, а язык SQL предоставляет декларативный способ манипуляции этими данными. 1. Реляционная модель и основы SQL Болье начинает с теории: реляционная модель организует данные в таблицы с уникальными строками, идентифицируемыми первичными ключами, и связями через внешние ключи. Нормализация разделяет данные на логически связанные таблицы, устраняя избыточность. Первая, вторая и третья нормальные формы последовательно удаляют дублирование и зависимости. SQL классифицируется на три группы инструкций: Язык схемы данных (CREATE, ALTER, DROP для определения структуры); Язык манипулирования данными (SELECT, INSERT, UPDATE, DELETE для работы с содержимым); Язык управления данными (GRANT, REVOKE для прав доступа). Автор подчеркивает непроцедурный характер SQL: программист описывает что нужно получить, а оптимизатор определяет как это сделать. 2. Типы данных и создание схемы Детально разбираются типы данных в MySQL, Oracle и SQL Server с таблицей соответствий. Символьные типы CHAR для фиксированной длины, VARCHAR для переменной длины до 255 символов, TEXT и его варианты TINYTEXT, MEDIUMTEXT, LONGTEXT для больших текстов. Числовые типы разделяются на целочисленные (TINYINT, SMALLINT, MEDIUMINT, INT, BIGINT) и вещественные (FLOAT, DOUBLE) с указанием точности и масштаба. 3. Запросы и соединения таблиц В книге есть множество примеров соединений данных из нескольких таблиц: Внутреннее соединение (INNER JOIN) возвращает только совпадающие строки из обеих таблиц. Внешние соединения (LEFT, RIGHT) сохраняют все строки из одной таблицы, дополняя NULL для несовпадающих из другой. Перекрестное соединение (CROSS JOIN) создает декартово произведение всех комбинаций строк. Автор показывает соединение трех и более таблиц, использование подзапросов в FROM как виртуальных таблиц, самосоединения для иерархических данных. 4. Группировка, агрегация и аналитические функции Аналитические функции (оконные) из главы 16 вычисляют значения по набору строк, связанных с текущей строкой, без сворачивания результата. Функции ранжирования ROW_NUMBER, RANK, DENSE_RANK присваивают номера в пределах окна. LAG и LEAD обращаются к предыдущим и следующим строкам для расчета изменений. Окна данных определяются через PARTITION BY для группировки и ORDER BY для сортировки. Рамки окна (ROWS BETWEEN) ограничивают диапазон строк для вычисления скользящих средних и накопительных сумм. 5. Подзапросы и условная логика Рассматриваются типы подзапросов. Есть примеры коррелированных и некоррелированных подзапросов: Некоррелированные подзапросы выполняются один раз независимо от внешнего запроса и возвращают скалярное значение, одностолбцовый или многостолбцовый результат; Коррелированные подзапросы выполняются для каждой строки внешнего запроса, обращаясь к столбцам из внешнего контекста. Оператор EXISTS проверяет наличие строк в подзапросе без их извлечения. Автор показывает применение CASE для преобразования результирующих наборов, проверки существования данных, обработки NULL-значений, предотвращения деления на нуль, условных обновлений UPDATE с разной логикой для разных строк. 6. Транзакции, индексы и оптимизация Транзакции обеспечивают атомарность операций: либо все изменения фиксируются COMMIT, либо откатываются ROLLBACK. Автор разбирает блокировки для многопользовательского доступа: блокировки чтения предотвращают изменения, блокировки записи блокируют чтение и изменение. Гранулярность блокировок варьируется от строк до таблиц. MySQL поддерживает разные движки хранения InnoDB, MyISAM с различной поддержкой транзакций. Точки сохранения (SAVEPOINT) позволяют частичный откат транзакции. Примеры и кейсы Все примеры построены на учебной базе данных Sakila, схеме проката DVD с таблицами film, actor, customer, rental, payment. Это демонстрационный датасет, специально разработанный для обучения SQL, а не реальные бизнес-кейсы. Задачи включают выборку фильмов по категориям, расчет выручки по магазинам, анализ активности клиентов, построение рейтингов актеров. Книга содержит схемы таблиц, ER-диаграммы связей, таблицы с результатами запросов, но минимум визуализаций. Баланс сильно смещен в сторону кода SQL: каждая концепция иллюстрируется 5-10 примерами запросов с подробными комментариями. Инструменты актуальны: MySQL 8.0, Oracle Database 19c, SQL Server 2019, хотя основной синтаксис SQL стабилен десятилетиями. Полезность книги Полнота раскрытия определяется фокусом на классическом SQL: реляционные концепции и традиционные СУБД описаны исчерпывающе, современные подходы к данным даны обзорно. Что раскрыто хорошо? Соединения таблиц разобраны через механику выполнения с объяснением декартова произведения, различий INNER, LEFT, RIGHT, CROSS JOIN, самосоединений и использования подзапросов в FROM, что дает понимание работы оптимизатора, а не только синтаксиса. Аналитические функции из главы 16 детально описаны с примерами ROW_NUMBER, RANK, DENSE_RANK, LAG, LEAD, определением окон через PARTITION BY и ORDER BY, рамками ROWS BETWEEN для скользящих агрегатов, что весьма ценно для изучения временных рядов и трендового анализа. Различия реализаций SQL между MySQL, Oracle и SQL Server показаны в каждой главе через сравнительные таблицы функций, особенностей синтаксиса, механизмов хранения, что позволяет писать переносимый код или быстро адаптироваться при смене СУБД. Транзакции и блокировки объяснены через многопользовательские сценарии с примерами взаимоблокировок, уровней изоляции, точек сохранения, каскадных обновлений внешних ключей, что необходимо для проектирования надежных систем без потерь данных. Что раскрыто плохо? Оптимизация производительности упомянута фрагментарно без методологии: нет разбора EXPLAIN-планов выполнения, статистики запросов, профилирования узких мест, техник перестройки запросов для использования индексов, что оставляет читателя беспомощным при медленных запросах на реальных объемах. Работа с большими данными в главе 17 дана на уровне концепций секционирования, шардинга, кластеризации без конкретных примеров настройки партиций по диапазонам, хешам, спискам, отсечения разделов в запросах, миграции данных между партициями. Хранимые процедуры, триггеры, функции не рассмотрены, хотя они критичны для бизнес-логики в БД: нет примеров PL/SQL в Oracle, Transact-SQL в SQL Server, процедурных расширений MySQL для автоматизации операций и обеспечения согласованности данных. Вердикт Ключевая ценность данной книги состоит в систематическом охвате SQL от реляционной теории до практических техник с параллельным сравнением трех основных СУБД в одном источнике. Это одно из немногих руководств, которые так детально разбирают различия реализации аналитических функций, индексов, транзакций между MySQL, Oracle и SQL Server с готовыми примерами для каждой платформы. Книга стоит прочтения для формирования фундаментального понимания SQL и реляционных баз данных. Читатель получит готовые паттерны запросов для типовых задач выборки, группировки, соединений, понимание работы оптимизатора и индексов, навыки проектирования нормализованных схем с ограничениями целостности, что закрывает 80% потребностей разработки приложений с SQL. Приобрести книгу можно здесь: https://www.labirint.ru/books/811526/ ### Обзор книги "Как вытащить из данных максимум" (Д. Морроу) Книга "Как вытащить из данных максимум" написана Джорданом Морроу и издана на русском языке в 2022 году издательством Альпина Паблишер. Цель книги - дать руководителям понимание как можно решить проблему катастрофической нехватки грамотных специалистов по работе с данными в условиях экспоненциального роста данных во всем мире. Ежедневно в соцсетях публикуются миллиарды сообщений, датчики на производствах генерируют миллионы сигналов, каждый подключенный к интернету автомобиль производит терабайты данных, но работать с этой информацией умеет меньшинство. Морроу показывает, как организации могут масштабировать аналитические компетенции на всю компанию, не нанимая армию дата-сайентистов. Целевая аудитория — менеджеры, бизнес-аналитики, руководители и все, кто принимает решения на основе данных, но не имеет технического образования в области статистики или программирования. Книга не требует предварительных знаний в аналитике, начинается с базовых концепций и постепенно углубляется в методологию. Морроу фокусируется на понимании принципов работы с данными, а не на технических инструментах или коде. Обложка книги "Как вытащить из данных максимум" Ключевые аспекты Базой концепции Морроу служит идея демократизации данных, где каждый сотрудник способен извлекать инсайты и принимать подкрепленные данными решения независимо от технической роли. 1. Четыре уровня аналитических методов Морроу выстраивает иерархию аналитики от простого к сложному: дескриптивный, диагностический, предиктивный и прескриптивный анализ. Дескриптивная аналитика отвечает на вопрос "что произошло" через визуализацию исторических данных и базовые метрики вроде средних значений, трендов, распределений. Это фундамент, который показывает текущее состояние бизнеса через дашборды и отчеты. Диагностический анализ углубляется в вопрос "почему это произошло", исследуя корреляции и причинно-следственные связи. Предиктивный уровень использует статистические модели и машинное обучение для ответа на вопрос "что произойдет", прогнозируя будущие события на основе исторических паттернов. Прескриптивная аналитика, самый продвинутый уровень, рекомендует действия — "что нужно сделать" — через оптимизационные алгоритмы и симуляции. Автор подчеркивает, что организации должны последовательно развивать компетенции на каждом уровне, а не перепрыгивать к AI без освоения базовых дескриптивных методов. 2. Определение дата-грамотности через четыре элемента Морроу определяет дата-грамотность как комбинацию четырех навыков: Чтение данных означает способность интерпретировать визуализации, понимать метрики и распознавать паттерны в таблицах и графиках; Работа с данными включает навыки фильтрации, сортировки, группировки и базовых трансформаций без обязательного программирования; Анализ данных требует критического мышления для выявления инсайтов, проверки гипотез и отделения корреляции от причинности; Общение на языке данных — умение транслировать аналитические находки в бизнес-контексте, строить нарративы вокруг цифр и убеждать стейкхолдеров. Морроу настаивает, что все четыре элемента важны: аналитик без коммуникативных навыков не создаст ценности, а менеджер, не умеющий читать данные, не сможет оценить качество анализа. 3. Три C дата-грамотности Морроу выделяет три фундаментальных навыка мышления: Любопытство (Curiosity) побуждает задавать вопросы к данным, не принимать первое объяснение, копать глубже в аномалиях и выбросах; Творческий подход (Creativity) позволяет находить нестандартные решения аналитических задач, комбинировать разные источники данных, создавать неожиданные визуализации; Критическое мышление (Critical thinking) защищает от ложных корреляций, когнитивных искажений и манипуляций данными. Эти три C формируют мышление дата-грамотного человека, который не просто выполняет технические операции, но понимает смысл и контекст анализа. Примеры и кейсы Книга содержит кейсы из разных индустрий: Netflix использует предиктивную аналитику для рекомендаций контента, US Open применяет AI для улучшения фан-опыта через анализ социальных медиа и перемещений зрителей, Coca-Cola оптимизирует ассортимент торговых автоматов на основе данных о предпочтениях в разных локациях. Примеры реальные, но описаны обзорно без глубокого разбора методологии. Книга практически не содержит визуализаций, схем, таблиц или кода — это полностью текстовое изложение концепций. Баланс смещен в сторону философии и стратегии работы с данными, а не технической реализации. Морроу упоминает инструменты вроде Qlik, Tableau, Power BI, но не дает пошаговых инструкций по их использованию, фокусируясь на универсальных принципах, которые не устаревают со сменой технологий. Полезность книги Полнота раскрытия темы зависит от угла зрения: концептуальная рамка дата-грамотности представлена исчерпывающе, но практическая реализация остается на уровне общих рекомендаций. Что раскрыто хорошо? Четыре уровня аналитики структурируют хаос методов в понятную иерархию от дескриптивного к прескриптивному, что помогает организациям оценить текущий уровень зрелости и спланировать развитие без попыток сразу внедрить AI. Три C дата-грамотности — любопытство, творчество, критическое мышление — дают операционализируемые навыки мышления с примерами применения, которые не зависят от конкретных инструментов и остаются актуальными при смене технологий. Схема принятия решений из шести ступеней (спросить, получить, проанализировать, интегрировать, решить, итерировать) создает воспроизводимый процесс для превращения данных в действия с акцентом на интеграцию контекста и итеративность. Что раскрыто плохо? Практические инструменты и техники анализа отсутствуют: нет примеров SQL-запросов, статистических тестов, методов работы с Excel или Python, что делает книгу концептуальной, но не позволяет сразу применить знания без дополнительных источников. Метрики успеха обучения дата-грамотности не конкретизированы: автор не предлагает KPI для измерения прогресса сотрудников или ROI программ обучения, что затрудняет оценку эффективности внедрения и получение бюджета от руководства. Преодоление сопротивления изменениям упомянуто вскользь без методологии работы со скептиками: нет техник убеждения руководителей старой школы, стратегий вовлечения сотрудников, боящихся автоматизации, или подходов к разрешению конфликтов между аналитиками и бизнесом. Вердикт Уникальность данной книги состоит в систематизации дата-грамотности как стратегической компетенции организации, а не только набора технических навыков. Здесь хорошо раскрыты уровни аналитики и ее взаимосвязь с организационной культурой, этикой и целями топ-менеджмента. Книга стоит прочтения для формирования стратегического видения работы с данными и планирования программ обучения. Читатель получит язык для обсуждения аналитики с нетехническими стейкхолдерами, понимание этапов созревания дата-культуры и чек-листы для оценки текущего состояния зрелости аналитики в компании. Приобрести книгу можно здесь: https://www.litres.ru/book/dzhordan-morrou/kak-vytaschit-iz-dannyh-maksimum-navyki-analitiki-dlya-nes-67106340/ ### Обзор книги "Анализ и визуализация данных в Yandex DataLens. Подробное руководство (А. Гинько)" Книга "Анализ и визуализация данных в Yandex DataLens. Подробное руководство: от новичка до эксперта" написана Александром Гинько и издана в 2023 году издательством ДМК Пресс. Это пошаговое руководство по работе с облачной платформой визуализации данных Yandex DataLens, охватывающее полный цикл от подключения источников до построения интерактивных дашбордов с применением машинного обучения. Автор ведет читателя от базовых концепций до продвинутых техник, включая LOD-выражения, оконные функции и Python-интеграцию. Книга решает проблему отсутствия структурированной документации на русском языке для DataLens. Официальная документация Яндекса фрагментарна, а англоязычные ресурсы по BI-инструментам не учитывают специфику российской экосистемы. Автор создал комплексный справочник, который позволяет начать работу с инструментом без изучения разрозненных статей и экспериментов методом проб и ошибок. Целевая аудитория - аналитики данных, бизнес-аналитики, специалисты по визуализации от начального до продвинутого уровня. Требуется базовое понимание SQL для работы с датасетами, знание структуры баз данных и умение формулировать бизнес-требования к отчетности. Опыт программирования на Python желателен только для последней главы о машинном обучении. Обложка книги "Анализ и визуализация данных в Yandex DataLens. Подробное руководство" Ключевые аспекты Базой работы в DataLens служит облачная архитектура Yandex.Cloud с управляемыми базами данных и разделением слоев хранения и визуализации данных. 1. Подключение к источникам данных и создание датасетов DataLens поддерживает подключение к управляемым БД в Yandex.Cloud, включая MySQL, PostgreSQL, ClickHouse, а также к внешним источникам через CSV, Google Sheets и Excel. Автор детально разбирает создание кластера MySQL через консоль Yandex.Cloud с выбором класса хоста и конфигурацией доступа. Датасет формируется на основе подключения и представляет собой слой абстракции над сырыми таблицами. 2. Типы данных и агрегация DataLens оперирует 10 типами данных: целое число, дробное число, строка, дата, дата и время, логический, геоточка, геополигон, массив. Автор приводит таблицу соответствий типов данных между источниками и DataLens, что важно при миграции между БД. Например, TIMESTAMP в PostgreSQL преобразуется в дата и время, а VARCHAR - в строку. Поля датасета делятся на измерения и показатели. Показатели поддерживают 8 типов агрегации. Автор подчеркивает важность правильного выбора агрегации: COUNT для подсчета строк, COUNTD для уникальных значений, AVG для средних показателей. Неправильная агрегация приводит к искажению метрик в чартах и дашбордах. 3. Функции DataLens и LOD-выражения Книга содержит справочник из более чем 150 функций, разделенных на категории: агрегатные, оконные, строковые, математические, логические, даты и времени, геофункции. Агрегатные функции включают SUM, AVG, COUNT, COUNT_IF с условиями, AVG_IF для условного среднего. Строковые функции CONCAT, SUBSTR, REPLACE, REGEXP_MATCH позволяют обрабатывать текст прямо в датасете. LOD-выражения (Level of Detail) управляют уровнем детализации агрегации независимо от группировки в чарте. Автор разбирает три директивы: FIXED вычисляет значение на заданном уровне измерений; INCLUDE добавляет измерения к текущей группировке; EXCLUDE удаляет измерения. 4. Оконные функции и ранжирование Оконные функции применяют агрегацию или вычисление в пределах окна строк без сворачивания результата. DataLens поддерживает три группы оконных функций: Агрегатные (SUM, AVG, MIN, MAX, COUNT с оконным контекстом); Функции смещения (LAG, FIRST, LAST для доступа к предыдущим и следующим строкам); Ранжирующие (RANK, RANK_DENSE, RANK_UNIQUE, RANK_PERCENTILE). Параметры группировки WITHIN, AMONG, TOTAL определяют границы окна. WITHIN [Date] группирует по дате, AMONG [Category] разбивает окно по категориям, TOTAL вычисляет по всему датасету. ORDER BY задает сортировку внутри окна. 5. Построение чартов и типы визуализаций DataLens предлагает 15 типов чартов: линейная диаграмма, столбчатая, линейчатая, круговая, кольцевая, точечная, диаграмма рассеяния, накопительная с областями, нормированная, древовидная, индикатор, таблица, карты пяти типов. В книге описаны настройки осей, легенды, итогов, лимитов на количество строк. 6. Дашборды, селекторы и связи Дашборд объединяет чарты, текстовые виджеты, селекторы и вкладки в интерактивный отчет. Селекторы фильтруют данные на всех связанных чартах: селектор на основе датасета подтягивает уникальные значения из поля, селектор на основе ручного ввода задает кастомный список значений. Связи между чартами определяют, какие селекторы влияют на какие виджеты. Алиас связи позволяет связывать поля с разными названиями из разных датасетов. Вкладки организуют дашборд в многостраничную структуру, каждая вкладка содержит независимый набор виджетов. Примеры и кейсы Книга построена на сквозном демонстрационном кейсе анализа продаж ритейла с таблицами товаров, заказов, клиентов, регионов. Автор использует датасет Sample Superstore для иллюстрации каждой функции и типа визуализации. В главе о машинном обучении приводится пример кластеризации магазинов по координатам методом k-средних с визуализацией на карте Москвы. Примеры учебные, адаптированы под задачи книги, но это не реальные бизнес-кейсы. Книга содержит более 200 скриншотов интерфейса DataLens, таблиц данных, готовых чартов и дашбордов. Визуализации четкие, с выделением активных элементов интерфейса стрелками и подписями. Баланс смещен в сторону пошаговых инструкций с GUI, код присутствует только в главе о Python и QL-чартах. Инструменты на данный момент актуальны, однако интерфейс DataLens может измениться в будущих версиях. Полезность книги Полнота раскрытия темы неравномерна: базовые концепции и интерфейс разобраны исчерпывающе, продвинутые темы вроде ETL и ML даны обзорно. Что раскрыто хорошо? Справочник функций с описанием синтаксиса, параметров, типов возвращаемых значений и примерами для всех 150+ функций занимает треть книги и служит полноценной заменой официальной документации при ежедневной работе. LOD-выражения объяснены через бизнес-задачи: расчет процента от общего, сравнение с бенчмарком, вычисление средних по группам независимо от детализации таблицы, с разбором директив FIXED, INCLUDE, EXCLUDE на конкретных формулах. Оконные функции даны с детальным разбором параметров WITHIN, AMONG, TOTAL, ORDER BY и примерами скользящих средних MAVG, накопительных сумм RSUM, ранжирования RANK для построения топов и трендов в динамике. Создание дашбордов описано пошагово от выбора типа чарта до настройки связей между виджетами, конфигурации селекторов, организации вкладок, управления доступом на уровне датасетов и строк через RLS-фильтры. Что раскрыто плохо? Подключение к внешним БД вне Yandex.Cloud дано поверхностно без разбора настройки сетевых правил, VPN-туннелей, белых списков IP, что критично для корпоративных сценариев с on-premise БД и firewall-ограничениями. Производительность и оптимизация запросов не рассматриваются: нет рекомендаций по индексам в источниках, материализации датасетов, кешированию, партиционированию, что приводит к медленным дашбордам на больших данных. Python-интеграция через QL-чарты описана на одном примере кластеризации без разбора установки библиотек, работы с pandas DataFrame, обработки ошибок, передачи параметров из селекторов, что делает главу демонстрационной, а не практической. Версионирование и CI/CD для дашбордов не упоминаются: нет методологии переноса между окружениями, хранения конфигураций в Git, автоматизации деплоя через API, что необходимо для командной разработки и продакшн-развертывания. Вердикт Уникальность данной книги заключается в том, что это пока единственное комплексное руководство по DataLens на русском языке с исчерпывающим справочником функций. Я не встречал других источников, которые бы так системно раскрывали LOD-выражения и оконные функции применительно к российской BI-платформе с практическими примерами на знакомых бизнес-задачах. Книга стоит прочтения для быстрого старта в DataLens и как настольный справочник. Читатель получит готовые формулы для типовых аналитических задач, понимание архитектуры датасетов и дашбордов, навыки построения интерактивных отчетов с фильтрацией и детализацией, что сокращает время на освоение инструмента с месяцев до недель. Приобрести книгу можно здесь: https://www.labirint.ru/books/896310/ ### Обзор книги "Data Mining: Извлечение информации из Facebook, Twitter, LinkedIn, Instagram, GitHub" (М. Рассел) Книга "Data Mining: Извлечение информации из Facebook, Twitter, LinkedIn, Instagram, GitHub" написана Мэтью Расселом и Михаэлем Классеном. Это практическое руководство по извлечению и анализу данных из популярных социальных платформ с использованием Python. Авторы демонстрируют, как применять техники data mining для получения инсайтов из пользовательского контента и метаданных социальных сетей. Книга решает проблему доступа к реальным данным для анализа. Большинство учебников по data mining оперируют очищенными датасетами, но практикам нужно уметь собирать сырые данные через API, обрабатывать их и извлекать полезную информацию в условиях ограничений платформ. Целевая аудитория - начинающие дата-саентисты и аналитики, желающие работать с социальными данными. Требуется базовое знание Python, понимание JSON и REST API, минимальные навыки программирования для работы с библиотеками запросов и обработки данных. Обложка книги "Data Mining: Извлечение информации из Facebook, Twitter, LinkedIn, Instagram, GitHub" Ключевые аспекты Базой исследований в книге служат публичные API социальных платформ и методы веб-скрейпинга, которые позволяют программно получать данные о пользователях, постах, связях и взаимодействиях. 1. Работа с API социальных сетей Авторы подробно разбирают OAuth-аутентификацию и rate limiting для каждой платформы: Twitter API позволяет получать твиты по ключевым словам, профили пользователей и графы подписок с ограничением 15 запросов на 15-минутное окно; Facebook Graph API дает доступ к постам, лайкам и комментариям, но требует токенов доступа с ограниченным временем жизни; LinkedIn API работает через OAuth 2.0 и предоставляет данные о профессиональных связях и опыте работы; Instagram API (до изменений политики) позволял получать медиа-контент и хештеги; GitHub API особенно полезен для анализа репозиториев, коммитов и активности разработчиков с лимитом 5000 запросов в час для аутентифицированных пользователей. 2. Анализ текста и обработка естественного языка Книга использует библиотеки NLTK и TextBlob для токенизации, стемминга и лемматизации текстов из постов. Авторы показывают извлечение n-грамм для определения устойчивых словосочетаний и применение TF-IDF для выделения значимых терминов в корпусе документов. Анализ настроений реализован с помощью предобученных моделей, которые определяют тональность постов как позитивную, негативную или нейтральную. Распознавание именованных сущностей позволяет автоматически извлекать из неструктурированного текста имена людей, названия компаний и упоминания мест. Это особенно важно при анализе упоминаний брендов и отслеживании тематических трендов. 3. Графовый анализ социальных сетей NetworkX используется для построения и анализа социальных графов из данных о подписках и взаимодействиях. Авторы вычисляют метрики центральности: degree centrality показывает количество связей узла; betweenness centrality - насколько часто узел лежит на кратчайших путях между другими узлами; closeness centrality - среднюю дистанцию до всех других узлов. Алгоритмы детекции сообществ, такие как Louvain и Girvan-Newman, выявляют кластеры пользователей со схожими интересами. PageRank применяется для ранжирования влиятельных пользователей в сети. Авторы демонстрируют, как визуализировать графы с помощью Gephi и D3.js для интерактивного исследования структуры сообществ. 4. Временные ряды и трендовый анализ Книга показывает работу с временными метками постов для выявления паттернов активности. Pandas используется для ресемплинга данных по часам, дням или неделям, построения скользящих средних и детекции аномалий в объемах публикаций. Анализ трендов включает отслеживание популярности хештегов во времени и корреляцию событий. Авторы применяют автокорреляцию для выявления циклических паттернов, например, пиков активности в определенные дни недели или часы суток. 5. Геопространственный анализ Для постов с геотегами используются библиотеки Folium и GeoPandas для визуализации на картах. Авторы показывают кластеризацию локаций методом DBSCAN для определения географических хотспотов активности. Анализ геоданных включает расчет расстояний между локациями, построение тепловых карт плотности постов и корреляцию географии с тональностью контента. Instagram особенно полезен для геоанализа из-за высокой доли геотегированного контента. 6. Машинное обучение для классификации и кластеризации Библиотека Scikit-learn используется для обучения моделей на размеченных данных. Наивный байесовский классификатор и метод опорных векторов применяются для тематической классификации постов, выявления спама, а также для распознавания изображений на основе извлеченных признаков. Также из методов машинного обучения авторы используют алгоритмы K-means и иерархическую кластеризацию, чтобы группировать пользователей по их поведению и интересам. Для уменьшения размерности данных они применяют PCA и t-SNE, что позволяет визуализировать многомерные признаки в двухмерном пространстве. Кроме того, тематическое моделирование с помощью LDA помогает выявлять скрытые темы в больших наборах текстов. Примеры и кейсы Книга содержит демонстрационные примеры: анализ твитов о выборах для определения общественного мнения, изучение GitHub-репозиториев популярных проектов для выявления паттернов коллаборации, анализ LinkedIn-данных для построения карьерных траекторий в tech-индустрии. Примеры учебные, не привязаны к конкретным бизнес-кейсам. Визуализации представлены через Matplotlib и Seaborn для графиков распределений, временных рядов и корреляционных матриц. Код занимает 60-70% объема книги, что делает ее практическим руководством. Инструменты и API актуальны на момент издания, но некоторые могут уже не работать из-за обновлений политик платформ. Полезность книги Полнота раскрытия разных тем в этой книге заметно отличается. Что раскрыто хорошо? Twitter API покрыт максимально детально с примерами streaming и REST endpoints, обработкой rate limits, фильтрацией по геолокации и языку, что позволяет сразу начать сбор данных без дополнительных источников. Графовый анализ с NetworkX раскрыт глубоко: от построения графов до вычисления десятка метрик центральности и алгоритмов детекции сообществ с интерпретацией результатов и практическими рекомендациями по выбору метрик. NLP-пайплайны для текста показаны пошагово с токенизацией, стеммингом, TF-IDF, sentiment analysis и named entity recognition, включая сравнение разных библиотек и их точности на реальных данных. Что раскрыто плохо? Facebook и Instagram API описаны поверхностно, авторы не предлагают альтернативных методов сбора данных при ограничениях API. Машинное обучение дано обзорно без детального разбора feature engineering, подбора гиперпараметров и валидации моделей, что делает примеры демонстрационными, но недостаточными для production-использования. Масштабирование и оптимизация не рассматриваются: нет рекомендаций по работе с миллионами записей, распределенным вычислениям, кешированию запросов или использованию баз данных вместо in-memory обработки. Этические и юридические аспекты scraping упомянуты вскользь без анализа Terms of Service разных платформ, GDPR-требований, методов анонимизации персональных данных и рисков при коммерческом использовании собранной информации. Вердикт Уникальность данной книги заключается в комплексном охвате пяти крупных платформ с готовым кодом на Python в одном источнике. Я не встречал других руководств, которые бы так детально разбирали специфику API каждой социальной сети с практическими примерами NLP и графового анализа именно для социальных данных. Книга стоит прочтения для быстрого старта в social media mining. Читатель получит работающие скрипты для сбора данных, набор техник анализа текста и графов, понимание ограничений API и готовые шаблоны для собственных проектов, что экономит недели на изучение документации и эксперименты. Приобрести книгу можно здесь: https://www.litres.ru/book/mihail-klassen-31634/data-mining-izvlechenie-informacii-iz-facebook-twitte-66738083/ ### Обзор книги "Доверительное A/B тестирование: Практическое руководство по контролируемым экспериментам" (Р. Кохави, Д. Танг) Книга "Доверительное A/B тестирование: Практическое руководство по контролируемым экспериментам" написана Роном Кохави, Дайан Танг и Я Сюй, опубликована в 2020 году издательством Cambridge University Press. Авторы исследуют методы проведения масштабных онлайн-экспериментов для принятия обоснованных решений в digital-продуктах на основе данных от миллионов пользователей. Книга решает важную проблему: получить цифры легко, получить цифры, которым можно доверять, сложно. Компании проводят тысячи A/B тестов ежегодно, но большинство организаций допускают ошибки в дизайне экспериментов, интерпретации результатов или построении инфраструктуры, что приводит к неверным решениям стоимостью в миллионы долларов. Целевая аудитория - data scientists, product managers, software engineers и руководители digital-бизнесов, работающие с онлайн-продуктами. Требуются базовые знания статистики на уровне понимания p-value и доверительных интервалов, опыт работы с метриками продукта и понимание того, как устроены веб-сервисы. Обложка книги "Доверительное A/B тестирование: Практическое руководство по контролируемым экспериментам" Ключевые аспекты Авторы начинают с фундамента научного метода, применяемого к онлайн-экспериментам, объясняя почему контролируемые эксперименты - золотой стандарт для установления причинно-следственных связей. 1. Overall Evaluation Criterion (OEC) как компас для решений OEC - количественная мера целей эксперимента, которая должна измеряться в краткосрочной перспективе (длительность эксперимента), но причинно влиять на долгосрочные стратегические цели. Для поисковой системы OEC может комбинировать usage (sessions-per-user), relevance (successful sessions) и ad revenue с весами, отражающими приоритеты бизнеса. Авторы подчеркивают: одна метрика, даже если это взвешенная комбинация нескольких целей, предпочтительнее balanced scorecard подхода. Это устраняет конфликты в интерпретации, когда одни метрики растут, другие падают - OEC дает однозначный ответ о ценности изменения. 2. Закон Тваймана и доверие к результатам Любая цифра, которая выглядит интересно или отлично, скорее всего ошибочна. В Bing запустили эксперимент с изменением заголовков рекламы, который показал рост revenue на 12% - сработал alert "revenue-too-high", сигнализирующий о возможном баге вроде двойного биллинга. После недель анализа результат подтвердился - простое объединение заголовка с первой строкой текста принесло Bing более $100 миллионов годового дохода только в США. Авторы используют этот кейс для демонстрации важности валидации аномалий: реверс одного неверного решения может окупить целую команду аналитиков. 3. Статистические ловушки в практике В книге описан метод Variance reduction через CUPED (Controlled-experiment Using Pre-Experiment Data). Метод снижает дисперсию метрик на ~50%, что эквивалентно удвоению sample size или сокращению длительности эксперимента вдвое. CUPED использует данные pre-experiment периода для уменьшения вариативности. Также описан метод Sample Ratio Mismatch — это важная проверка, которая показывает, правильно ли сработала рандомизация. Если в эксперименте с распределением 50/50 в группу Control оказалось, например, 50,5% пользователей вместо 50%, значит, в логике распределения есть ошибка. Коррекция на множественные проверки тоже необходима, когда анализируется много метрик: без нее вероятность ложных «значимых» результатов становится слишком высокой. Примеры и кейсы Книга насыщена реальными кейсами, но в основном это кейсы техно-гигантов, таких как Microsoft Bing, Amazon, Google и LinkedIn. Например, в Amazon перенесли предложение оформить кредитную карту с главной страницы на страницу корзины — простое решение, основанное на расчете экономической эффективности. Эксперимент показал рост годовой прибыли на десятки миллионов долларов. А рекомендации в корзине, предложенные аналитиком, увеличили выручку Amazon на 3%, что соответствует сотням миллионов долларов, несмотря на сопротивление со стороны одного из топ-менеджеров. Однако книга почти не содержит примеров кода или технических деталей реализации — основной акцент сделан на концепциях, статистических принципах и бизнес-кейсах с конкретными цифрами импакта на бизнес. Авторы подробно объясняют, что нужно делать и почему это работает, но практически не раскрывают, как именно писать код или строить экспериментальные платформы. Полезность книги Тема доверительных a/b тестов раскрыта с разных сторон: книга охватывает весь жизненный цикл экспериментов — от проектирования до масштабирования платформы и формирования организационной культуры. Что раскрыто хорошо? Практический опыт авторов из компаний, проводящих более 20 000 экспериментов в год. Рон Кохави — один из технических руководителей Microsoft с десятилетиями опыта в Bing и Amazon. Дайан Танг — ведущий специалист Google с глубокой экспертизой в рекламных системах. Я Сюй — руководитель направления Data Science в LinkedIn. Это не теоретики, а практики, которые построили платформы для миллионов экспериментов. Конкретные цифры влияния из реальных экспериментов. Такая прозрачность крайне редка: авторы показывают внутренние процессы крупнейших технологических компаний. Полный охват темы — от статистических основ до вопросов организационной культуры и этики. 1-я часть подходит начинающим, 2-я посвящена метрикам и общей метрике эффективности (OEC), 3-я — дополнительным аналитическим техникам, 4-я — инструментам и архитектуре платформы, 5-я — продвинутой статистике. Каждый читатель найдет главы, актуальные для своего уровня подготовки. Что раскрыто плохо? Байесовские подходы к экспериментам рассмотрены очень поверхностно, только как дополнительные методы. Книга в основном ориентирована на частотную статистику, что ограничивает ее полезность для команд, использующих байесовские A/B-тесты с априорными распределениями и обновлением постериоров для ускорения принятия решений. Применение машинного обучения в контексте экспериментов освещено недостаточно глубоко для специалистов по ML. Метрики офлайн-оценки алгоритмов ранжирования, контрфактические методы, многорукие бандиты — все это упоминается, но без детального объяснения того, как применять такие подходы в промышленных ML-системах. Сценарии для небольших проектов и стартапов практически не рассматриваются. Большинство примеров взято из Microsoft, Google и LinkedIn — компаний с миллионами пользователей и тысячами экспериментов. Стартапы с 10 тысячами активных пользователей или B2B-сервисы с сотнями корпоративных клиентов сталкиваются с другими трудностями: нехваткой статистической мощности, длинными циклами продаж и узкими выборками. Анализ затрат и выгод от проведения экспериментов практически отсутствует. Авторы подробно рассказывают, как создавать платформу с минимальной себестоимостью одного эксперимента, но почти не оценивают первоначальные инвестиции в инфраструктуру, инженерные ресурсы и штат аналитиков. Окупаемость и расчеты эффективности эксперимента для презентации руководству также недостаточно раскрыты. Вердикт "Доверительное A/B тестирование: Практическое руководство по контролируемым экспериментам" - это уникальная книга, которая объединяет опыт ведущих экспертов Microsoft, Google и LinkedIn и приводит реальные цифры влияния экспериментов — от миллионов до сотен миллионов долларов. Такая степень прозрачности и глубина практических уроков, накопленных десятилетиями крупномасштабного экспериментирования, практически нигде больше не встречается: большинство компаний держат эти знания внутри. Эту книгу обязательно стоит прочитать продакт-менеджерам, аналитикам данных и инженерам, работающим над цифровыми продуктами. Ее практическая ценность — в понимании того, как избежать миллионных ошибок через корректный дизайн экспериментов, создать масштабируемую платформу и внедрить культуру, где решения принимаются на основе данных, а не мнения самого высокооплачиваемого руководителя. Приобрести книгу можно здесь: https://www.labirint.ru/books/787312/ ### Обзор книги "Kalman and Bayesian Filters in Python" (R. Labbe) Книга "Kalman and Bayesian Filters in Python" написана Роджером Лаббе и впервые опубликована в 2015 году под open-source лицензией Creative Commons. Книга исследует методы оценки состояния динамических систем при наличии зашумленных сенсорных данных. Центральная идея - применение байесовского вероятностного подхода для объединения априорных знаний о системе с неточными измерениями для получения оптимальной оценки текущего состояния. Книга решает проблему доступности темы фильтров Калмана для практикующих инженеров. Традиционные академические учебники требуют несколько лет математической подготовки и редко содержат работающий код или решенные примеры. Автор устраняет этот барьер через интерактивный формат с исполняемым кодом и визуализациями. Целевая аудитория - инженеры-программисты, специалисты по computer vision, robotics и data science, которым нужно реализовать фильтры для реальных задач. Требуются базовые знания Python, линейной алгебры на уровне операций с матрицами и элементарной теории вероятностей. Ключевые аспекты 1. От g-h фильтра к фильтру Калмана Фильтр Калмана математически выводится из простейшего g-h фильтра (также известного как alpha-beta фильтр). Автор начинает с интуитивного примера отслеживания веса на шумных весах, где параметры g и h балансируют между доверием к предсказанию и доверием к новому измерению. Этот подход демонстрирует, что сложный фильтр Калмана по сути является рекурсивным алгоритмом predict-update. На каждом шаге система предсказывает следующее состояние, затем корректирует предсказание на основе нового измерения, взвешивая их относительную достоверность. 2. Байесовское обновление убеждений Дискретный байесовский фильтр вводится через конкретный пример: если сенсор сообщает о резком изменении направления объекта, интерпретация зависит от контекста. Для истребителя резкий маневр вероятен, для грузового поезда на прямой колее - маловероятен. Принцип байесовской фильтрации - никогда не отбрасывать информацию. Убеждения обновляются на основе прошлого, знаний о системе и характеристик сенсоров. Автор подчеркивает, что неопределенность знаний меняется в зависимости от силы доказательств, что формализуется через теорему Байеса. 3. Переход к непрерывным доменам через гауссианы Гауссовы распределения позволяют применять байесовский подход к непрерывным системам. Глава 3 вводит представление убеждений через нормальное распределение с параметрами среднего и дисперсии. Многомерные гауссианы (глава 5) демонстрируют мощь скрытых переменных. Совместное отслеживание позиции и скорости через ковариационную матрицу дает существенно лучшие оценки, чем отслеживание только позиции - это эффект, аналогичный триангуляции в геометрии. 4. Нелинейная фильтрация: три подхода Глава 9 идентифицирует ключевую проблему: реальный мир нелинеен, но базовый фильтр Калмана работает только для линейных систем. Автор вводит три семейства решений для нелинейных задач. Extended Kalman Filter (глава 11) использует линеаризацию через якобианы и разложение Тейлора; Unscented Kalman Filter (глава 10) пропагирует сигма-точки через нелинейные функции без вычисления производных; Particle Filters (глава 12) применяют методы Монте-Карло для произвольных распределений, включая мультимодальные. UKF автор ставит первым, так как он проще в реализации и часто дает результаты не хуже EKF. 5. Продвинутые техники: сглаживание и адаптация Глава 13 показывает, что фильтры Калмана отлично работают для постобработки данных, а не только для real-time filtering. Сглаживание использует информацию из будущих измерений для улучшения оценок прошлых состояний - предсказывать прошлое проще, чем будущее. Глава 14 покрывает adaptive filtering для маневрирующих целей, которые требуют нескольких моделей процесса. Адаптивные техники позволяют фильтру переключаться между моделями в зависимости от поведения объекта. Это критично для отслеживания объектов с переменной динамикой вроде автомобилей или самолетов. 6. Библиотека FilterPy и экосистема Автор разработал библиотеку FilterPy, которая реализует все алгоритмы из книги плюс дополнительные: Kalman filter, Extended Kalman filter, Unscented Kalman filter, particle filter, g-h filters, least squares, H-infinity filters, smoothers. Библиотека доступна через pip install filterpy и активно используется в индустрии. Код в FilterPy написан с педагогическими целями - оптимизирован для читаемости, а не производительности. Переменные и операции точно соответствуют уравнениям из текста один-к-одному. Автор использует NumPy и SciPy для всех вычислений, код совместим с Python 2.7 и 3.x. Практические примеры Книга содержит демонстрационные примеры на синтетических данных: отслеживание собаки в поле, терморегулирование здания, навигация по GPS, слежение за движущимся объектом в computer vision. Это не кейсы из реального бизнеса, а учебные задачи с контролируемыми параметрами шума для иллюстрации концепций. Автор упоминает использование FilterPy в реальных проектах на работе и то, что SpaceX применяет книгу для обучения концепциям state estimation, но конкретные детали не раскрываются. Книга насыщена визуализациями - каждый алгоритм сопровождается 5-10 графиками распределений вероятностей, ковариационных эллипсов, траекторий фильтрации. Баланс сильно смещен в сторону практики: примерно 70% составляет исполняемый код Python и интерактивные примеры, 30% - теоретические объяснения. Инструменты полностью актуальны: Python 3, NumPy, SciPy, Matplotlib, библиотека FilterPy активно поддерживается и обновляется автором. Полезность книги Тема раскрыта очень полно для вводного уровня - книга покрывает весь спектр от базовых концепций до продвинутых алгоритмов через 14 глав и 5 приложений. Что раскрыто хорошо? Прогрессивное построение знаний от g-h фильтра до particle filters через последовательные главы, где каждая концепция вводится точно тогда, когда она нужна. Это делает материал доступным для самостоятельного изучения без преподавателя - структура от простого к сложному исключает информационные перегрузки. Интерактивный формат Jupyter Notebook с более чем 500 работающими примерами кода, которые можно модифицировать и запускать прямо в браузере через Binder. Все графики и результаты воспроизводимы - читатель может изменить параметр шума или ковариации и мгновенно увидеть эффект на поведение фильтра. Каждая глава содержит упражнения с полными решениями в коде, что радикально отличается от традиционных учебников типа Grewal & Andrews. Автор доверяет читателю и не скрывает ответы - если нужен быстрый ответ, можно прочитать решение, для глубокого понимания - реализовать самостоятельно. Бесплатная доступность под Creative Commons Attribution 4.0 лицензией с открытым кодом на GitHub. Книга получает отзывы от Allen Downey (профессор и автор O'Reilly) и используется SpaceX для внутреннего обучения, что подтверждает качество материала и его применимость в индустрии. Что раскрыто плохо? Численная стабильность фильтров обсуждается поверхностно в главе 7, недостаточно для продакшн-реализаций в критических системах. Автор предупреждает, что наивная реализация не численно стабильна, но детальные методы типа square-root filtering или UD-factorization требуют дополнительных источников. Отсутствуют реальные кейсы из бизнеса или индустрии с публичными датасетами для воспроизведения. Все примеры синтетические с искусственным гауссовым шумом, что не отражает сложности вроде outliers, пропущенных измерений, нестационарного шума или sensor bias в продакшн-системах. Вердикт Книга уникальна полностью интерактивным форматом Jupyter Notebook, где код, текст, математика и визуализации объединены в одном месте. Такого подхода я не встречал ни в одном классическом учебнике по фильтрам Калмана - читатель может немедленно экспериментировать с параметрами и видеть результаты без переключения между IDE и PDF. Книгу обязательно стоит читать всем, кто работает с зашумленными временными рядами или задачами трекинга. Практическая ценность - за 2-3 недели изучения можно перейти от нулевых знаний к реализации работающих фильтров для собственных задач, включая нелинейные системы через UKF или particle filters. Скачать книгу можно здесь: https://github.com/rlabbe/Kalman-and-Bayesian-Filters-in-Python ### Детекция аномалий через Isolation Forest Аномалии в данных бывают разными. Большинство — это шум, ошибки сбора, сбитые логи или просто разовые всплески, которые искажают распределения и ухудшают работу моделей. Но среди них могут встречаться действительно важные точки — те, что указывают на сбои в системах, подозрительные действия пользователей или нетипичную динамику бизнес-показателей. В большинстве примеров из учебных пособий аномалии легко определяются визуально. Однако в реальных данных детекция аномалий - не такая уж простая задача. Сложность в том, что реальные данные редко подчиняются аккуратным распределениям. Пороговые правила быстро ломаются, а классические подходы — статистические тесты, кластеризация, методы расстояний — оказываются либо слишком чувствительными к шуму, либо плохо масштабируются на больших и многомерных выборках. Алгоритм Isolation Forest (Изоляционный лес) предлагает другой путь. Он не пытается моделировать «норму» — он измеряет, насколько легко изолировать каждую точку. Аномальные значения отделяются от остальных быстрее, и длина пути до изоляции становится критерием «подозрительности». Чем путь короче, тем выше шанс, что точка выбивается из общей картины. Принципы работы Isolation Forest Метод изоляционного леса был предложен Liu, Ting и Zhou в 2008 году. С тех пор Isolation Forest стал одним из базовых инструментов для детекции аномалий без учителя, вошел в состав библиотеки scikit-learn и широко применяется в задачах, где классические статистические методы оказываются неприменимы из-за сложной структуры данных или отсутствия подходящей параметрической модели. Метод работает линейно по времени, не требует вычисления расстояний между всеми парами объектов и хорошо справляется с многомерными данными, что делает его удобным инструментом для практической детекции аномалий. Isolation Forest эксплуатирует свойство аномалий: они находятся далеко от плотных областей распределения и требуют меньше операций для изоляции. Метод строит ансамбль бинарных деревьев, где каждое дерево рекурсивно разбивает пространство признаков случайными гиперплоскостями. Нормальные точки, расположенные в плотных областях, изолируются глубже в дереве. Аномалии изолируются ближе к корню. Основное отличие изоляционного леса от density-based методов (DBSCAN, LOF) — отсутствие необходимости явно оценивать плотность или вычислять расстояния между точками. Isolation Forest не строит кластеры и не требует метрики близости. Метод работает через случайное разбиение, что обеспечивает вычислительную эффективность O(n log n) для построения одного дерева и O(log n) для оценки одной точки. Концепция изоляции Изоляция — процесс рекурсивного разбиения данных до момента, когда точка оказывается в отдельном листе дерева. На каждом шаге алгоритм случайно выбирает признак и случайный порог между минимальным и максимальным значением этого признака в текущем подмножестве данных. Пространство делится на два региона, процесс повторяется рекурсивно для каждого региона до достижения условия остановки. Разработчики алгоритма заметили, что все аномалии имеют 2 ключевых свойства: Малочисленность; Отличие от нормальных наблюдений. Эти свойства приводят к тому, что аномалии изолируются быстрее. Рассмотрим двумерное пространство с одной аномалией на периферии плотного кластера. При случайном разбиении вероятность провести границу между аномалией и кластером высока на первых шагах. Для изоляции точки внутри кластера потребуется множество разбиений, поскольку каждое разбиение отсекает лишь часть плотной области. Рис. 1: Визуализация принципа изоляции в Isolation Forest. Левая панель демонстрирует изоляцию аномалии (красный крест) одним разбиением, правая панель показывает необходимость множественных разбиений для изоляции нормальной точки (зеленый круг) внутри плотного кластера Случайность выбора признака и порога обеспечивает устойчивость метода. Один случайный выбор может быть неудачным, но ансамбль деревьев усредняет результаты и дает стабильную оценку. Количество деревьев контролирует дисперсию оценок: больше деревьев — меньше шум, но выше вычислительная стоимость. Математическая основа Центральная метрика Isolation Forest — длина пути (path length) от корня дерева до листа, содержащего точку. Обозначим h(x) — длину пути для точки x в одном дереве. Аномалии имеют малые значения h(x), нормальные точки — большие. Для устранения зависимости от размера дерева используется нормализация на основе ожидаемой длины пути в случайном бинарном дереве. Ожидаемая длина пути для выборки размера n в бинарном дереве поиска приближается к: c(n) = 2H(n−1) − 2(n−1)/n где: H(i) — гармоническое число, H(i) ≈ ln(i) + 0.5772; n — размер выборки; c(n) — средняя длина пути в сбалансированном дереве. Эта формула выводится из анализа средней глубины листа в случайном бинарном дереве поиска. Гармоническое число описывает вероятностный характер случайных разбиений, поэтому оно входит в выражение для ожидаемой глубины. Часть 2H(n−1) отвечает за среднюю «полную» глубину дерева, а вычитание 2(n−1)/n вносит поправку на то, что реальные случайные деревья не бывают идеально сбалансированными. Anomaly score s(x,n) для точки x вычисляется как: s(x,n) = 2^(−E[h(x)]/c(n)) где: E[h(x)] — среднее значение длины пути по всем деревьям в лесу; c(n) — нормализующая константа; s(x,n) — итоговая оценка аномальности. Значение s(x,n) лежит в диапазоне [0, 1]. Интерпретация: s → 1: высокая вероятность аномалии, точка изолируется близко к корню; s → 0.5: нормальное наблюдение, длина пути близка к средней; s < 0.5: точка находится в очень плотной области. Экспоненциальная форма обеспечивает нелинейное масштабирование оценок. Различия в длине пути между аномалиями и нормальными точками усиливаются, что упрощает выбор порога для классификации. Алгоритм построения леса Построение Isolation Forest состоит из двух фаз: Обучение (построение деревьев); Оценка (вычисление anomaly scores). Обучение начинается с формирования ансамбля изоляционных деревьев. Для каждого дерева алгоритм выбирает случайную подвыборку размера ψ из исходных данных. Использование подвыборок снижает вычислительную сложность и повышает разнообразие деревьев. Процесс построения одного дерева: Выбрать случайную подвыборку размера ψ из n точек данных; Установить текущий узел как корень, назначить ему всю подвыборку; Если узел содержит одну точку или достигнута максимальная глубина — сделать его листом; Случайно выбрать признак q из доступных признаков; Случайно выбрать порог p между min и max значениями признака q в текущем узле; Разбить данные: точки с q < p отправить в левое поддерево, остальные в правое; Рекурсивно повторить шаги 3-6 для левого и правого поддеревьев. Случайный выбор признака и порога — это ключевой элемент алгоритма. Признак выбирается равномерно из всех доступных, а порог — равномерно из диапазона значений выбранного признака. Благодаря этому Isolation Forest принципиально отличается от классических деревьев решений, которые подбирают разбиения так, чтобы максимизировать чистоту узлов. Внесенная случайность позволяет строить деревья гораздо быстрее и обеспечивает разнообразие в ансамбле, что напрямую повышает качество изоляции аномалий. Рис. 2: Процесс построения изоляционного дерева. Верхняя панель показывает случайные разбиения пространства признаков на подвыборке данных, нижняя панель иллюстрирует соответствующую древовидную структуру с рекурсивными разбиениями до изоляции точек в листьях Максимальная глубина дерева обычно ограничивается значением log₂(ψ). Для подвыборок размера 256 это около 8 уровней. Ограничение глубины предотвращает переобучение и ускоряет построение. Аномалии изолируются на малой глубине, поэтому глубокие деревья не нужны. Фаза оценки проходит после построения всех деревьев. Для каждой тестовой точки x алгоритм проходит от корня до листа в каждом дереве, подсчитывая количество разбиений (длину пути). Значения усредняются по всем деревьям, нормализуются и преобразуются в anomaly score через экспоненциальную функцию. Гиперпараметры модели Производительность Isolation Forest зависит от 4-х основных гиперпараметров: Количество деревьев; Размер подвыборки; Доля загрязнения (доля выбросов в данных); Максимальное число признаков для разбиения. Оптимальный выбор параметров определяется характеристиками данных и требованиями задачи. Авторы оригинальной работы провели эмпирический анализ и дали рекомендации, актуальные для большинства применений. Количество деревьев (n_estimators) Параметр n_estimators задает размер ансамбля деревьев. Увеличение числа деревьев снижает дисперсию оценок и делает детекцию аномалий более стабильной. Каждое дерево добавляет свою порцию случайности за счет отбора подвыборки, признаков и порогов. Усреднение результатов большого числа деревьев помогает сгладить случайные колебания и повысить надежность модели. Эмпирические исследования показывают: увеличение n_estimators свыше 100 дает убывающую отдачу. Разница в качестве между 100 и 200 деревьями обычно составляет доли процента по метрикам precision и recall. Да, конечно, бывают экстраординарные случаи, но все же типичные значения: 100-300 деревьев для большинства задач. Вычислительная стоимость растет линейно с числом деревьев n_estimators. Построение леса из 100 деревьев занимает примерно в 10 раз больше времени, чем из 10. Тем не менее, благодаря высокой эффективности алгоритма даже 1000 деревьев строятся за считанные секунды на современном оборудовании для выборок с десятками тысяч наблюдений. Оценка новых точек также происходит быстро: чтобы пройти через 100 деревьев глубиной 8, требуется около 800 сравнений. Практическая рекомендация: начинать с n_estimators=100, увеличивать до 200-300 при высокой вариативности оценок между запусками. Если время обучения критично, можно снизить до 50 без существенной потери качества на чистых данных с явными аномалиями. Размер подвыборки (max_samples) Параметр max_samples определяет размер ψ случайной выборки для построения каждого дерева. Оригинальная работа рекомендует ψ = 256 как оптимальное значение для большинства задач. Это эмпирическая находка, основанная на балансе между качеством детекции и вычислительной эффективностью. Малые значения ψ (64-128) приводят к мелким деревьям и быстрому обучению, но повышают дисперсию оценок. Каждое дерево видит ограниченную часть данных, оценки становятся менее надежными. Большие значения ψ (512-1024) увеличивают глубину деревьев и время построения. Аномалии могут изолироваться глубже, что снижает разделяющую способность метода. Значение 256 обеспечивает глубину дерева около 8 уровней (log₂(256) = 8). Это достаточно для изоляции большинства аномалий и недостаточно для переобучения на шум. Экспериментально показано: для выборок размером от нескольких тысяч до миллионов наблюдений качество детекции стабильно при ψ = 256. Для выборок меньше 256 наблюдений рекомендуется устанавливать max_samples='auto' или max_samples=min(256, n), где n — размер выборки. Scikit-learn по умолчанию использует min(256, n_samples). Для очень больших данных (миллионы строк) можно увеличить ψ до 512, но прирост качества обычно минимален. Доля загрязнения (contamination) Параметр contamination определяет ожидаемую долю аномалий в данных. Значение используется для установки порога классификации: точки с anomaly score выше порога маркируются как аномалии. Если contamination=0.1, то это значит, что 10% точек с наивысшими scores считаются аномалиями. Неправильный выбор contamination напрямую влияет на качество детекции. Если значение слишком низкое (например, 0.01 при реальной доле аномалий 0.05), часть настоящих аномалий останется незамеченной — растет число пропусков (false negatives). Если значение слишком высокое (например, 0.1 при реальной доле 0.02), алгоритм начнет ошибочно отмечать нормальные точки как аномальные — увеличивается число ложных срабатываний (false positives). В реальных задачах доля аномалий часто неизвестна. В таких случаях стратегии выбора contamination следующие: Априорная оценка на основе знаний предметной области (domain knowledge): если известно, что аномалии составляют 1-2% данных, устанавливаем contamination=0.01; Валидация на размеченной подвыборке: если доступна небольшая выборка с известными метками, подбираем contamination по максимуму F1-score; Консервативный подход: устанавливаем низкое значение (0.01-0.05) для минимизации false positives, затем вручную проверяем топ-K точек по anomaly score; Автоматический выбор через gap statistic: анализируем распределение anomaly scores, ищем разрыв между нормальными и аномальными значениями. Scikit-learn по умолчанию использует contamination=0.1, что подходит для демонстрационных примеров, но часто завышено для производственных систем. Типичные значения в практических задачах: 0.01-0.05. Для систем мониторинга, где критичны ложные срабатывания, используют 0.001-0.01. Важно понимать: contamination не влияет на обучение модели, только на порог классификации. Anomaly scores вычисляются независимо от этого параметра. Порог можно изменить после обучения модели без необходимости ее переобучения. Максимальное число признаков (max_features) Параметр max_features ограничивает количество признаков, рассматриваемых при каждом разбиении. По умолчанию используются все признаки (max_features=1.0). Ограничение числа признаков повышает разнообразие деревьев и может улучшить детекцию в высокоразмерных пространствах. В данных с сотнями признаков многие могут быть нерелевантны или коррелированы. Использование всех признаков на каждом разбиении приводит к тому, что часть деревьев выбирает шумные признаки, снижая качество изоляции. Ограничение max_features=0.5 или max_features=sqrt(n_features) вынуждает деревья использовать разные подмножества признаков, повышая разнообразие ансамбля. Эффект схож с Random Forest, где случайный выбор признаков улучшает обобщающую способность. Для Isolation Forest это не так важно, поскольку сам принцип изоляции робастен к шуму. Тем не менее, в задачах с высокой размерностью (>100 признаков) разумно тестировать max_features=0.5-0.8. Вычислительная сложность не зависит от max_features напрямую — признак выбирается случайно, тут не требуется перебор. Однако в реализации scikit-learn меньшее max_features может незначительно ускорить построение из-за оптимизаций кеширования. Практическая рекомендация: для данных с <50 признаками использовать max_features=1.0 (все признаки). Для высокоразмерных данных тестировать max_features=0.5-0.8, оценивать влияние через кросс-валидацию на размеченной подвыборке. Если размеченных данных нет, сравнивать стабильность топ-K аномалий между запусками. Практическая реализация на Python Метод машинного обучения Isolation Forest реализован в библиотеке scikit-learn в модуле sklearn.ensemble. Для работы с данными потребуются numpy и pandas, для визуализации — matplotlib. Рассмотрим базовую детекцию аномалий и многомерный анализ на реальных данных. Базовая детекция Начнем с загрузки данных и построения модели. Используем котировки акций Taiwan Semiconductor (TSM) за последние 2 года. Задача - обнаружить аномальные торговые сессии по признакам: дневная доходность, объем торгов, внутридневной диапазон (high-low). import yfinance as yf import numpy as np import pandas as pd from sklearn.ensemble import IsolationForest import matplotlib.pyplot as plt # Загрузка данных ticker = yf.Ticker("TSM") data = ticker.history(period="2y") # Проверка структуры данных yfinance if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.get_level_values(0) # Вычисление признаков data['Return'] = data['Close'].pct_change() data['Range'] = (data['High'] - data['Low']) / data['Close'] data['Volume_norm'] = (data['Volume'] - data['Volume'].rolling(20).mean()) / data['Volume'].rolling(20).std() # Удаление первых строк с NaN data = data.dropna() # Формирование матрицы признаков features = data[['Return', 'Range', 'Volume_norm']].values # Построение модели iso_forest = IsolationForest( n_estimators=200, max_samples=256, contamination=0.02, random_state=42 ) # Обучение и предсказание data['Anomaly'] = iso_forest.fit_predict(features) data['Anomaly_score'] = iso_forest.score_samples(features) # Аномалии имеют метку -1, нормальные точки +1 anomalies = data[data['Anomaly'] == -1] print(f"Обнаружено аномалий: {len(anomalies)}") print(f"Процент аномалий: {len(anomalies)/len(data)*100:.2f}%") print("\nТоп-5 аномалий по anomaly score:") print(data.nlargest(5, 'Anomaly_score')[['Close', 'Return', 'Volume', 'Anomaly_score']]) Обнаружено аномалий: 10 Процент аномалий: 2.07% Топ-5 аномалий по anomaly score: Close Return Volume Anomaly_score Date 2024-07-23 00:00:00-04:00 166.866852 0.002775 11690700 -0.372882 2024-02-21 00:00:00-05:00 122.276474 0.000080 11469700 -0.374339 2024-04-29 00:00:00-04:00 135.663483 0.001446 9997100 -0.375019 2024-02-15 00:00:00-05:00 125.876297 -0.001856 12554000 -0.375208 2024-04-11 00:00:00-04:00 144.185303 0.006702 11818800 -0.375273 Модель обучается на 3-х признаках: Дневная доходность показывает резкие движения цены; Нормализованный диапазон отражает внутридневную волатильность; Нормализованный объем выявляет необычную торговую активность. Параметр contamination=0.02 предполагает 2% аномальных сессий — реалистичная оценка для ликвидных акций. Метод fit_predict() возвращает бинарные метки: -1 для аномалий, +1 для нормальных точек. Метод score_samples() дает anomaly scores — отрицательные значения, где более отрицательные соответствуют более аномальным точкам. Знак инверсирован относительно теоретической формулы (где s ∈ [0,1]) для совместимости с другими методами scikit-learn. # Визуализация plt.figure(figsize=(15,6)) plt.plot(data.index, data['Close'], label='Close Price', color='darkgray') plt.scatter(anomalies.index, anomalies['Close'], color='red', label='Anomalies', marker='o', s=50) plt.title('TSM: Обнаруженные аномалии с помощью Isolation Forest') plt.xlabel('Дата') plt.ylabel('Цена закрытия') plt.legend() plt.show() Рис. 3: Визуализация обнаруженных аномалий в котировках TSM c помощью Isolation Forest Результаты показывают торговые дни с нестандартным поведением: резкие падения или ралли, аномальные объемы, высокая внутридневная волатильность. Такие точки заслуживают детального анализа — они могут сигнализировать о важных событиях: публикация отчетности, новости о компании, макроэкономические шоки. Многомерный анализ Расширим анализ визуализацией аномалий в многомерном пространстве. Построим графики временных рядов с выделением аномальных точек и распределение anomaly scores. # Визуализация fig, axes = plt.subplots(3, 1, figsize=(14, 10)) # Ценовые бары с аномалиями bar_width = 0.8 axes[0].bar(data.index, data['High'] - data['Low'], bottom=data['Low'], color='#2d2d2d', width=bar_width, alpha=0.6) axes[0].bar(anomalies.index, anomalies['High'] - anomalies['Low'], bottom=anomalies['Low'], color='red', width=bar_width, alpha=0.8) axes[0].scatter(anomalies.index, anomalies['Close'], color='red', s=50, zorder=5, label=f'Anomalies ({len(anomalies)})') axes[0].set_ylabel('Price (USD)', fontsize=10) axes[0].legend(loc='upper left') axes[0].grid(alpha=0.3) # Дневная доходность axes[1].plot(data.index, data['Return'], color='#2d2d2d', linewidth=0.8) axes[1].scatter(anomalies.index, anomalies['Return'], color='red', s=50, zorder=5) axes[1].axhline(y=0, color='gray', linestyle='--', linewidth=0.8) axes[1].set_ylabel('Daily return', fontsize=10) axes[1].grid(alpha=0.3) # Объем торгов axes[2].bar(data.index, data['Volume'], color='#2d2d2d', width=1, alpha=0.6) axes[2].bar(anomalies.index, anomalies['Volume'], color='red', width=1, alpha=0.8) axes[2].set_ylabel('Volume', fontsize=10) axes[2].set_xlabel('Date', fontsize=10) axes[2].grid(alpha=0.3) plt.tight_layout() plt.show() # Распределение anomaly scores fig, ax = plt.subplots(figsize=(10, 6)) ax.hist(data['Anomaly_score'], bins=50, color='#2d2d2d', alpha=0.6, label='All points') threshold = np.percentile(data['Anomaly_score'], 100 * (1 - 0.02)) ax.axvline(x=threshold, color='red', linestyle='--', linewidth=2, label=f'Threshold (contamination=0.02)') ax.set_xlabel('Anomaly score', fontsize=11) ax.set_ylabel('Frequency', fontsize=11) ax.set_title('Distribution of anomaly scores', fontsize=12, fontweight='bold') ax.legend() ax.grid(alpha=0.3) plt.tight_layout() plt.show() # Scatter plot: Return vs Volume_norm fig, ax = plt.subplots(figsize=(10, 7)) scatter = ax.scatter(data['Return'], data['Volume_norm'], c=data['Anomaly_score'], cmap='RdYlBu_r', s=30, alpha=0.6, edgecolors='none') top_anomalies = data.nsmallest(10, 'Anomaly_score') ax.scatter(top_anomalies['Return'], top_anomalies['Volume_norm'], color='red', s=100, marker='x', linewidths=2, label='Top-10 anomalies', zorder=5) ax.set_xlabel('Daily return', fontsize=11) ax.set_ylabel('Normalized volume', fontsize=11) ax.set_title('Anomaly detection in 2D feature space', fontsize=12, fontweight='bold') ax.grid(alpha=0.3) ax.legend() cbar = plt.colorbar(scatter, ax=ax) cbar.set_label('Anomaly score', fontsize=10) plt.tight_layout() plt.show() Код генерирует три визуализации. Первая показывает временные ряды цены, доходности и объема с выделением аномальных точек красным цветом. Видно, когда происходили нестандартные события: резкие движения цены сопровождались аномальными объемами. Рис. 4: Дневные бары динамики цен, дневная доходность и объемы торгов с отображением аномалий Вторая визуализация демонстрирует распределение anomaly scores и порог классификации. Большинство точек сконцентрировано в области высоких (менее отрицательных) scores, аномалии находятся в левом хвосте распределения. Рис. 5: Гистограмма распределения оценок аномальности с порогом Третья визуализация проецирует данные на двумерное пространство признаков (доходность и нормализованный объем) с раскраской по anomaly score. Топ-10 аномалий выделены красными крестами. Scatter plot показывает: аномалии часто находятся на периферии облака точек — комбинация высокого разброса доходности и аномального объема, либо экстремальные значения одного из признаков при нормальном втором. Рис. 6: Диаграмма рассеяния взаимосвязи доходности и нормализованного объема с подсветкой топ-10 аномалий Интерпретация результатов требует знаний предметной области. Не все аномалии одинаково важны: одни связаны с техническими событиями, такими как дивидендные выплаты или сплиты акций, другие отражают фундаментальные события — публикацию квартальных отчетов, геополитические риски или изменения в цепочках поставок. Важно учитывать, что Isolation Forest выявляет статистические отклонения, но не объясняет их причины, поэтому для полноценного анализа необходимо сопоставлять найденные аномалии с событиями в соответствующие даты. Сравнение с альтернативными методами Isolation Forest — один из множества алгоритмов детекции аномалий. Но есть и другие. Альтернативы включают методы на основе плотности, расстояний, статистические подходы. Каждый метод имеет сильные стороны и ограничения. Выбор зависит от размерности данных, вычислительных ресурсов, интерпретируемости результатов: Local Outlier Factor (LOF) оценивает локальную плотность точки относительно соседей. Он эффективен для обнаружения локальных аномалий в данных с кластерами разной плотности, но становится медленным в высокоразмерных пространствах. One-Class SVM строит границу, отделяющую нормальные данные от выбросов в преобразованном пространстве. Метод подходит для небольших и низкоразмерных выборок, но плохо масштабируется и чувствителен к масштабированию признаков. Статистические методы (Z-score, IQR, Mahalanobis distance) выявляют точки, сильно отклоняющиеся от распределения. Методы быстры и интерпретируемы для одномерных или низкоразмерных данных, но требуют предположений о распределении и плохо работают в высоких размерностях. Преимущества Isolation Forest: Метод не требует предположений о распределении данных и автоматически адаптируется к структуре данных; Масштабируемость: линейная сложность позволяет работать с миллионами наблюдений, легко параллелится; Устойчивость к высокоразмерным данным: случайные разбиения по признакам сохраняют эффективность; Робастность к шуму и нерелевантным признакам за счет усреднения ансамбля; Алгоритм не требует размеченных данных, подходит для unsupervised задач с редкими аномалиями. Ограничения Isolation Forest: Masking effect: плотные кластеры аномалий могут оставаться незамеченными; Чувствителен к параметру contamination - неправильный выбор влияет на false positives / negatives; Ограниченная интерпретируемость: anomaly score не объясняет причины аномальности; Предположение о независимости признаков может пропускать аномалии, проявляющиеся в комбинациях признаков; Нет гарантий оптимальности: разные запуски дают слегка разные результаты. Заключение Метод изоляционного леса изменил ландшафт индустрии в области детекции аномалий, предложив элегантное решение, основанное на простой идее: аномалии легче изолировать, чем другие данные. Метод отказывается от вычисления плотностей и расстояний в пользу случайного разбиения пространства, что обеспечивает линейную сложность и масштабируемость на данных любого размера. Он универсален, не требует предположений о распределении признаков и эффективно работает в высокоразмерных пространствах, сохраняя устойчивость к шуму и нерелевантным признакам. Несмотря на преимущества, метод имеет ограничения: плотные кластеры аномалий могут оставаться незамеченными, параметр contamination требует внимательного выбора, а anomaly score не объясняет причины отклонений. Тем не менее, Isolation Forest остается мощным инструментом для предварительного анализа данных, выявления выбросов и построения систем мониторинга, особенно в условиях больших объемов и сложной структуры данных. ### Машинное обучение для A/B тестов: практический гайд по CUPAC A/B-тестирование остается основным инструментом для принятия продуктовых решений в технологических компаниях. Главная проблема — высокая дисперсия метрик, из-за которой требуется несколько недель или даже месяцев, чтобы достичь статистической значимости. Длительные эксперименты замедляют итерации и увеличивают альтернативные издержки. Классический подход к снижению дисперсии — метод CUPED. Он использует исторические значения целевой метрики для корректировки результатов. Метод CUPAC (Control Using Predictions As Covariates) расширяет эту идею: вместо исторических данных используются предсказания машинного обучения. Это позволяет задействовать более широкий набор признаков и достичь большего снижения дисперсии. Проблема variance в экспериментах Статистическая мощность A/B теста определяется размером эффекта, объемом выборки и дисперсией метрики. Первые два параметра часто находятся вне контроля исследователя: изменения в продукте дают тот эффект, который дают, а увеличение трафика ограничено масштабом бизнеса. Остается третий параметр — дисперсия. Почему высокая дисперсия замедляет тесты Стандартная ошибка среднего пропорциональна: σ/√n где: σ — стандартное отклонение метрики; n — размер выборки. Для достижения статистической значимости при фиксированном эффекте требуемый размер выборки растет квадратично с увеличением дисперсии. Типичный пример: метрика Выручка на пользователя (RPU) обладает высокой дисперсией, потому что в выборке одновременно присутствуют неактивные пользователи с нулевой выручкой и клиенты, которые тратят очень много. Из-за этого распределение сильно скошено вправо, что приводит к длительным экспериментам даже при заметных изменениях в продукте. Практические последствия высокой дисперсии: Эксперименты длятся 4-8 недель вместо 1-2 недель; Невозможность тестировать небольшие улучшения, которые в сумме дают значительный эффект; Увеличение риска внешних конфаундеров (сезонность, конкурентные действия, технические инциденты); Замедление скорости итераций и продуктовых релизов. Снижение дисперсии в 4 раза сокращает требуемую длительность эксперимента в 4 раза или позволяет обнаруживать эффекты в 2 раза меньшей величины при той же длительности. Традиционные подходы к снижению дисперсии Стратификация — классический метод из теории выборки. Пользователи разбиваются на однородные группы (страты) по заранее известным признакам, внутри каждой страты проводится рандомизация. Дисперсия итоговой оценки снижается за счет исключения между-стратовой вариации. Ограничения стратификации в онлайн-экспериментах: Необходимость отбора признаков для стратификации до начала эксперимента; Сложность с непрерывными признаками, требующими дискретизации; Комбинаторный рост числа страт при использовании нескольких признаков; Малые размеры некоторых страт приводят к проблемам с оценкой. Постстратификация (post-stratification) частично решает проблему высокой дисперсии, но требует точного знания размеров страт в генеральной совокупности. Метод регрессионной корректировки (regression adjustment) использует линейную регрессию для уточнения оценок, однако его эффективность ограничена допущением о линейности связей. Подход CUPED стал настоящим прорывом, объединив простоту использования и строгие теоретические гарантии. Метод не требует предварительной стратификации и может применяться post hoc к результатам практически любого эксперимента. CUPED: фундамент для CUPAC CUPED использует ковариатную корректировку для снижения дисперсии экспериментальных метрик. Основная идея: если известна переменная, коррелирующая с целевой метрикой но не зависящая от воздействия (treatment), ее можно использовать для уменьшения шума в оценках. Механика работы CUPED Пусть Y — метрика в период эксперимента, X — та же метрика в предэкспериментальный период (pre-experiment period). Скорректированная метрика вычисляется как: Y_cv = Y - θ(X - E[X]) где: Y_cv — скорректированное значение метрики; Y — наблюдаемое значение в эксперименте; X — значение ковариаты (pre-experiment метрика); θ — оптимальный коэффициент корректировки; E[X] — среднее значение ковариаты по всей выборке. Параметр θ выбирается для минимизации дисперсии скорректированной метрики. Оптимальное значение: θ* = Cov(Y, X) / Var(X) Это стандартный коэффициент из линейной регрессии Y на X. Дисперсия скорректированной метрики: Var(Y_cv) = Var(Y)(1 - ρ²) где ρ — корреляция между Y и X. При ρ = 0.7 дисперсия снижается на 51%, что эквивалентно удвоению размера выборки. Ключевое свойство метода CUPED состоит в том, что он не вносит смещения (bias) в оценку эффекта воздействия (treatment effect). Корректирующий член θ · (X − E[X]) по построению имеет нулевое математическое ожидание, поэтому выполняется равенство E[Y_cv] = E[Y]. Благодаря рандомизации распределение X одинаково в контрольной и тестовой группах, и значит корректировка не влияет на разницу между ними. Ограничения метода: CUPED требует исторических данных той же метрики для каждого пользователя, поэтому новые пользователи не могут быть корректно обработаны — для них либо используют нули, снижая эффективность, либо исключают из корректировки; Метод чувствителен к нестационарности: если поведение пользователей меняется между pre- и post- периодами, корреляция падает, и эффективность CUPED снижается; Кроме того, CUPED работает только с одной ковариатой и не позволяет использовать дополнительную информацию о пользователях; расширение на несколько признаков приводит к нестабильности оценок и может увеличить дисперсию. CUPAC: использование ML-предсказаний CUPAC решает ограничения CUPED через замену исторических значений метрики на предсказания машинного обучения. Вместо корреляции между pre- и post-experiment значениями одной метрики используется предиктивная модель, обученная на множестве признаков. Ключевые отличия от CUPED Архитектурное изменение заключается в разделении на два этапа: обучение предиктивной модели и применение ковариатной корректировки. Модель обучается предсказывать целевую метрику Y по набору признаков X, которые доступны до начала эксперимента. Предсказания ŷ = f(X) затем используются как ковариата в формуле корректировки. Преимущества подхода: Возможность использовать любые признаки пользователей, не только историю целевой метрики; Работа с новыми пользователями через предсказания по демографическим и первым взаимодействиям; Адаптация к нестационарности через регулярное переобучение модели; Большее снижение дисперсии за счет более точных предсказаний. Качество предсказаний напрямую влияет на размер снижения дисперсии. Если модель объясняет 60% дисперсии целевой метрики (R² = 0.6), теоретический предел снижения дисперсии через CUPAC составляет 60%. Для сравнения, CUPED с корреляцией 0.7 между периодами дает снижение на 49%. Выбор признаков для модели имеет ключевое значение. Все используемые признаки должны быть доступны до начала эксперимента и не зависеть от назначения в контрольную или тестовую группу. Это исключает использование конкурентных фичей (concurrent features) — характеристик, измеряемых одновременно с целевой метрикой и потенциально затрагиваемых воздействием. Математический аппарат Корректировка в CUPAC использует ту же формулу, что и CUPED: Y_cv = Y - θ(ŷ - E[ŷ]) где: Y_cv — скорректированное значение метрики; Y — наблюдаемое значение; ŷ — предсказание ML-модели; θ — коэффициент корректировки; E[ŷ] — среднее предсказание. Оптимальный коэффициент: θ* = Cov(Y, ŷ) / Var(ŷ) Это эквивалентно регрессии остатков на предсказания. Дисперсия скорректированной метрики: Var(Y_cv) = Var(Y)(1 - ρ²_Yŷ) где ρ_Yŷ — корреляция между фактическими значениями и предсказаниями. Эта корреляция связана с R² модели: ρ²_Yŷ = R², следовательно максимальное снижение дисперсии определяется качеством предсказаний модели. Важное свойство метода: корректировка остается несмещенной при любом качестве модели. Даже полностью случайные предсказания не приведут к систематической ошибке в оценке treatment effect — в худшем случае они просто не уменьшат дисперсию. Благодаря этому CUPAC остается устойчивым к ошибкам моделирования. Практическая реализация требует особого внимания к оценке коэффициента θ. Наивный подход — вычислять θ на тех же данных, на которых обучалась модель — это приводит к переобучению корректировки. Правильная практика — использовать кросс-валидационные предсказания (cross-fitted predictions), когда для каждого пользователя предсказание получено моделью, обученной без его данных. Выбор предикторов для ковариат Эффективный набор признаков для CUPAC модели балансирует между предсказательной силой и практичностью сбора данных. Категории признаков по убыванию важности: Исторические значения целевой метрики остаются наиболее сильными предикторами. Агрегаты за разные временные окна (7, 14, 30 дней) захватывают как недавние тренды, так и долгосрочные паттерны. Для метрик с сезонностью добавляются значения из аналогичных периодов прошлого года; Поведенческие метрики включают частоту использования продукта, глубину взаимодействия, давность последнего визита. Эти признаки особенно ценны для новых пользователей, у которых нет длинной истории целевой метрики; Демографические и контекстные признаки: возраст, география, тип устройства, источник привлечения. Их предсказательная сила обычно ниже, но они доступны для всех пользователей, включая новых; Признаки из смежных метрик расширяют информацию. Если целевая метрика — выручка на пользователя, то признаки из количества сессий, времени в продукте, метрик вовлеченности добавляют ортогональную информацию. Реализация CUPAC на Python Практическое применение CUPAC требует интеграции нескольких компонентов: сбор и подготовка данных, обучение предиктивной модели, применение корректировки в эксперименте, валидация результатов. Подготовка данных и обучение модели Временная структура данных для CUPAC включает три периода: training period для обучения модели; prediction period для генерации предсказаний; experiment period для проведения теста. Training period должен быть достаточно длинным для накопления статистики (обычно 60-90 дней) и располагаться до начала эксперимента. import pandas as pd import numpy as np from datetime import datetime, timedelta class CUPACPipeline: def __init__(self, training_days=60, prediction_lag=7): """ training_days: длина периода для обучения модели prediction_lag: задержка между prediction и experiment периодами """ self.training_days = training_days self.prediction_lag = prediction_lag self.model = None self.scaler = None self.theta = None def prepare_training_data(self, df, experiment_start_date): """ Подготовка данных для обучения модели """ # Определение временных границ prediction_date = experiment_start_date - timedelta(days=self.prediction_lag) training_end = prediction_date training_start = training_end - timedelta(days=self.training_days) # Фильтрация данных training_data = df[ (df['date'] >= training_start) & (df['date'] < training_end) ].copy() # Создание целевой переменной (будущие значения) target_data = df[ (df['date'] >= prediction_date) & (df['date'] < experiment_start_date) ].copy() target_agg = target_data.groupby('user_id').agg({ 'revenue': 'sum', 'sessions': 'count' }).reset_index() target_agg.columns = ['user_id', 'target_revenue', 'target_sessions'] return training_data, target_agg, prediction_date def engineer_features(self, df, reference_date): """ Создание признаков для модели """ features_list = [] # Агрегация по временным окнам for window in [7, 14, 30]: window_start = reference_date - timedelta(days=window) window_data = df[ (df['date'] >= window_start) & (df['date'] < reference_date) ] user_agg = window_data.groupby('user_id').agg({ 'revenue': ['sum', 'mean', 'std', 'count'], 'sessions': ['count', 'mean'], 'session_duration': ['mean', 'max'] }) user_agg.columns = [f'{col[0]}_{col[1]}_{window}d' for col in user_agg.columns] user_agg = user_agg.reset_index() if len(features_list) == 0: features_list.append(user_agg) else: features_list[0] = features_list[0].merge( user_agg, on='user_id', how='outer' ) features_df = features_list[0] # Признаки recency last_activity = df.groupby('user_id')['date'].max().reset_index() last_activity['recency_days'] = ( reference_date - last_activity['date'] ).dt.days features_df = features_df.merge( last_activity[['user_id', 'recency_days']], on='user_id', how='left' ) # Категориальные признаки categorical_features = df.groupby('user_id').agg({ 'device_type': lambda x: x.mode()[0] if len(x) > 0 else 'unknown', 'country': lambda x: x.mode()[0] if len(x) > 0 else 'unknown' }).reset_index() features_df = features_df.merge( categorical_features, on='user_id', how='left' ) # One-hot encoding для категориальных признаков features_df = pd.get_dummies( features_df, columns=['device_type', 'country'], drop_first=True ) # Заполнение пропусков features_df = features_df.fillna(0) return features_df def fit(self, training_data, target_data, reference_date): """ Обучение CUPAC модели """ from sklearn.ensemble import GradientBoostingRegressor from sklearn.preprocessing import StandardScaler from sklearn.model_selection import KFold # Создание признаков features = self.engineer_features(training_data, reference_date) # Объединение с целевой переменной model_data = features.merge(target_data, on='user_id', how='inner') X = model_data.drop(['user_id', 'target_revenue', 'target_sessions'], axis=1) y = model_data['target_revenue'] # Масштабирование self.scaler = StandardScaler() X_scaled = self.scaler.fit_transform(X) # Cross-fitted predictions kf = KFold(n_splits=5, shuffle=True, random_state=42) cv_predictions = np.zeros(len(y)) for train_idx, val_idx in kf.split(X_scaled): model_fold = GradientBoostingRegressor( n_estimators=150, max_depth=6, learning_rate=0.05, subsample=0.8, min_samples_leaf=50, random_state=42 ) model_fold.fit(X_scaled[train_idx], y.iloc[train_idx]) cv_predictions[val_idx] = model_fold.predict(X_scaled[val_idx]) # Финальная модель на всех данных self.model = GradientBoostingRegressor( n_estimators=150, max_depth=6, learning_rate=0.05, subsample=0.8, min_samples_leaf=50, random_state=42 ) self.model.fit(X_scaled, y) # Метрики качества self.r_squared = 1 - np.var(y - cv_predictions) / np.var(y) self.correlation = np.corrcoef(y, cv_predictions)[0, 1] # Сохранение имен признаков для предсказаний self.feature_names = X.columns.tolist() return cv_predictions def predict(self, experiment_data, experiment_start_date): """ Генерация предсказаний для периода эксперимента """ # Создание признаков на момент начала эксперимента prediction_date = experiment_start_date - timedelta(days=self.prediction_lag) features = self.engineer_features(experiment_data, prediction_date) # Выравнивание признаков с обучающими данными X = features.drop('user_id', axis=1) # Добавление отсутствующих колонок for col in self.feature_names: if col not in X.columns: X[col] = 0 # Удаление лишних колонок и сортировка X = X[self.feature_names] X_scaled = self.scaler.transform(X) predictions = self.model.predict(X_scaled) return features['user_id'], predictions Давайте рассмотрим, что делает этот код: Мы создаем класс CUPACPipeline для построения и применения пайплайна CUPAC; Метод __init__ задает параметры обучения и инициализирует пустые атрибуты модели и масштабирования; Метод prepare_training_data формирует тренировочные данные и целевую переменную по пользователям. Затем рассчитывает временные границы для тренировочного окна и периода предсказания на основе даты эксперимента, и фильтрует исходный датафрейм по датам, плюс агрегирует метрики пользователей для обучения модели; Метод engineer_features создает признаки: агрегаты за разные временные окна, давность пользователей (recency) и категориальные признаки с one-hot кодированием; Метод fit обучает модель на признаках и целевой переменной с масштабированием и cross-fitted предсказаниями. Затем рассчитывает метрики качества модели, такие как R² и корреляцию между предсказаниями и реальными значениями; Метод predict создает признаки для экспериментальных данных и генерирует предсказания по обученной модели. Модель машинного обучения GradientBoostingRegressor обучается на исторических признаках пользователей, чтобы предсказывать будущую выручку за период до эксперимента. Она используется как корректирующий инструмент CUPAC, уменьшая дисперсию метрики без внесения смещения в оценку эффекта воздействия. Параметры градиентного бустинга настроены консервативно: умеренная глубина деревьев (6), низкий learning rate (0.05), большой min_samples_leaf (50). Это снижает риск переобучения, что важнее максимизации R² на валидации. Применение корректировки в эксперименте После генерации предсказаний они используются для корректировки экспериментальных метрик. Процесс применяется после завершения эксперимента, когда уже собраны фактические значения метрик. # Генерация синтетических данных np.random.seed(42) # Параметры n_users = 5000 # 5000 пользователей date_range = pd.date_range('2025-01-01', '2025-11-30', freq='D') # Создание исторических данных пользователей user_data = [] for user_id in range(n_users): # Базовые характеристики пользователя base_revenue = np.random.lognormal(mean=2, sigma=1) device_type = np.random.choice(['mobile', 'desktop', 'tablet'], p=[0.6, 0.3, 0.1]) country = np.random.choice(['US', 'UK', 'DE', 'FR'], p=[0.4, 0.3, 0.2, 0.1]) # Генерация активности по дням (весь исторический период до эксперимента) for date in date_range[:120]: # До 1 мая (120 дней с 1 января) if np.random.random() < 0.3: # 30% дней пользователь активен sessions = np.random.poisson(2) revenue = base_revenue * np.random.gamma(2, 0.5) * sessions session_duration = np.random.exponential(300) * sessions user_data.append({ 'user_id': user_id, 'date': date, 'revenue': revenue, 'sessions': sessions, 'session_duration': session_duration, 'device_type': device_type, 'country': country }) df_historical = pd.DataFrame(user_data) # Данные эксперимента (1 мая - 31 октября, 184 дня) experiment_start = pd.Timestamp('2025-05-01') experiment_end = pd.Timestamp('2025-10-31') experiment_data = [] for user_id in range(n_users): # Рандомизация в тест/контроль treatment = np.random.binomial(1, 0.5) # Treatment effect: 8% увеличение revenue treatment_multiplier = 1.08 if treatment == 1 else 1.0 # Получение базовых характеристик пользователя user_history = df_historical[df_historical['user_id'] == user_id] if len(user_history) > 0: base_revenue = user_history['revenue'].mean() device_type = user_history['device_type'].iloc[0] country = user_history['country'].iloc[0] else: # Новый пользователь base_revenue = np.random.lognormal(mean=2, sigma=1) device_type = np.random.choice(['mobile', 'desktop', 'tablet']) country = np.random.choice(['US', 'UK', 'DE', 'FR']) # Генерация данных эксперимента (184 дня) experiment_days = pd.date_range(experiment_start, experiment_end, freq='D') for date in experiment_days: if np.random.random() < 0.35: sessions = np.random.poisson(2) revenue = base_revenue * treatment_multiplier * np.random.gamma(2, 0.5) * sessions session_duration = np.random.exponential(300) * sessions experiment_data.append({ 'user_id': user_id, 'date': date, 'revenue': revenue, 'sessions': sessions, 'session_duration': session_duration, 'device_type': device_type, 'country': country, 'treatment': treatment }) df_experiment = pd.DataFrame(experiment_data) # Агрегация метрик эксперимента по пользователям experiment_results = df_experiment.groupby('user_id').agg({ 'revenue': 'sum', 'sessions': 'sum', 'treatment': 'first' }).reset_index() print(f"Historical data: {len(df_historical)} записей, {df_historical['user_id'].nunique()} пользователей") print(f"Experiment data: {len(experiment_results)} пользователей") print(f"Control group: {(experiment_results['treatment']==0).sum()} пользователей") print(f"Treatment group: {(experiment_results['treatment']==1).sum()} пользователей") # Функция подготовки данных def prepare_training_data(df, experiment_start_date, training_days=60, prediction_window=7): """ Исправленная подготовка данных для обучения """ # Prediction period находится перед экспериментом prediction_end = experiment_start_date - timedelta(days=1) prediction_start = prediction_end - timedelta(days=prediction_window) # Training period находится перед prediction period training_end = prediction_start - timedelta(days=1) training_start = training_end - timedelta(days=training_days) print(f"Training period: {training_start.date()} to {training_end.date()}") print(f"Prediction period: {prediction_start.date()} to {prediction_end.date()}") print(f"Experiment starts: {experiment_start_date.date()}") # Данные для обучения (признаки) training_data = df[ (df['date'] >= training_start) & (df['date'] < training_end) ].copy() # Данные для целевой переменной (что мы хотим предсказать) target_data = df[ (df['date'] >= prediction_start) & (df['date'] <= prediction_end) ].copy() # Агрегация целевой переменной по пользователям target_agg = target_data.groupby('user_id').agg({ 'revenue': 'sum', 'sessions': 'count' }).reset_index() target_agg.columns = ['user_id', 'target_revenue', 'target_sessions'] # Для признаков используем дату конца training периода reference_date = training_end return training_data, target_agg, reference_date # Обучение CUPAC модели pipeline = CUPACPipeline(training_days=60, prediction_lag=7) # Используем функцию подготовки данных training_data, target_data, reference_date = prepare_training_data( df_historical, experiment_start, training_days=60, prediction_window=7 ) print(f"\nTraining period records: {len(training_data)}") print(f"Target period users: {len(target_data)}") print(f"Users with both training and target data: {len(target_data[target_data['user_id'].isin(training_data['user_id'].unique())])}") if len(target_data) == 0: raise ValueError("No target data available. Check date ranges.") # Создание признаков и обучение features = pipeline.engineer_features(training_data, reference_date) model_data = features.merge(target_data, on='user_id', how='inner') print(f"Model training data: {len(model_data)} пользователей") if len(model_data) < 100: raise ValueError(f"Insufficient training data: {len(model_data)} users") # Обучение модели from sklearn.ensemble import GradientBoostingRegressor from sklearn.preprocessing import StandardScaler from sklearn.model_selection import KFold X = model_data.drop(['user_id', 'target_revenue', 'target_sessions'], axis=1) y = model_data['target_revenue'] pipeline.scaler = StandardScaler() X_scaled = pipeline.scaler.fit_transform(X) # Cross-fitted predictions kf = KFold(n_splits=5, shuffle=True, random_state=42) cv_predictions = np.zeros(len(y)) for train_idx, val_idx in kf.split(X_scaled): model_fold = GradientBoostingRegressor( n_estimators=150, max_depth=6, learning_rate=0.05, subsample=0.8, min_samples_leaf=50, random_state=42 ) model_fold.fit(X_scaled[train_idx], y.iloc[train_idx]) cv_predictions[val_idx] = model_fold.predict(X_scaled[val_idx]) # Финальная модель pipeline.model = GradientBoostingRegressor( n_estimators=150, max_depth=6, learning_rate=0.05, subsample=0.8, min_samples_leaf=50, random_state=42 ) pipeline.model.fit(X_scaled, y) pipeline.r_squared = 1 - np.var(y - cv_predictions) / np.var(y) pipeline.correlation = np.corrcoef(y, cv_predictions)[0, 1] pipeline.feature_names = X.columns.tolist() print(f"\nModel R²: {pipeline.r_squared:.3f}") print(f"Model correlation: {pipeline.correlation:.3f}") # Генерация предсказаний для периода эксперимента # Используем данные до начала эксперимента для признаков prediction_reference_date = experiment_start - timedelta(days=1) features_experiment = pipeline.engineer_features(df_historical, prediction_reference_date) X_experiment = features_experiment.drop('user_id', axis=1) # Выравнивание признаков for col in pipeline.feature_names: if col not in X_experiment.columns: X_experiment[col] = 0 X_experiment = X_experiment[pipeline.feature_names] X_experiment_scaled = pipeline.scaler.transform(X_experiment) predictions = pipeline.model.predict(X_experiment_scaled) predictions_df = pd.DataFrame({ 'user_id': features_experiment['user_id'], 'prediction': predictions }) print(f"\nGenerated predictions for {len(predictions_df)} пользователей") # Анализ эксперимента с CUPAC analysis = CUPACAnalysis(predictions_df, experiment_results) # Применение корректировки var_reduction = analysis.compute_correction('revenue') print(f"\nVariance Reduction: {var_reduction:.2%}") print(f"Theta: {analysis.theta:.4f}") # Сравнение результатов results = analysis.run_ab_test('revenue') print("\n" + "="*60) print("РЕЗУЛЬТАТЫ A/B ТЕСТА") print("="*60) print("\nOriginal Metric:") print(f" Control mean: ${results['original']['control_mean']:.2f}") print(f" Treatment mean: ${results['original']['treatment_mean']:.2f}") print(f" Lift: {results['original']['lift']:.2%}") print(f" P-value: {results['original']['p_value']:.4f}") print(f" T-statistic: {results['original']['t_statistic']:.2f}") print(f" 95% CI: [${results['original']['ci_lower']:.2f}, ${results['original']['ci_upper']:.2f}]") print("\nCUPAC Metric:") print(f" Control mean: ${results['cupac']['control_mean']:.2f}") print(f" Treatment mean: ${results['cupac']['treatment_mean']:.2f}") print(f" Lift: {results['cupac']['lift']:.2%}") print(f" P-value: {results['cupac']['p_value']:.4f}") print(f" T-statistic: {results['cupac']['t_statistic']:.2f}") print(f" 95% CI: [${results['cupac']['ci_lower']:.2f}, ${results['cupac']['ci_upper']:.2f}]") print("\n" + "="*60) print("IMPROVEMENT METRICS") print("="*60) print(f"T-statistic improved by: {results['improvement']['t_stat_ratio']:.2f}x") print(f"CI width reduced by: {results['improvement']['ci_width_reduction']:.2%}") # Интерпретация результатов if results['original']['p_value'] < 0.05 and results['cupac']['p_value'] < 0.05: print("\n✓ Обе метрики показывают статистически значимый эффект") elif results['original']['p_value'] >= 0.05 and results['cupac']['p_value'] < 0.05: print("\n✓ CUPAC обнаружил значимый эффект, пропущенный оригинальной метрикой") elif results['original']['p_value'] < 0.05 and results['cupac']['p_value'] >= 0.05: print("\n⚠ Несогласованность результатов - требуется дополнительная проверка") else: print("\n○ Обе метрики не обнаружили статистически значимого эффекта") Historical data: 179863 записей, 5000 пользователей Experiment data: 5000 пользователей Control group: 2573 пользователей Treatment group: 2427 пользователей Training period: 2025-02-21 to 2025-04-22 Prediction period: 2025-04-23 to 2025-04-30 Experiment starts: 2025-05-01 Training period records: 89866 Target period users: 4719 Users with both training and target data: 4719 Model training data: 4719 пользователей Model R²: 0.315 Model correlation: 0.562 Generated predictions for 5000 пользователей Variance Reduction: 71.60% Theta: 41.7089 ============================================================ РЕЗУЛЬТАТЫ A/B ТЕСТА ============================================================ Original Metric: Control mean: $3164.59 Treatment mean: $3430.84 Lift: 8.41% P-value: 0.0410 T-statistic: 2.04 95% CI: [$10.96, $521.54] CUPAC Metric: Control mean: $3192.58 Treatment mean: $3401.17 Lift: 6.59% P-value: 0.0026 T-statistic: 3.01 95% CI: [$72.88, $344.30] ============================================================ IMPROVEMENT METRICS ============================================================ T-statistic improved by: 1.47x CI width reduced by: 46.84% ✓ Обе метрики показывают статистически значимый эффект Класс CUPACAnalysis выполняет статистический анализ с корректировкой: вычисляется оптимальный коэффициент θ, применяется корректировка, а затем проводится t-test для исходной и скорректированной версии метрики. Результаты включают не только p-values, но и относительное улучшение (improvement) в t-статистике и ширине доверительных интервалов. Sensitivity analysis проверяет устойчивость результатов к выбору θ. Если p-value сильно меняется при небольших вариациях θ, это сигнализирует о возможных проблемах с данными или моделью. Результаты CUPAC показывают, что корректировка существенно повысила точность оценки эффекта эксперимента. Исходный lift составил 8,41% с p-value 0,041, тогда как после корректировки lift стал 6,59%, но p-value снизилось до 0,0026, а t-статистика выросла с 2,04 до 3,01. Это сопровождается уменьшением ширины доверительного интервала почти вдвое (−46,84%), при этом дисперсия метрики снизилась на 71,6%. Вывод: модель хорошо предсказывает поведение пользователей (R²=0,315, корреляция=0,562). В целом CUPAC делает выводы более стабильными и статистически убедительными. Оценка эффективности снижения дисперсии (variance reduction) Количественная оценка эффективности CUPAC включает несколько метрик. Прямое измерение — процент снижения дисперсии. Косвенные метрики — изменение статистической мощности и требуемого размера выборки. import matplotlib.pyplot as plt import seaborn as sns def evaluate_cupac_effectiveness(original_metric, cupac_metric, treatment_indicator): """ Комплексная оценка эффективности CUPAC """ # Variance reduction var_original = np.var(original_metric) var_cupac = np.var(cupac_metric) var_reduction = (var_original - var_cupac) / var_original # Эффект на статистическую мощность control_mask = treatment_indicator == 0 treatment_mask = treatment_indicator == 1 # Стандартные ошибки se_original = np.sqrt( var_original / treatment_mask.sum() + var_original / control_mask.sum() ) se_cupac = np.sqrt( var_cupac / treatment_mask.sum() + var_cupac / control_mask.sum() ) se_reduction = (se_original - se_cupac) / se_original # Minimum detectable effect (MDE) alpha = 0.05 beta = 0.20 # 80% power z_alpha = stats.norm.ppf(1 - alpha/2) z_beta = stats.norm.ppf(1 - beta) mde_original = (z_alpha + z_beta) * se_original mde_cupac = (z_alpha + z_beta) * se_cupac mde_improvement = (mde_original - mde_cupac) / mde_original # Sample size reduction # n_new / n_old = (sigma_new / sigma_old)^2 sample_size_reduction = 1 - (np.sqrt(var_cupac) / np.sqrt(var_original))**2 results = { 'variance_reduction': var_reduction, 'se_reduction': se_reduction, 'mde_improvement': mde_improvement, 'sample_size_reduction': sample_size_reduction } return results def plot_distribution_comparison(original_metric, cupac_metric, treatment_indicator): """ Визуализация распределений оригинальной и CUPAC метрики """ fig, axes = plt.subplots(2, 2, figsize=(14, 10)) control_mask = treatment_indicator == 0 treatment_mask = treatment_indicator == 1 # Распределения для контрольной группы axes[0, 0].hist(original_metric[control_mask], bins=50, alpha=0.7, color='blue', label='Control', density=True) axes[0, 0].hist(original_metric[treatment_mask], bins=50, alpha=0.7, color='red', label='Treatment', density=True) axes[0, 0].set_title('Original Metric Distribution') axes[0, 0].legend() axes[0, 0].set_xlabel('Metric Value') axes[0, 0].set_ylabel('Density') axes[0, 1].hist(cupac_metric[control_mask], bins=50, alpha=0.7, color='blue', label='Control', density=True) axes[0, 1].hist(cupac_metric[treatment_mask], bins=50, alpha=0.7, color='red', label='Treatment', density=True) axes[0, 1].set_title('CUPAC Metric Distribution') axes[0, 1].legend() axes[0, 1].set_xlabel('Metric Value') axes[0, 1].set_ylabel('Density') # Q-Q plots для проверки нормальности stats.probplot(original_metric, dist="norm", plot=axes[1, 0]) axes[1, 0].set_title('Q-Q Plot: Original Metric') stats.probplot(cupac_metric, dist="norm", plot=axes[1, 1]) axes[1, 1].set_title('Q-Q Plot: CUPAC Metric') plt.tight_layout() return fig def bootstrap_variance_reduction(original_metric, cupac_metric, n_bootstrap=1000): """ Bootstrap доверительный интервал для variance reduction """ var_reductions = [] for _ in range(n_bootstrap): idx = np.random.choice(len(original_metric), size=len(original_metric), replace=True) var_orig_boot = np.var(original_metric[idx]) var_cupac_boot = np.var(cupac_metric[idx]) vr = (var_orig_boot - var_cupac_boot) / var_orig_boot var_reductions.append(vr) var_reductions = np.array(var_reductions) ci_lower = np.percentile(var_reductions, 2.5) ci_upper = np.percentile(var_reductions, 97.5) return var_reductions.mean(), ci_lower, ci_upper # Визуализация эффективности CUPAC print("\n" + "="*60) print("ДОПОЛНИТЕЛЬНЫЙ АНАЛИЗ") print("="*60) # Оценка эффективности effectiveness = evaluate_cupac_effectiveness( analysis.data['revenue'].values, analysis.data['revenue_cupac'].values, analysis.data['treatment'].values ) print(f"\nVariance Reduction: {effectiveness['variance_reduction']:.2%}") print(f"Standard Error Reduction: {effectiveness['se_reduction']:.2%}") print(f"MDE Improvement: {effectiveness['mde_improvement']:.2%}") print(f"Sample Size Reduction: {effectiveness['sample_size_reduction']:.2%}") # Визуализация распределений fig1 = plot_distribution_comparison( analysis.data['revenue'].values, analysis.data['revenue_cupac'].values, analysis.data['treatment'].values ) plt.show() # Bootstrap анализ variance reduction vr_mean, vr_lower, vr_upper = bootstrap_variance_reduction( analysis.data['revenue'].values, analysis.data['revenue_cupac'].values, n_bootstrap=1000 ) print(f"\nBootstrap Variance Reduction: {vr_mean:.2%}") print(f"95% CI: [{vr_lower:.2%}, {vr_upper:.2%}]") # Визуализация bootstrap результатов fig2, ax = plt.subplots(figsize=(10, 6)) var_reductions_boot = [] for _ in range(1000): idx = np.random.choice(len(analysis.data), size=len(analysis.data), replace=True) var_orig = np.var(analysis.data['revenue'].values[idx]) var_cupac = np.var(analysis.data['revenue_cupac'].values[idx]) var_reductions_boot.append((var_orig - var_cupac) / var_orig) ax.hist(var_reductions_boot, bins=50, color='steelblue', alpha=0.7, edgecolor='black') ax.axvline(vr_mean, color='red', linestyle='--', linewidth=2, label=f'Mean: {vr_mean:.2%}') ax.axvline(vr_lower, color='orange', linestyle='--', linewidth=1.5, label=f'2.5%: {vr_lower:.2%}') ax.axvline(vr_upper, color='orange', linestyle='--', linewidth=1.5, label=f'97.5%: {vr_upper:.2%}') ax.set_xlabel('Variance Reduction') ax.set_ylabel('Frequency') ax.set_title('Bootstrap Distribution of Variance Reduction') ax.legend() ax.grid(True, alpha=0.3) plt.tight_layout() plt.show() ============================================================ ДОПОЛНИТЕЛЬНЫЙ АНАЛИЗ ============================================================ Variance Reduction: 71.60% Standard Error Reduction: 46.71% MDE Improvement: 46.71% Sample Size Reduction: 71.60% Bootstrap Variance Reduction: 71.54% 95% CI: [67.52%, 75.70%] Представленный выше код выполняет дополнительный анализ эффективности CUPAC: Вычисляет метрики улучшения — уменьшение дисперсии (variance reduction), уменьшение стандартной ошибки (SE reduction), улучшение минимально обнаруживаемого эффекта (MDE improvement) и потенциальное сокращение размера выборки; Строит графики распределений исходной и скорректированной метрики для контрольной и тестовой групп, а также Q-Q графики для проверки нормальности; Проводит bootstrap-анализ дисперсии, оценивая доверительный интервал для variance reduction. Рис. 1: Сравнение распределений оригинальной и CUPAC метрики. Верхние панели демонстрируют гистограммы распределения выручки для контрольной (синий) и тестовой (красный) групп. Левая панель показывает оригинальную метрику с высокой дисперсией и значительным перекрытием распределений между группами. Правая панель отображает CUPAC-скорректированную метрику со снижением дисперсии на 71.6% при сохранении разделения между группами. Нижние панели содержат Q-Q plots для проверки нормальности распределений. Отклонения от диагональной линии на хвостах указывают на наличие тяжелых хвостов, характерных для метрик выручки, что не влияет на валидность t-теста при больших выборках Рис. 2: Бутстрап-распределение снижения дисперсии. Гистограмма демонстрирует распределение оценок снижения дисперсии, полученных методом bootstrap с 1000 итераций. Красная пунктирная линия обозначает среднее значение variance reduction (71.54%), оранжевые линии показывают границы 95% доверительного интервала [67.52%, 75.70%]. Узкий доверительный интервал (ширина 8.18%) свидетельствует о высокой стабильности оценки и робастности CUPAC корректировки. Симметричная форма распределения подтверждает отсутствие систематических смещений в методе Визуализация распределений помогает выявлять потенциальные проблемы: если метрика CUPAC имеет тяжелые хвосты, либо мультимодальное распределение, это может указывать на ошибки модели или наличие подгрупп пользователей с разными характеристиками. Бутстрап-доверительные интервалы для variance reduction оценивают стабильность результатов: широкий интервал сигнализирует о высокой вариативности и необходимости увеличить объем тренировочных данных, либо улучшить модель. Практические аспекты применения Внедрение CUPAC в продакшен среду требует решения нескольких практических задач: выбор архитектуры модели, обработка нестационарности, интеграция в существующий пайплайн проведения экспериментов. Выбор архитектуры предиктивной модели Модели градиентного бустинга (XGBoost, LightGBM, CatBoost) на сегодняшний день являются наиболее популярными в работе с методом CUPAC благодаря хорошему сочетанию точности, скорости обучения и интерпретируемости. Линейные модели (Ridge, Lasso) подходят для высокоразмерных данных с преимущественно линейными зависимостями: они эффективны вычислительно и устойчивы к переподгонке при правильной регуляризации, однако не улавливают нелинейные паттерны и взаимодействия признаков. Нейронные сети оправданы для очень больших датасетов с сложными нелинейными зависимостями; многослойные архитектуры с dropout и batch normalization дают качество, сопоставимое с градиентным бустингом, но требуют больше времени на обучение и настройку гиперпараметров. Обработка нестационарности данных Поведенческие паттерны пользователей меняются со временем из-за сезонности, маркетинговых кампаний, изменений продукта и внешних факторов, из-за чего модель, обученная на старых данных, теряет точность и снижает эффективность CUPAC. Стратегии борьбы с нестационарностью: Регулярное переобучение: частота зависит от скорости изменений паттернов — для стабильных продуктов достаточно раз в месяц, для быстро меняющихся — еженедельно. Сигнал к переобучению — падение корреляции на holdout ниже порога; Скользящее окно обучения: используется только недавний период (например, последние 60 дней), что адаптирует модель к актуальным паттернам, но сокращает объем данных; Temporal features: добавление времени (день недели, месяц, праздники, время с последнего релиза) позволяет учитывать систематические временные паттерны. Интеграция в пайплайны A/B тестирования Внедрение CUPAC в продакшен требует интеграции с существующей экспериментальной инфраструктурой, включая хранилище моделей, генерацию предсказаний и статистический анализ. Архитектура пайплайна: Offline — периодическое обучение и валидация моделей на исторических данных с сохранением в model registry; Near-realtime — генерация и кеширование предсказаний для пользователей; Post-experiment — применение CUPAC к завершенным экспериментам и сравнение с оригинальными метриками. Ключевые требования: Версионирование моделей с метаданными (дата, R², признаки, гиперпараметры) для воспроизводимости и отката; Кеширование предсказаний для экономии ресурсов при длительных экспериментах; Мониторинг качества через корреляцию предсказаний и фактических значений: падение ниже порога инициирует переобучение или алерт; A/B тестирование самого CUPAC на подмножестве экспериментов для проверки консистентности и стабильности результатов. Таким образом, предсказания лучше генерировать заранее, затем сохранять в кеш, а после завершения запускать CUPAC анализ. Для анализа результатов можно использовать дашборды с преднастроенными алертами. Оптимально реализовать CUPAC как микросервис с API для постепенной интеграции без переписывания существующего кода. Заключение Метод CUPAC превращает машинное обучение из вспомогательного инструмента маркетинга и продуктовой аналитики в важный элемент экспериментальной инфраструктуры: Снижение дисперсии на 40-70% — это не просто улучшение метрики, а качественный сдвиг в способности компаний принимать решения; Эксперименты, которые раньше требовали месяцев накопления данных, завершаются за недели; Изменения, которые терялись в шуме, становятся видимыми и измеримыми. Метод работает там, где классический CUPED упирается в потолок: новые пользователи без истории, нестационарные паттерны поведения, необходимость использовать десятки признаков одновременно. Предиктивная модель агрегирует всю доступную информацию о пользователе в единую оценку будущего поведения, которая служит идеальной ковариатой для корректировки. При этом математические гарантии несмещенности сохраняются независимо от качества модели — плохие предсказания не навредят, просто не помогут. Практическое внедрение CUPAC требует инвестиций в инфраструктуру: дополнительный компьют, регулярное обучение моделей, мониторинг деградации, интеграция в платформу для проведения экспериментов. Тем не менее, для компаний, проводящих десятки A/B тестов одновременно, эти затраты окупятся многократно через ускорение итераций и способность обнаруживать тонкие, но крайне важные эффекты в данных пользователей. ### Тест Чоу (Chow Test) для определения структурных сдвигов временных рядов Финансовые временные ряды почти всегда содержат структурные сдвиги — моменты, когда рынок меняет поведение. В такие моменты стандартные модели перестают быть устойчивыми: коэффициенты регрессии перестают сохранять свои значения на разных участках ряда, изменяются направления и силы связей между переменными, а сами уравнения, хорошо описывающие данные до разрыва, уже не отражают поведение ряда после него. В результате модель начинает систематически ошибаться и теряет прогностическую ценность. Тест Чоу (Chow Test) позволяет статистически проверить гипотезу о стабильности параметров регрессионной модели между двумя временными периодами. Он позволяет формально проверить, произошел ли такой разрыв в определенной точке времени. По сути, он отвечает на вопрос: "Изменилась ли структура модели в определенный момент?" Метод разработан экономистом Грегори Чоу и является популярным инструментом эконометрического анализа. В алгоритмическом трейдинге тест применяется для валидации торговых стратегий, детекции изменений в корреляционной структуре активов, оценки устойчивости факторных моделей. Математическая основа теста Чоу Тест базируется на сравнении суммы квадратов остатков (RSS) для 3-х регрессионных моделей: объединенной модели на всей выборке и двух отдельных моделей для каждого подпериода. Принцип работы теста Рассмотрим линейную регрессию y = Xβ + ε, где временной ряд разделен в точке t на два подпериода. Тест проверяет нулевую гипотезу H₀: β₁ = β₂, где β₁ и β₂ — векторы коэффициентов для первого и второго периодов соответственно. F-статистика теста Чоу вычисляется по формуле: F = [(RSS_pooled - RSS₁ - RSS₂) / k] / [(RSS₁ + RSS₂) / (n₁ + n₂ - 2k)] Компоненты формулы: RSS_pooled — сумма квадратов остатков объединенной модели; RSS₁, RSS₂ — суммы квадратов остатков для первого и второго периодов; k — количество параметров модели (включая константу); n₁, n₂ — размеры подвыборок. Числитель отражает улучшение качества подгонки при использовании отдельных моделей вместо объединенной. Знаменатель нормирует это улучшение на дисперсию остатков в раздельных моделях. Высокое значение F-статистики указывает на существенное различие параметров между периодами. Статистическая интерпретация F-статистика при справедливости нулевой гипотезы распределена по закону Фишера с параметрами (k, n₁ + n₂ − 2k) степеней свободы. Критическое значение выбирается в соответствии с заданным уровнем значимости α (обычно 0,05 или 0,01). Если вычисленное значение F-статистики превышает критическое, нулевая гипотеза отвергается, что указывает на наличие структурного сдвига. P-value показывает вероятность получить наблюдаемое или более экстремальное значение статистики при справедливости H₀. Значение p-value < 0.05 интерпретируется как статистически значимое различие параметров моделей. Мощность теста зависит от размера выборки, величины структурного сдвига и дисперсии остатков. При малых выборках (n < 30 в каждом подпериоде) тест обладает низкой мощностью и может не обнаружить реальные сдвиги. Увеличение дисперсии остатков снижает чувствительность теста, поэтому гетероскедастичность во временных рядах требует коррекции стандартных ошибок. Типы теста Чоу Существуют три модификации теста Чоу, различающиеся целями и структурой данных. Break Point Test Break Point Test проверяет наличие структурного сдвига в заранее известной точке временного ряда. Точка разрыва определяется внешними событиями: изменение регуляции, макроэкономический шок, корпоративное действие. Тест отвечает на вопрос: изменились ли параметры модели после события? Требования к данным: Точка структурного разрыва должна быть определена до проведения теста, чтобы избежать смещения, связанного с подбором данных (data snooping bias).; Минимальный размер каждого подпериода: n ≥ k + 1, где k — количество параметров; Рекомендуемый размер для надежных результатов: n ≥ 30 наблюдений в каждом подпериоде; Остатки (residuals) должны удовлетворять предпосылкам МНК: гомоскедастичность, отсутствие автокорреляции. В алгоритмическом трейдинге Break Point Test применяется для оценки влияния изменений в правилах торговли на бирже, запуска новых торговых площадок, изменения структуры индексов. Predictive Test Predictive Test оценивает прогнозную способность модели, построенной на обучающей выборке, при применении к тестовой выборке. Модель оценивается на первых n₁ наблюдениях, затем тест проверяет, остаются ли параметры стабильными на следующих n₂ наблюдениях. F-статистика Predictive Test вычисляется по формуле: F = [(RSS_pred - RSS₁) / n₂] / [RSS₁ / (n₁ - k)] Где RSS_pred — сумма квадратов остатков при прогнозировании второго периода моделью из первого периода. Этот вариант теста используется для walk-forward анализа торговых стратегий. Модель обучается на обучающей выборке, затем Predictive Test определяет, сохраняется ли ее предсказательная сила на тестовом периоде. Отклонение нулевой гипотезы сигнализирует об переподгонке или изменении рыночного режима. Sample Split Test Sample Split Test сравнивает параметры моделей для 2-х независимых выборок, которые не обязательно последовательны во времени. Применяется для сравнения регрессионных зависимостей между разными активами, рынками или временными периодами с аналогичными характеристиками. Пример использования: сравнение факторной модели доходности акций технологического сектора с факторной моделью финансового сектора. Тест показывает, различаются ли чувствительности секторов к общим факторам риска. Примеры на Python Базовая реализация теста Чоу использует библиотеку statsmodels для построения регрессионных моделей и scipy для расчета критических значений F-распределения. import numpy as np import pandas as pd import yfinance as yf from scipy import stats import statsmodels.api as sm import matplotlib.pyplot as plt def chow_test(y, X, breakpoint): """ Выполняет тест Чоу для проверки структурного сдвига. Parameters: y : array-like, зависимая переменная X : array-like, независимые переменные (без константы) breakpoint : int, индекс точки разрыва Returns: dict с F-статистикой, p-value и результатами моделей """ n = len(y) k = X.shape[1] + 1 # +1 для константы # Добавляем константу к регрессорам X_const = sm.add_constant(X) # Объединенная модель на всей выборке model_pooled = sm.OLS(y, X_const).fit() rss_pooled = np.sum(model_pooled.resid ** 2) # Модель для первого периода y1, X1 = y[:breakpoint], X_const[:breakpoint] model1 = sm.OLS(y1, X1).fit() rss1 = np.sum(model1.resid ** 2) n1 = len(y1) # Модель для второго периода y2, X2 = y[breakpoint:], X_const[breakpoint:] model2 = sm.OLS(y2, X2).fit() rss2 = np.sum(model2.resid ** 2) n2 = len(y2) # Расчет F-статистики numerator = (rss_pooled - (rss1 + rss2)) / k denominator = (rss1 + rss2) / (n - 2 * k) f_stat = numerator / denominator # Степени свободы и p-value df1 = k df2 = n - 2 * k p_value = 1 - stats.f.cdf(f_stat, df1, df2) # Критическое значение для α = 0.05 f_critical = stats.f.ppf(0.95, df1, df2) return { 'f_statistic': f_stat, 'p_value': p_value, 'f_critical': f_critical, 'reject_h0': f_stat > f_critical, 'rss_pooled': rss_pooled, 'rss_separate': rss1 + rss2, 'model_pooled': model_pooled, 'model1': model1, 'model2': model2, 'breakpoint': breakpoint } # Загрузка данных ticker = yf.Ticker("TSM") data = ticker.history(start="2020-01-01", end="2024-01-01", interval="1d") # Проверка на MultiIndex if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) # Убираем таймзону из индекса data.index = data.index.tz_localize(None) # Подготовка данных: зависимость цены от времени y = data['Close'].values X = np.arange(len(y)).reshape(-1, 1) # Точка разрыва: начало пандемии COVID-19 (март 2020) pandemic_date = pd.Timestamp('2020-03-01') breakpoint_idx = data.index.get_indexer([pandemic_date], method='nearest')[0] # Выполнение теста results = chow_test(y, X, breakpoint_idx) print(f"F-статистика: {results['f_statistic']:.4f}") print(f"P-value: {results['p_value']:.6f}") print(f"Критическое значение F (α=0.05): {results['f_critical']:.4f}") print(f"Отклонить H0: {results['reject_h0']}") print(f"\nRSS объединенной модели: {results['rss_pooled']:.2f}") print(f"RSS раздельных моделей: {results['rss_separate']:.2f}") print(f"Улучшение RSS: {results['rss_pooled'] - results['rss_separate']:.2f}") F-статистика: 42.4644 P-value: 0.000000 Критическое значение F (α=0.05): 3.0047 Отклонить H0: True RSS объединенной модели: 401015.13 RSS раздельных моделей: 369681.23 Улучшение RSS: 31333.91 Представленный выше код реализует классический Break Point Test для временного ряда цен закрытия акций Taiwan Semiconductor Manufacturing (TSM). Функция chow_test принимает зависимую переменную, регрессоры и индекс точки разрыва, возвращает F-статистику, p-value и результаты трех регрессионных моделей. Интерпретация результатов: F-статистика = 42.46 значительно превышает критическое значение 3.00 → нулевая гипотеза H₀ отклоняется; P-value ≈ 0 подтверждает, что вероятность случайного появления такого эффекта крайне мала; Структурный разрыв присутствует: поведение временного ряда изменилось около марта 2020 года; RSS объединенной модели = 401015, RSS раздельных моделей = 369681 → раздельные модели объясняют данные лучше; Улучшение RSS = 31333 показывает, насколько точность прогноза повышается при учете разрыва. Таким образом, тест Чоу говорит нам, что для прогнозов и анализа следует учитывать два отдельных периода, а не использовать объединенную модель. Логика работы: Сначала оценивается объединенная регрессия на всех данных; Затем оцениваются две отдельные регрессии до и после точки разрыва; Разница между RSS объединенной модели и суммой RSS раздельных моделей показывает, насколько лучше данные описываются двумя разными наборами параметров. F-статистика нормирует эту разницу и сравнивается с критическим значением F-распределения. В примере точка разрыва соответствует марту 2020 года — началу пандемии COVID-19. Высокое значение F-статистики и низкий p-value указывают на структурный сдвиг: тренд цен TSM изменился после пандемии из-за бума спроса на гаджеты и облачные сервисы, а следовательно на полупроводники. Анализ биржевых портфелей Структурные сдвиги в финансовых временных рядах отражают изменения режима волатильности, корреляционной структуры или трендовой компоненты. Тест Чоу позволяет выявлять такие сдвиги, что важно для корректировки и адаптации торговых стратегий. Рассмотрим пример: проверка стабильности бета-коэффициента акции относительно рыночного индекса. Бета измеряет систематический риск актива и широко используется при построении хеджированных портфелей. Если бета изменяется после структурного события, это сигнализирует о необходимости перебалансировки позиций для поддержания целевого уровня риска. import numpy as np import pandas as pd import yfinance as yf # Загрузка данных для акции и индекса stock_ticker = yf.Ticker("AMD") index_ticker = yf.Ticker("^GSPC") # S&P 500 stock_data = stock_ticker.history(start="2019-01-01", end="2024-01-01", interval="1d") index_data = index_ticker.history(start="2019-01-01", end="2024-01-01", interval="1d") # Проверка на MultiIndex if isinstance(stock_data.columns, pd.MultiIndex): stock_data.columns = stock_data.columns.droplevel(1) if isinstance(index_data.columns, pd.MultiIndex): index_data.columns = index_data.columns.droplevel(1) # Убираем таймзону из индекса stock_data.index = stock_data.index.tz_localize(None) index_data.index = index_data.index.tz_localize(None) # Расчет доходностей stock_returns = stock_data['Close'].pct_change().dropna() index_returns = index_data['Close'].pct_change().dropna() # Объединение данных returns_df = pd.DataFrame({ 'stock': stock_returns, 'market': index_returns }).dropna() # Точка разрыва: начало повышения процентных ставок ФРС (март 2022) rate_hike_date = pd.Timestamp('2022-03-16') breakpoint_idx = returns_df.index.get_indexer([rate_hike_date], method='nearest')[0] # Подготовка данных для регрессии y = returns_df['stock'].values X = returns_df['market'].values.reshape(-1, 1) # Тест Чоу для бета-коэффициента beta_test = chow_test(y, X, breakpoint_idx) # Вывод результатов print("Тест Чоу для бета-коэффициента AMD") print(f"Период 1: {returns_df.index[0].strftime('%Y-%m-%d')} — {returns_df.index[breakpoint_idx-1].strftime('%Y-%m-%d')}") print(f"Период 2: {returns_df.index[breakpoint_idx].strftime('%Y-%m-%d')} — {returns_df.index[-1].strftime('%Y-%m-%d')}") print(f"\nБета период 1: {beta_test['model1'].params[1]:.4f}") print(f"Бета период 2: {beta_test['model2'].params[1]:.4f}") print(f"Изменение беты: {beta_test['model2'].params[1] - beta_test['model1'].params[1]:.4f}") print(f"\nF-статистика: {beta_test['f_statistic']:.4f}") print(f"P-value: {beta_test['p_value']:.6f}") print(f"Структурный сдвиг: {'Да' if beta_test['reject_h0'] else 'Нет'}") Тест Чоу для бета-коэффициента AMD Период 1: 2019-01-03 — 2022-03-15 Период 2: 2022-03-16 — 2023-12-29 Бета период 1: 1.4364 Бета период 2: 1.9716 Изменение беты: 0.5353 F-статистика: 10.0282 P-value: 0.000048 Структурный сдвиг: Да Код проверяет изменение беты AMD относительно S&P 500 после начала цикла повышения ставок ФРС в марте 2022. Доходности акции регрессируются на доходности индекса, тест Чоу сравнивает коэффициенты регрессии до и после события. Результаты показывают значение беты в каждом периоде и статистическую значимость различия. Если тест отклоняет нулевую гипотезу, систематический риск акции изменился, что требует пересмотра весов в портфеле. Повышение беты означает увеличение чувствительности к рыночным движениям — позиция становится более рискованной. Снижение беты указывает на защитные свойства актива в новом режиме. Множественные точки разрыва На длительных отрезках истории временные ряды часто содержат несколько структурных сдвигов. Однократное применение теста Чоу с одной фиксированной точкой разрыва может не выявить все изменения параметров. Итеративный подход позволяет обнаруживать несколько последовательных разрывов и более полно учитывать динамику ряда. Алгоритм последовательного поиска: Разделить временной ряд на кандидаты точек разрыва с шагом (например, каждые 5% наблюдений); Для каждой точки выполнить тест Чоу и сохранить F-статистику; Выбрать точку с максимальной F-статистикой, если она превышает критическое значение; Разделить ряд на два подряда по найденной точке; Рекурсивно повторить процедуру для каждого подряда; Остановиться, когда ни одна точка не дает значимого теста или подряд слишком короткий. import numpy as np import pandas as pd import yfinance as yf import matplotlib.pyplot as plt # Предполагается, что chow_test определен и работает # Функции поиска брейкпоинтов def find_multiple_breakpoints(y, X, min_segment_size=60, alpha=0.05): """ Итеративно находит множественные точки структурных сдвигов. """ def find_single_breakpoint(y_seg, X_seg, start_idx): n = len(y_seg) if n < 2 * min_segment_size: return None best_f = 0 best_bp = None step = max(1, n // 20) for bp in range(min_segment_size, n - min_segment_size, step): result = chow_test(y_seg, X_seg, bp) if result['f_statistic'] > best_f and result['p_value'] < alpha: best_f = result['f_statistic'] best_bp = bp return (start_idx + best_bp, best_f) if best_bp is not None else None def recursive_search(y_seg, X_seg, start_idx, breakpoints): result = find_single_breakpoint(y_seg, X_seg, start_idx) if result is None: return bp_idx, f_stat = result breakpoints.append({'index': bp_idx, 'f_statistic': f_stat}) bp_relative = bp_idx - start_idx recursive_search(y_seg[:bp_relative], X_seg[:bp_relative], start_idx, breakpoints) recursive_search(y_seg[bp_relative:], X_seg[bp_relative:], bp_idx, breakpoints) breakpoints = [] recursive_search(y, X, 0, breakpoints) breakpoints.sort(key=lambda x: x['index']) return breakpoints # Применение к данным TSM ticker = yf.Ticker("TSM") data = ticker.history(start="2019-01-01", end="2024-01-01", interval="1d") if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) data.index = data.index.tz_localize(None) y = data['Close'].values X = np.arange(len(y)).reshape(-1, 1) breakpoints = find_multiple_breakpoints(y, X, min_segment_size=90) print(f"Найдено {len(breakpoints)} структурных сдвигов:\n") for i, bp in enumerate(breakpoints, 1): date = data.index[bp['index']] print(f"{i}. Дата: {date.strftime('%Y-%m-%d')}, F-статистика: {bp['f_statistic']:.2f}") # Визуализация plt.figure(figsize=(14, 7)) plt.plot(data.index, y, color='#2C3E50', linewidth=1.5, label='Цена закрытия TSM') colors = ['#E74C3C', '#27AE60', '#2980B9', '#F39C12', '#9B59B6'] y_min = y.min() for i, bp in enumerate(breakpoints): date = data.index[bp['index']] f_value = bp['f_statistic'] # Вертикальная линия plt.axvline(date, color=colors[i % len(colors)], linestyle='--', linewidth=2, alpha=0.7) # Аннотация F-статистик plt.text(date, y_min, f"F={f_value:.2f}", rotation=0, color=colors[i % len(colors)], ha='center', va='bottom', fontsize=9) plt.xlabel('Дата', fontsize=11) plt.ylabel('Цена (USD)', fontsize=11) plt.title('Множественные структурные сдвиги в ценах TSM', fontsize=13, fontweight='bold') plt.grid(True, alpha=0.3) plt.tight_layout() plt.show() Рис. 1: Динамика цен акции Taiwan Semiconductor с отмеченными множественными структурными сдвигами, выявленными с помощью теста Чоу. Вертикальные линии указывают точки разрыва, а подписи рядом с осью X отображают соответствующие значения F-статистик, отражающие статистическую значимость изменений Найдено 9 структурных сдвигов: 1. Дата: 2019-05-13, F-статистика: 184.41 2. Дата: 2019-10-03, F-статистика: 51.77 3. Дата: 2020-03-09, F-статистика: 580.04 4. Дата: 2020-10-30, F-статистика: 1711.53 5. Дата: 2021-03-12, F-статистика: 186.92 6. Дата: 2021-11-29, F-статистика: 72.51 7. Дата: 2022-04-11, F-статистика: 446.67 8. Дата: 2022-11-17, F-статистика: 259.61 9. Дата: 2023-08-01, F-статистика: 72.62 Функция find_multiple_breakpoints реализует рекурсивный алгоритм детекции: На каждой итерации перебираются кандидаты точек разрыва с шагом 5% от длины сегмента, выбирается точка с максимальной F-статистикой при условии статистической значимости; Затем временной ряд разделяется на два подряда, и процедура повторяется для каждого из них; Рекурсия останавливается, когда сегмент становится слишком коротким или не находится значимых точек. Минимальный размер сегмента устанавливается равным 90 наблюдений (примерно 3-4 месяца для дневных данных), что обеспечивает достаточную мощность теста. Шаг перебора в 5% балансирует вычислительную эффективность и точность локализации разрыва. Ограничения метода и альтернативы Тест Чоу накладывает строгие требования на данные и структуру разрыва. Основные ограничения метода: Априорное знание точки разрыва. Классический тест требует, чтобы точка разрыва была определена заранее. Перебор всех возможных точек с выбором максимальной F-статистики может искажать распределение и завышать уровень значимости; Одномоментный разрыв. Тест фиксирует мгновенное изменение параметров. Плавные структурные изменения тест не обнаруживает, а дрейф параметров требует альтернативных подходов; Гомоскедастичность остатков. F-статистика корректна при постоянной дисперсии. Волатильные кластеры финансовых данных нарушают это предположение, увеличивая риск ложных срабатываний; Отсутствие автокорреляции. Для корректного теста остатки должны быть независимыми. Автокорреляция искажает стандартные ошибки и p-value, требуя корректировки через HAC; Чувствительность к выбросам. Экстремальные наблюдения влияют на RSS и могут вызвать ложное отклонение гипотезы. Робастные методы снижают эффект выбросов, однако требуют модификации теста. Альтернативы методы детекции сдвигов Альтернативные подходы устраняют ограничения теста Чоу и расширяют возможности анализа структурных сдвигов во временных рядах: Метод CUSUM (Cumulative Sum Control Chart) выявляет изменения параметров без априорного знания точки разрыва. Доверительные границы строятся по распределению броуновского моста, что позволяет мониторить данные в реальном времени; Метод Recursive Residuals использует последовательные регрессии с расширяющимся окном. Рекурсивный остаток — стандартизированная ошибка прогноза. Структурный сдвиг проявляется как систематическое смещение остатков после точки разрыва; Метод Bayesian Change Point Detection рассматривает число и положение точек разрыва как случайные величины с априорными распределениями. Апостериорное распределение вычисляется через MCMC или вариационный вывод. Метод автоматически определяет оптимальное количество сдвигов и позволяет учитывать экспертное знание через априорные распределения. Выбор метода зависит от задачи и характеристик данных: Тест Чоу оптимален для подтверждения структурного сдвига в известной точке, например, при анализе влияния регуляторных изменений; Методы CUSUM и Recursive Residuals применяются для мониторинга торговых систем в продакшене: детекция деградации модели в реальном времени; Метод Bayesian Change Point Detection используется в исследовательском анализе для автоматического обнаружения режимов рынка без предварительных гипотез о точках разрыва. Заключение Тест Чоу является одним из ключевых инструментов для проверки стабильности регрессионных моделей во времени. Он позволяет статистически определить, изменились ли параметры модели после определенного события. В алгоритмическом трейдинге это особенно важно для оценки устойчивости стратегий к смене рыночных режимов, выявления моментов рекалибровки факторных моделей и валидации производительности на тестовых данных. Практическая ценность теста заключается в его простоте и интерпретируемости: F-статистика напрямую показывает величину улучшения модели при учете структурного сдвига. Итеративные расширения метода решают задачу множественных точек разрыва, хотя и требуют аккуратности в интерпретации результатов из-за множественного тестирования. ### Виды функций потерь в машинном обучении Функция потерь — это способ «сообщить» модели, какие ошибки наиболее критичны. Математическая формулировка напрямую влияет на поведение модели во время обучения: какие ошибки минимизируются в приоритетном порядке, как модель реагирует на выбросы и насколько агрессивно оптимизирует параметры. Оптимизация этой функции через градиентный спуск составляет основу обучения моделей машинного обучения. Разные типы задач требуют разных функций потерь. Регрессия, бинарная классификация, многоклассовая классификация, сегментация изображений — каждая область использует специфические метрики оптимизации. Понимание математики функций потерь и их практических свойств позволяет настраивать модели под конкретные бизнес-требования. Функции потерь для регрессии Задачи регрессии требуют предсказания непрерывных значений: цен акций, объемов продаж, температуры. Функции потерь в регрессии измеряют расстояние между предсказанным и истинным значениями. Основное различие между ними — чувствительность к выбросам и характер штрафа за большие ошибки. Mean Squared Error (MSE) MSE вычисляет среднее квадратов отклонений предсказаний от истинных значений: MSE = (1/n) × Σ(yᵢ - ŷᵢ)² где: n — количество наблюдений; yᵢ — истинное значение i-го наблюдения; ŷᵢ — предсказанное значение i-го наблюдения. Квадрат разности усиливает штраф за большие ошибки нелинейно. Отклонение в 10 единиц дает штраф 100, в то время как 10 отклонений по 1 единице дают суммарный штраф только 10. Это свойство делает MSE чувствительной к выбросам. MSE используют в тех случаях, когда особенно важно учитывать крупные ошибки. В задачах прогнозирования волатильности финансовых инструментов недооценка резких ценовых движений может привести к значительным потерям. Поскольку MSE сильнее штрафует большие отклонения, метрика помогает моделям лучше учитывать экстремальные изменения цены. Градиент MSE пропорционален величине ошибки, что обеспечивает быструю сходимость при больших отклонениях и замедление при приближении к минимуму. Однако выбросы в данных могут смещать оптимум, заставляя модель подстраиваться под аномальные наблюдения в ущерб общей точности. import numpy as np import torch import torch.nn as nn # Реализация MSE в PyTorch mse_loss = nn.MSELoss() y_true = torch.tensor([2.5, 3.8, 5.1, 4.2]) y_pred = torch.tensor([2.3, 4.1, 5.0, 6.5]) loss = mse_loss(y_pred, y_true) print(f"MSE Loss: {loss.item():.4f}") # Ручная реализация для понимания manual_mse = torch.mean((y_pred - y_true) ** 2) print(f"Manual MSE: {manual_mse.item():.4f}") # Демонстрация чувствительности к выбросам y_true_outlier = torch.tensor([2.5, 3.8, 5.1, 4.2]) y_pred_outlier = torch.tensor([2.3, 4.1, 5.0, 15.0]) # Выброс в последнем предсказании loss_outlier = mse_loss(y_pred_outlier, y_true_outlier) print(f"MSE with outlier: {loss_outlier.item():.4f}") MSE Loss: 1.3575 Manual MSE: 1.3575 MSE with outlier: 29.1950 Код демонстрирует базовое применение MSE и ее чувствительность к выбросам. В последнем примере одно аномальное предсказание (15.0 вместо 4.2) резко увеличивает значение функции потерь. Квадратичный штраф за отклонение доминирует над остальными ошибками, что может исказить процесс обучения если данные содержат нетипичные наблюдения. Mean Absolute Error (MAE) MAE использует абсолютное значение ошибки вместо квадрата: MAE = (1/n) × Σ|yᵢ - ŷᵢ| Где переменные имеют те же значения что и в MSE. Линейный характер штрафа делает MAE более устойчивой к выбросам. Ошибка в 10 единиц дает штраф 10, независимо от того, одна это большая ошибка или десять маленьких по 1 единице. Модель не переобучается на аномальные значения. MAE предпочтительна когда выбросы в данных не представляют интереса или возникают из-за ошибок измерения. В задачах прогнозирования спроса аномально высокие значения часто связаны с временными факторами (праздники, акции), которые модель не должна воспринимать как типичное поведение. Градиент MAE остается постоянным и не меняется в зависимости от величины ошибки. Из-за этого сходимость алгоритма замедляется вблизи минимума: модель продолжает делать шаги одинакового размера, даже когда уже находится рядом с оптимальным решением. На практике это требует более осторожного подбора learning rate, чтобы избежать скачков и обеспечить стабильное обучение. import torch import torch.nn as nn mae_loss = nn.L1Loss() y_true = torch.tensor([2.5, 3.8, 5.1, 4.2]) y_pred = torch.tensor([2.3, 4.1, 5.0, 6.5]) loss = mae_loss(y_pred, y_true) print(f"MAE Loss: {loss.item():.4f}") # Сравнение с MSE при наличии выброса y_true_outlier = torch.tensor([2.5, 3.8, 5.1, 4.2]) y_pred_outlier = torch.tensor([2.3, 4.1, 5.0, 15.0]) mae_outlier = mae_loss(y_pred_outlier, y_true_outlier) mse_outlier = nn.MSELoss()(y_pred_outlier, y_true_outlier) print(f"MAE with outlier: {mae_outlier.item():.4f}") print(f"MSE with outlier: {mse_outlier.item():.4f}") print(f"Ratio MSE/MAE: {(mse_outlier/mae_outlier).item():.2f}x") MAE Loss: 0.7250 MAE with outlier: 2.8500 MSE with outlier: 29.1950 Ratio MSE/MAE: 10.24x Код показывает разницу в поведении MAE и MSE при наличии выброса. MAE растет линейно с величиной ошибки, в то время как MSE растет квадратично. Отношение MSE к MAE демонстрирует насколько сильнее квадратичная функция потерь реагирует на аномальные предсказания. Huber Loss Huber Loss комбинирует свойства MSE и MAE через параметр delta: При |yᵢ - ŷᵢ| ≤ δ: L = (1/2) × (yᵢ - ŷᵢ)² При |yᵢ - ŷᵢ| > δ: L = δ × |yᵢ - ŷᵢ| - (1/2) × δ² где: δ (delta) — порог переключения между квадратичным и линейным режимами; yᵢ — истинное значение; ŷᵢ — предсказанное значение. Huber Loss сочетает поведение MSE и MAE: для небольших ошибок она работает как MSE, а для крупных — как MAE. Такой подход обеспечивает оптимальный баланс: быструю и стабильную сходимость рядом с оптимумом и устойчивость к выбросам. Квадратичная часть создает достаточно сильный градиент, когда модель существенно ошибается, а линейная часть не позволяет аномальным значениям чрезмерно влиять на обучение. Выбор delta определяет границу между "нормальными" и "большими" ошибками. Значение delta = 1.0 работает для нормализованных данных со средним 0 и стандартным отклонением 1. Для ненормализованных данных delta стоит устанавливать исходя из распределения ошибок на валидационной выборке — типично это 1-2 стандартных отклонения. import torch import torch.nn as nn huber_loss = nn.HuberLoss(delta=1.0) y_true = torch.tensor([2.5, 3.8, 5.1, 4.2]) y_pred = torch.tensor([2.3, 4.1, 5.0, 15.0]) loss = huber_loss(y_pred, y_true) print(f"Huber Loss (delta=1.0): {loss.item():.4f}") # Сравнение разных delta deltas = [0.5, 1.0, 2.0, 5.0] for delta in deltas: loss = nn.HuberLoss(delta=delta)(y_pred, y_true) print(f"Huber Loss (delta={delta}): {loss.item():.4f}") # Визуализация поведения при разных ошибках errors = torch.linspace(-5, 5, 100) huber_values = torch.where( torch.abs(errors) <= 1.0, 0.5 * errors ** 2, 1.0 * torch.abs(errors) - 0.5 ) mse_values = 0.5 * errors ** 2 mae_values = torch.abs(errors) print("\nПоведение функций при ошибке = 3.0:") print(f"MSE: {0.5 * 3.0**2:.2f}") print(f"MAE: {abs(3.0):.2f}") print(f"Huber (δ=1.0): {1.0 * abs(3.0) - 0.5:.2f}") Huber Loss (delta=1.0): 2.5925 Huber Loss (delta=0.5): 1.3363 Huber Loss (delta=1.0): 2.5925 Huber Loss (delta=2.0): 4.9175 Huber Loss (delta=5.0): 10.3925 Поведение функций при ошибке = 3.0: MSE: 4.50 MAE: 3.00 Huber (δ=1.0): 2.50 Код демонстрирует влияние параметра delta на значение функции потерь. При малых delta Huber Loss ведет себя ближе к MAE, при больших — ближе к MSE. Правильный выбор delta зависит от распределения данных и толерантности к выбросам в конкретной задаче. Quantile Loss Quantile Loss позволяет предсказывать не только точечные оценки, но и квантили распределения. Функция использует асимметричный штраф: L = Σ ρτ(yᵢ - ŷᵢ) где: ρτ(u) = u × (τ - I(u < 0)); τ — целевой квантиль (от 0 до 1); I(u < 0) — индикаторная функция (1 если u < 0, иначе 0); yᵢ — истинное значение; ŷᵢ — предсказанное значение. При τ = 0.5 функция симметрична и эквивалентна MAE. При τ < 0.5 недооценка штрафуется слабее переоценки, при τ > 0.5 наоборот. Это позволяет обучать модели предсказывать границы доверительных интервалов. Quantile Loss применяется в риск-менеджменте для оценки Value at Risk (VaR). Предсказание 5-го процентиля (τ = 0.05) дает оценку максимальных потерь с вероятностью 95%. Модель штрафуется сильнее за недооценку риска чем за переоценку. import torch import numpy as np def quantile_loss(y_pred, y_true, quantile): errors = y_true - y_pred loss = torch.where( errors >= 0, quantile * errors, (quantile - 1) * errors ) return torch.mean(loss) # Генерация данных с выбросами torch.manual_seed(42) y_true = torch.randn(100) * 2 + 10 y_pred_median = torch.ones(100) * 10 # Предсказание медианы y_pred_q05 = torch.ones(100) * 7 # Предсказание 5-го процентиля # Расчет потерь для разных квантилей loss_median = quantile_loss(y_pred_median, y_true, 0.5) loss_q05 = quantile_loss(y_pred_q05, y_true, 0.05) loss_q95 = quantile_loss(y_pred_median, y_true, 0.95) print(f"Quantile Loss (τ=0.5, median): {loss_median.item():.4f}") print(f"Quantile Loss (τ=0.05, VaR): {loss_q05.item():.4f}") print(f"Quantile Loss (τ=0.95): {loss_q95.item():.4f}") # Обучение модели для предсказания разных квантилей class QuantileRegressor(torch.nn.Module): def __init__(self, input_dim): super().__init__() self.linear = torch.nn.Linear(input_dim, 1) def forward(self, x): return self.linear(x) # Пример: предсказание 10-го, 50-го и 90-го процентилей X = torch.randn(100, 5) y = torch.sum(X, dim=1) + torch.randn(100) * 0.5 quantiles = [0.1, 0.5, 0.9] models = {} for q in quantiles: model = QuantileRegressor(5) optimizer = torch.optim.Adam(model.parameters(), lr=0.01) for epoch in range(500): optimizer.zero_grad() y_pred = model(X).squeeze() loss = quantile_loss(y_pred, y, q) loss.backward() optimizer.step() models[q] = model print(f"Trained model for quantile {q}") Quantile Loss (τ=0.5, median): 0.8123 Quantile Loss (τ=0.05, VaR): 0.1855 Quantile Loss (τ=0.95): 0.8661 Trained model for quantile 0.1 Trained model for quantile 0.5 Trained model for quantile 0.9 В примере выше представлена реализация Quantile Loss и обучение моделей для разных квантилей. Ключевое свойство данной функции — асимметричность штрафа. При прогнозировании нижней границы (τ = 0.05) модель штрафуется сильнее когда реальное значение оказывается ниже предсказанного. Это заставляет модель быть консервативной в оценках рисков. Обучение нескольких моделей с разными τ позволяет получить полную картину распределения предсказаний. Функции потерь для бинарной классификации Бинарная классификация предсказывает вероятность принадлежности объекта к одному из двух классов. Функции потерь в этих задачах оценивают качество вероятностных предсказаний и должны быть дифференцируемыми для работы градиентного спуска. Основное требование — штрафовать не только неправильные предсказания, но и излишнюю уверенность модели в ошибочных ответах. Binary Cross-Entropy (Log Loss) Binary Cross-Entropy измеряет расхождение между предсказанным распределением вероятностей и истинными метками классов: BCE = -(1/n) × Σ[yᵢ × log(ŷᵢ) + (1 - yᵢ) × log(1 - ŷᵢ)] где: n — количество примеров; yᵢ — истинная метка класса (0 или 1); ŷᵢ — предсказанная вероятность класса 1 (от 0 до 1). Логарифм усиливает штраф когда модель уверенно ошибается. Предсказание вероятности 0.01 для класса 1 при истинной метке 1 дает большой штраф из-за log(0.01) ≈ -4.6. Предсказание 0.49 вместо 0.51 штрафуется слабо. Binary Cross-Entropy — стандартная функция потерь для логистической регрессии и выходных слоев нейросетей с сигмоидной активацией. Она является выпуклой относительно параметров логистической регрессии, что гарантирует сходимость к глобальному минимуму при оптимизации. При этом важно учитывать численную стабильность. Прямое вычисление log(0) или log(1) приводит к переполнению и потере точности. В PyTorch эти проблемы решаются с помощью BCEWithLogitsLoss, которая принимает логиты (значения до применения сигмоиды) и объединяет сигмоиду с расчетом функции потерь в одной операции. Такой подход предотвращает ошибки округления и обеспечивает более устойчивые вычисления. import torch import torch.nn as nn # Нестабильная версия (для демонстрации) def unstable_bce(y_pred, y_true): return -torch.mean( y_true * torch.log(y_pred) + (1 - y_true) * torch.log(1 - y_pred) ) # Стабильная версия через логиты bce_with_logits = nn.BCEWithLogitsLoss() # Пример данных y_true = torch.tensor([1., 0., 1., 0., 1.]) logits = torch.tensor([2.5, -1.8, 0.5, -3.2, 4.1]) # Сырые выходы модели # Правильный способ loss = bce_with_logits(logits, y_true) print(f"BCE with Logits Loss: {loss.item():.4f}") # Демонстрация штрафа за уверенные ошибки confident_wrong = torch.tensor([5.0]) # Сигмоид ≈ 0.993, очень уверенное предсказание класса 1 y_true_wrong = torch.tensor([0.]) # Истинный класс 0 loss_confident = bce_with_logits(confident_wrong, y_true_wrong) print(f"Loss for confident wrong prediction: {loss_confident.item():.4f}") uncertain = torch.tensor([0.1]) # Сигмоид ≈ 0.525, неуверенное предсказание loss_uncertain = bce_with_logits(uncertain, y_true_wrong) print(f"Loss for uncertain prediction: {loss_uncertain.item():.4f}") BCE with Logits Loss: 0.1525 Loss for confident wrong prediction: 5.0067 Loss for uncertain prediction: 0.7444 Представленный пример кода демонстрирует применение численно стабильной версии Binary Cross-Entropy. BCEWithLogitsLoss принимает логиты напрямую, избегая промежуточного вычисления сигмоиды. Сравнение штрафов показывает что функция сильно наказывает уверенные ошибки: неправильное но уверенное предсказание (логит 5.0) дает потери около 5, в то время как неуверенное (логит 0.1) — около 0.7. Hinge Loss Функция потерь Hinge Loss используется в моделях SVM (Support Vector Machines) и фокусируется на максимизации отступа между классами. Ее формула: L = Σ max(0, 1 - yᵢ × ŷᵢ) где: yᵢ — истинная метка класса (-1 или +1, не 0 или 1); ŷᵢ — сырой выход модели (не вероятность). Функция штрафует примеры только если они находятся на неправильной стороне границы решения или слишком близко к ней. Если yᵢ × ŷᵢ ≥ 1, штраф равен нулю — модель достаточно уверена в правильном предсказании. При yᵢ × ŷᵢ < 1 начисляется линейный штраф. Hinge Loss не предоставляет вероятностных оценок — она формирует жесткое разделение классов. Это особенно полезно в задачах, где важнее четкая решающая граница, а не точная калибровка вероятностей. В частности, SVM с Hinge Loss стремится найти гиперплоскость, которая максимизирует отступ от ближайших точек каждого класса, обеспечивая максимально уверенное разделение данных. import torch import torch.nn as nn hinge_loss = nn.HingeEmbeddingLoss() # Для Hinge Loss метки должны быть -1 или +1 y_true = torch.tensor([1., -1., 1., -1., 1.]) y_pred = torch.tensor([0.8, -1.5, 0.3, -0.2, 2.1]) # Ручной расчет для понимания manual_hinge = torch.mean(torch.clamp(1 - y_true * y_pred, min=0)) print(f"Manual Hinge Loss: {manual_hinge.item():.4f}") # Анализ поведения на разных примерах examples = [ (1.0, 2.0, "Correct, confident"), (1.0, 0.5, "Correct, in margin"), (1.0, -0.5, "Wrong"), (-1.0, -2.0, "Correct, confident"), (-1.0, 0.3, "Wrong, small violation") ] print("\nBehavior analysis:") for y_t, y_p, desc in examples: y_t_tensor = torch.tensor([y_t]) y_p_tensor = torch.tensor([y_p]) margin = y_t * y_p loss = torch.clamp(torch.tensor(1 - margin), min=0) print(f"{desc}: margin={margin:.2f}, loss={loss.item():.4f}") Manual Hinge Loss: 0.3400 Behavior analysis: Correct, confident: margin=2.00, loss=0.0000 Correct, in margin: margin=0.50, loss=0.5000 Wrong: margin=-0.50, loss=1.5000 Correct, confident: margin=2.00, loss=0.0000 Wrong, small violation: margin=-0.30, loss=1.3000 Код показывает работу Hinge Loss через концепцию отступа (margin). Произведение yᵢ × ŷᵢ называется отступом: положительное значение означает правильную сторону границы, отрицательное — ошибку. Функция требует отступ минимум 1.0 для нулевого штрафа. Примеры с отступом от 0 до 1 находятся в "зоне неуверенности" и получают штраф пропорционально близости к границе. Это заставляет модель не просто правильно классифицировать объекты, но и делать это с запасом. Focal Loss Функция потерь Focal Loss модифицирует Binary Cross-Entropy для решения проблемы дисбаланса классов: FL = -Σ αᵢ × (1 - ŷᵢ)^γ × log(ŷᵢ) где: αᵢ — вес класса (балансирует частоты классов); γ (gamma) — фокусирующий параметр (обычно 2); ŷᵢ — предсказанная вероятность правильного класса. Множитель (1 - ŷᵢ)^γ снижает вес легко классифицируемых примеров. Когда модель уверенно правильно предсказывает класс (ŷᵢ ≈ 1), множитель близок к нулю и штраф минимален. Сложные примеры с ŷᵢ ≈ 0.5 сохраняют полный вес. Это перенаправляет фокус обучения на проблемные случаи. Параметр α регулирует баланс между классами. В задачах детекции объектов встречается сильный дисбаланс данных — число фоновых областей может превышать количество объектов в соотношении 1000:1. Установка α = 0.25 для положительного класса уменьшает влияние многочисленных негативных примеров и помогает модели лучше обучаться на редких объектах. Параметр γ определяет степень фокусировки. При γ = 0 функция сводится к обычной cross-entropy, а при γ = 2 (стандартное значение в задачах object detection) фокусировка усиливается: модель уделяет больше внимания сложным для классификации примерам и меньше — уже правильно классифицированным областям. import torch import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha=0.25, gamma=2.0): super().__init__() self.alpha = alpha self.gamma = gamma def forward(self, inputs, targets): bce_loss = F.binary_cross_entropy_with_logits( inputs, targets, reduction='none' ) probas = torch.sigmoid(inputs) targets_probas = targets * probas + (1 - targets) * (1 - probas) focal_weight = (1 - targets_probas) ** self.gamma alpha_weight = targets * self.alpha + (1 - targets) * (1 - self.alpha) focal_loss = alpha_weight * focal_weight * bce_loss return focal_loss.mean() # Имитация дисбаланса классов: 95% класса 0, 5% класса 1 torch.manual_seed(42) n_samples = 1000 n_positive = 50 y_true = torch.zeros(n_samples) y_true[:n_positive] = 1.0 logits = torch.randn(n_samples) # Сравнение обычного BCE и Focal Loss bce_loss = nn.BCEWithLogitsLoss() focal_loss = FocalLoss(alpha=0.25, gamma=2.0) loss_bce = bce_loss(logits, y_true) loss_focal = focal_loss(logits, y_true) print(f"BCE Loss (imbalanced): {loss_bce.item():.4f}") print(f"Focal Loss (imbalanced): {loss_focal.item():.4f}") # Анализ весов для легких и сложных примеров easy_example = torch.tensor([5.0]) # Высокая уверенность hard_example = torch.tensor([0.1]) # Низкая уверенность y_pos = torch.tensor([1.0]) focal_easy = focal_loss(easy_example, y_pos) focal_hard = focal_loss(hard_example, y_pos) print(f"\nFocal Loss for easy example: {focal_easy.item():.4f}") print(f"Focal Loss for hard example: {focal_hard.item():.4f}") print(f"Ratio hard/easy: {(focal_hard/focal_easy).item():.1f}x") BCE Loss (imbalanced): 0.8078 Focal Loss (imbalanced): 0.2515 Focal Loss for easy example: 0.0000 Focal Loss for hard example: 0.0364 Ratio hard/easy: 483382.4x Код реализует Focal Loss и демонстрирует ее поведение на небалансированных данных: Фокусирующий множитель (1 - p)^γ существенно снижает вклад легких примеров: отношение штрафа за сложный пример к легкому достигает 483000x; Когда модель уверенно правильно предсказывает класс, штраф становится практически нулевым (0.0000 для легкого примера), в то время как сложные примеры с низкой уверенностью сохраняют значительный вес (0.0364). Это перенаправляет градиенты на проблемные случаи; Параметр alpha дополнительно балансирует классы, предотвращая доминирование частого класса — на небалансированных данных Focal Loss (0.2515) дает более низкое значение чем обычный BCE (0.8078), концентрируя обучение на меньшинстве. Функции потерь для многоклассовой классификации Многоклассовая классификация расширяет бинарный случай на несколько взаимоисключающих классов. Модель выдает вектор вероятностей для каждого класса, сумма которых равна 1. Функции потерь сравнивают это распределение с истинным классом и штрафуют модель за низкую уверенность в правильном ответе. Categorical Cross-Entropy Функция потерь Categorical Cross-Entropy обобщает Binary Cross-Entropy на случай нескольких классов: CCE = -Σᵢ Σⱼ yᵢⱼ × log(ŷᵢⱼ) где: i — индекс примера; j — индекс класса; yᵢⱼ — элемент one-hot вектора (1 для правильного класса, 0 для остальных); ŷᵢⱼ — предсказанная вероятность класса j для примера i. Функция вычисляет кросс-энтропию между предсказанным распределением вероятностей и истинным распределением (one-hot вектор). Поскольку в one-hot векторе только один элемент равен 1, формула упрощается до: -log(ŷᵢc) , где c — индекс правильного класса. Модель штрафуется только за вероятность правильного класса, вероятности остальных классов влияют косвенно через нормализацию. Categorical Cross-Entropy предполагает использование меток one-hot кодирования. Например, для задачи с тремя классами вектор [0, 1, 0] означает, что образец принадлежит классу 1. На выходе модели обычно применяется слой softmax, который преобразует логиты в вероятности. import torch import torch.nn as nn import torch.nn.functional as F # Данные: 5 примеров, 3 класса logits = torch.tensor([ [2.0, 1.0, 0.1], [0.5, 2.5, 0.3], [0.1, 0.2, 3.0], [1.5, 1.5, 1.5], [3.0, 0.5, 0.5] ]) # One-hot метки targets_onehot = torch.tensor([ [1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [0., 1., 0.], [1., 0., 0.] ]) # Ручной расчет через softmax probas = F.softmax(logits, dim=1) manual_cce = -torch.mean(torch.sum(targets_onehot * torch.log(probas), dim=1)) print(f"Manual Categorical Cross-Entropy: {manual_cce.item():.4f}") # Анализ уверенности предсказаний for i in range(5): true_class = torch.argmax(targets_onehot[i]).item() pred_proba = probas[i, true_class].item() example_loss = -torch.log(probas[i, true_class]) print(f"Example {i}: true_class={true_class}, " f"proba={pred_proba:.3f}, loss={example_loss:.3f}") Manual Categorical Cross-Entropy: 0.3995 Example 0: true_class=0, proba=0.659, loss=0.417 Example 1: true_class=1, proba=0.802, loss=0.220 Example 2: true_class=2, proba=0.896, loss=0.110 Example 3: true_class=1, proba=0.333, loss=1.099 Example 4: true_class=0, proba=0.859, loss=0.152 Код демонстрирует вычисление Categorical Cross-Entropy через one-hot представление. Функция потерь для каждого примера равна отрицательному логарифму вероятности правильного класса. Пример 3 с равными логитами [1.5, 1.5, 1.5] дает вероятность 1/3 для каждого класса и высокий штраф -log(1/3) ≈ 1.1, показывая неуверенность модели. Примеры с четкими предсказаниями (высокий логит правильного класса) получают низкий штраф. Sparse Categorical Cross-Entropy Функция потерь Sparse Categorical Cross-Entropy математически эквивалентна обычной Categorical Cross-Entropy, но принимает метки классов как целые числа вместо one-hot векторов. Ее формула расчета следующая: SCCE = -Σᵢ log(ŷᵢ,cᵢ) где: i — индекс примера; cᵢ — целочисленная метка класса для примера i (0, 1, 2, ...); ŷᵢ,cᵢ — предсказанная вероятность правильного класса cᵢ. Разница между двумя вариантами — чисто техническая. Sparse-версия значительно экономит память при большом количестве классов. Например, хранение метки как числа «42» занимает всего 4 байта, тогда как one-hot вектор размерности 1000 требует около 4000 байт. В задачах с тысячами классов (таких как классификация слов в NLP) эта экономия становится весьма существенной. import torch import torch.nn as nn # Данные: 5 примеров, 3 класса logits = torch.tensor([ [2.0, 1.0, 0.1], [0.5, 2.5, 0.3], [0.1, 0.2, 3.0], [1.5, 1.5, 1.5], [3.0, 0.5, 0.5] ]) # Sparse метки (целые числа) targets_sparse = torch.tensor([0, 1, 2, 1, 0]) # Использование PyTorch CrossEntropyLoss (автоматически применяет softmax) loss_fn = nn.CrossEntropyLoss() loss = loss_fn(logits, targets_sparse) print(f"Sparse Categorical Cross-Entropy: {loss.item():.4f}") # Сравнение с one-hot версией targets_onehot = torch.zeros(5, 3) targets_onehot[range(5), targets_sparse] = 1.0 probas = torch.nn.functional.softmax(logits, dim=1) manual_loss = -torch.mean(torch.sum(targets_onehot * torch.log(probas), dim=1)) print(f"One-hot version (should match): {manual_loss.item():.4f}") # Демонстрация эффективности для большого числа классов print("\nMemory efficiency comparison:") # Малое число классов n_classes_small = 10 targets_small = torch.randint(0, n_classes_small, (100,)) onehot_small = torch.zeros(100, n_classes_small) sparse_size_small = targets_small.element_size() * targets_small.nelement() onehot_size_small = onehot_small.element_size() * onehot_small.nelement() print(f"\n{n_classes_small} classes:") print(f"Sparse: {sparse_size_small / 1024:.2f} KB") print(f"One-hot: {onehot_size_small / 1024:.2f} KB") print(f"Ratio: {onehot_size_small / sparse_size_small:.1f}x") # Среднее число классов n_classes_medium = 1000 targets_medium = torch.randint(0, n_classes_medium, (100,)) onehot_medium = torch.zeros(100, n_classes_medium) sparse_size_medium = targets_medium.element_size() * targets_medium.nelement() onehot_size_medium = onehot_medium.element_size() * onehot_medium.nelement() print(f"\n{n_classes_medium} classes:") print(f"Sparse: {sparse_size_medium / 1024:.2f} KB") print(f"One-hot: {onehot_size_medium / 1024:.2f} KB") print(f"Ratio: {onehot_size_medium / sparse_size_medium:.1f}x") # Большое число классов n_classes_large = 50000 targets_large = torch.randint(0, n_classes_large, (100,)) onehot_large = torch.zeros(100, n_classes_large) sparse_size_large = targets_large.element_size() * targets_large.nelement() onehot_size_large = onehot_large.element_size() * onehot_large.nelement() print(f"\n{n_classes_large} classes:") print(f"Sparse: {sparse_size_large / 1024:.2f} KB") print(f"One-hot: {onehot_size_large / 1024:.2f} KB") print(f"Ratio: {onehot_size_large / sparse_size_large:.1f}x") Sparse Categorical Cross-Entropy: 0.3995 One-hot version (should match): 0.3995 Memory efficiency comparison: 10 classes: Sparse: 0.78 KB One-hot: 3.91 KB Ratio: 5.0x 1000 classes: Sparse: 0.78 KB One-hot: 390.62 KB Ratio: 500.0x 50000 classes: Sparse: 0.78 KB One-hot: 19531.25 KB Ratio: 25000.0x Демонстрация использования памяти наглядно показывает практическую ценность sparse-формата: размер меток в sparse-представлении остается постоянным (около 0.78 KB), независимо от количества классов. В то время как размер one-hot кодирования растет линейно: для 10 классов разница составляет примерно 5 раз, для 1000 классов — уже 500 раз, а для 50 000 классов — около 25 000 раз. В задачах NLP, где словари могут включать десятки тысяч токенов, sparse-формат становится критически важным методом для эффективности обучения и экономии памяти. KL Divergence Kullback-Leibler Divergence измеряет различие между двумя распределениями вероятностей: KL(P||Q) = Σⱼ Pⱼ × log(Pⱼ / Qⱼ) где: P — истинное распределение (целевое); Q — предсказанное распределение; j — индекс класса. Функция потерь KL Divergence является асимметричной: KL(P‖Q) ≠ KL(Q‖P) Она особенно сильно штрафует ситуации, когда модель занижает вероятность событий, которым истинное распределение приписывает высокий вес. Например, если истинное распределение дает классу A вероятность 0.8, а модель — всего 0.2, штраф получается значительным, так как log(0.8/0.2) ≈ 1.4. KL Divergence применяют, когда целевое распределение «мягкое», а не one-hot. Например, в задачах knowledge distillation студенческая модель обучается повторять выходное распределение учительской модели, а не точные метки классов. В задачах multi-label классификации с пересекающимися классами целевой вектор может выглядеть как [0.7, 0.2, 0.1], отражая разную степень принадлежности к каждому классу. import torch import torch.nn as nn import torch.nn.functional as F kl_div_loss = nn.KLDivLoss(reduction='batchmean') # Мягкие метки (не one-hot) targets_soft = torch.tensor([ [0.7, 0.2, 0.1], [0.1, 0.8, 0.1], [0.2, 0.2, 0.6], [0.4, 0.4, 0.2], [0.8, 0.1, 0.1] ]) logits = torch.tensor([ [2.0, 1.0, 0.1], [0.5, 2.5, 0.3], [0.1, 0.2, 3.0], [1.5, 1.5, 1.5], [3.0, 0.5, 0.5] ]) # KL Divergence требует логарифмы предсказаний log_probas = F.log_softmax(logits, dim=1) loss = kl_div_loss(log_probas, targets_soft) print(f"KL Divergence Loss: {loss.item():.4f}") # Сравнение с Cross-Entropy на hard labels targets_hard = torch.tensor([0, 1, 2, 1, 0]) ce_loss = nn.CrossEntropyLoss()(logits, targets_hard) print(f"Cross-Entropy Loss: {ce_loss.item():.4f}") # Демонстрация асимметричности P = torch.tensor([[0.8, 0.2]]) Q1 = torch.tensor([[0.7, 0.3]]) Q2 = torch.tensor([[0.9, 0.1]]) kl_PQ1 = kl_div_loss(torch.log(Q1), P) kl_PQ2 = kl_div_loss(torch.log(Q2), P) print(f"\nKL(P||Q1) where Q1=[0.7, 0.3]: {kl_PQ1.item():.4f}") print(f"KL(P||Q2) where Q2=[0.9, 0.1]: {kl_PQ2.item():.4f}") KL Divergence Loss: 0.0724 Cross-Entropy Loss: 0.3995 KL(P||Q1) where Q1=[0.7, 0.3]: 0.0257 KL(P||Q2) where Q2=[0.9, 0.1]: 0.0444 Код демонстрирует применение KL Divergence с мягкими метками. Функция принимает логарифмы предсказанных вероятностей (log_softmax) и целевое распределение. Мягкие метки полезны в knowledge distillation: большая модель-учитель дает не жесткие 0/1, а распределение вероятностей, содержащее больше информации о структуре данных. KL Divergence (0.0724) дает более низкое значение чем Cross-Entropy (0.3995) на тех же данных, поскольку мягкие метки содержат ненулевые вероятности для нескольких классов. Пример асимметричности показывает что отклонение от целевого распределения P=[0.8, 0.2] к Q2=[0.9, 0.1] дает больший штраф (0.0444) чем к Q1=[0.7, 0.3] (0.0257), хотя оба отклонения составляют 0.1 по абсолютной величине — KL Divergence сильнее штрафует когда модель присваивает низкую вероятность событиям с высокой истинной вероятностью. Специализированные функции потерь Некоторые задачи машинного обучения требуют функций потерь учитывающих специфику предметной области. Например: в сегментации изображений работают с попиксельными масками; в object detection важны пересечения bounding box’ов; в метрическом обучении учитываются расстояния в embedding-пространстве. Использование стандартных функций потерь в таких задачах может привести к рассогласованию между оптимизируемой целью и фактической метрикой качества, что снижает эффективность обучения. Dice Loss и IoU Loss Функция потерь Dice Loss основана на коэффициенте Dice, измеряющем сходство двух множеств: Dice = (2 × |A ∩ B|) / (|A| + |B|) Dice Loss = 1 - Dice где: A — множество предсказанных пикселей класса; B — множество истинных пикселей класса; |A ∩ B| — количество правильно предсказанных пикселей; |A| + |B| — сумма размеров множеств. Коэффициент Dice измеряет степень перекрытия между предсказанием и истиной. Значение 1 означает полное совпадение, 0 — отсутствие пересечения. Dice Loss инвертирует метрику для минимизации. Функция IoU (Intersection over Union) Loss использует похожую логику: IoU = |A ∩ B| / |A ∪ B| IoU Loss = 1 - IoU Где |A ∪ B| — объединение множеств (все пиксели предсказанные или истинные). Dice более чувствительна к малым объектам. В числителе стоит удвоенное пересечение, что усиливает вклад правильных предсказаний. Для объекта размером 100 пикселей правильное предсказание 50 пикселей дает Dice = 2×50/(100+100) = 0.5. IoU для той же ситуации: 50/(100+50) ≈ 0.33. Dice растет быстрее, давая модели более сильный сигнал на малых объектах. import torch import torch.nn as nn class DiceLoss(nn.Module): def __init__(self, smooth=1.0): super().__init__() self.smooth = smooth def forward(self, predictions, targets): predictions = torch.sigmoid(predictions) intersection = (predictions * targets).sum(dim=(1, 2)) union = predictions.sum(dim=(1, 2)) + targets.sum(dim=(1, 2)) dice = (2.0 * intersection + self.smooth) / (union + self.smooth) return 1.0 - dice.mean() class IoULoss(nn.Module): def __init__(self, smooth=1.0): super().__init__() self.smooth = smooth def forward(self, predictions, targets): predictions = torch.sigmoid(predictions) intersection = (predictions * targets).sum(dim=(1, 2)) union = predictions.sum(dim=(1, 2)) + targets.sum(dim=(1, 2)) - intersection iou = (intersection + self.smooth) / (union + self.smooth) return 1.0 - iou.mean() # Пример: сегментация с малым объектом torch.manual_seed(42) batch_size, height, width = 2, 64, 64 # Создание маски с малым объектом (10x10 пикселей) targets = torch.zeros(batch_size, height, width) targets[:, 20:30, 20:30] = 1.0 # Предсказание с частичным перекрытием predictions_logits = torch.randn(batch_size, height, width) - 2.0 predictions_logits[:, 22:28, 22:28] += 5.0 # Частичное перекрытие dice_loss = DiceLoss() iou_loss = IoULoss() loss_dice = dice_loss(predictions_logits, targets) loss_iou = iou_loss(predictions_logits, targets) print(f"Dice Loss: {loss_dice.item():.4f}") print(f"IoU Loss: {loss_iou.item():.4f}") # Сравнение с BCE bce_loss = nn.BCEWithLogitsLoss() loss_bce = bce_loss(predictions_logits, targets) print(f"BCE Loss: {loss_bce.item():.4f}") # Анализ поведения на разных уровнях перекрытия overlaps = [0.2, 0.4, 0.6, 0.8, 1.0] print("\nDice vs IoU for different overlaps:") for overlap in overlaps: dice_val = (2 * overlap) / (1 + overlap) iou_val = overlap / (2 - overlap) print(f"Overlap {overlap:.1f}: Dice={dice_val:.3f}, IoU={iou_val:.3f}") Dice Loss: 0.8852 IoU Loss: 0.9385 BCE Loss: 0.2129 Dice vs IoU for different overlaps: Overlap 0.2: Dice=0.333, IoU=0.111 Overlap 0.4: Dice=0.571, IoU=0.250 Overlap 0.6: Dice=0.750, IoU=0.429 Overlap 0.8: Dice=0.889, IoU=0.667 Overlap 1.0: Dice=1.000, IoU=1.000 Код реализует Dice Loss и IoU Loss для сегментации. Параметр smooth предотвращает деление на ноль когда и предсказание и цель пусты. Сравнение показывает что при малом перекрытии масок Dice Loss (0.8852) и IoU Loss (0.9385) дают высокие значения, сигнализируя о плохом качестве предсказания, в то время как BCE (0.2129) показывает умеренный штраф из-за большого количества правильно предсказанного фона. Сравнение разных уровней перекрытия показывает, что Dice растет быстрее, чем IoU. Например, при 40% перекрытия Dice = 0.571, а IoU = 0.250. Это делает Dice более чувствительной к улучшениям на малых объектах, обеспечивая модели более сильный градиентный сигнал. В отличие от Dice и IoU, Binary Cross-Entropy оценивает каждый пиксель независимо, не учитывая глобальное перекрытие масок. Для задач, где важна целостная форма объекта, использование Dice или IoU позволяет модели лучше оптимизировать качество сегментации. Contrastive Loss Contrastive Loss обучает модель создавать пространство эмбеддингов таким образом, чтобы похожие объекты располагались близко друг к другу, а различающиеся — далеко. Формула расчета функции следующая: L = (1 - Y) × (1/2) × D² + Y × (1/2) × max(0, m - D)² где: Y — метка пары (0 для похожих, 1 для различных); D — евклидово расстояние между эмбеддингами; m (margin) — минимальное расстояние для различных пар. Функция работает с парами примеров. Для похожих пар (Y = 0) минимизируется расстояние D. Для различных пар (Y = 1) минимизируется max(0, m - D), что штрафует модель только если различные примеры ближе чем margin. Если различные объекты уже достаточно далеко (D ≥ m), штраф нулевой. Функцию потерь Contrastive Loss используют для распознавания лиц (face recognition) и задач обучения на сходство (similarity learning). Модель тренируют на парах изображений: одного человека (похожие) и разных людей (различные). В результате формируется пространство эмбеддингов, где объекты можно сравнивать по расстоянию, без необходимости заново обучать классификатор для каждого нового набора людей. Параметр margin (разрыв) задает, насколько далеко должны быть разные объекты. Если значение слишком маленькое, классы плохо разделяются; если слишком большое, модель пытается разнести объекты нереально далеко. Обычно используют 𝑚=1.0 для нормализованных векторов признаков или устанавливают margin равным среднему расстоянию между разными парами в данных. import torch import torch.nn as nn class ContrastiveLoss(nn.Module): def __init__(self, margin=2.0): super().__init__() self.margin = margin def forward(self, embedding1, embedding2, label): distance = torch.nn.functional.pairwise_distance(embedding1, embedding2) loss_similar = (1 - label) * torch.pow(distance, 2) loss_dissimilar = label * torch.pow(torch.clamp(self.margin - distance, min=0.0), 2) loss = 0.5 * (loss_similar + loss_dissimilar) return loss.mean() class EmbeddingNet(nn.Module): def __init__(self, input_dim, embedding_dim): super().__init__() self.fc = nn.Sequential( nn.Linear(input_dim, 128), nn.ReLU(), nn.Linear(128, embedding_dim) ) def forward(self, x): return self.fc(x) # Генерация данных: 3 класса с перекрытием в пространстве признаков torch.manual_seed(42) class1_data = torch.randn(50, 10) * 2.0 + torch.tensor([1.5, 1.5] + [0.0] * 8) class2_data = torch.randn(50, 10) * 2.0 + torch.tensor([-1.5, 1.5] + [0.0] * 8) class3_data = torch.randn(50, 10) * 2.0 + torch.tensor([0.0, -2.0] + [0.0] * 8) # Создание пар pairs = [] for i in range(30): idx1, idx2 = torch.randint(0, 50, (2,)) pairs.append((class1_data[idx1], class1_data[idx2], 0)) pairs.append((class1_data[idx1], class2_data[idx2], 1)) pairs.append((class2_data[idx1], class3_data[idx2], 1)) # Обучение model = EmbeddingNet(input_dim=10, embedding_dim=5) criterion = ContrastiveLoss(margin=2.0) optimizer = torch.optim.Adam(model.parameters(), lr=0.005) for epoch in range(150): total_loss = 0.0 for x1, x2, label in pairs: optimizer.zero_grad() emb1 = model(x1.unsqueeze(0)) emb2 = model(x2.unsqueeze(0)) loss = criterion(emb1, emb2, torch.tensor([label], dtype=torch.float)) loss.backward() optimizer.step() total_loss += loss.item() if (epoch + 1) % 30 == 0: print(f"Epoch {epoch+1}: Loss = {total_loss/len(pairs):.4f}") # Проверка расстояний with torch.no_grad(): emb_c1 = model(class1_data[:5]) emb_c2 = model(class2_data[:5]) emb_c3 = model(class3_data[:5]) dist_within_c1 = torch.nn.functional.pairwise_distance(emb_c1[0:1], emb_c1[1:2]) dist_c1_c2 = torch.nn.functional.pairwise_distance(emb_c1[0:1], emb_c2[0:1]) dist_c1_c3 = torch.nn.functional.pairwise_distance(emb_c1[0:1], emb_c3[0:1]) print(f"\nDistance within class 1: {dist_within_c1.item():.4f}") print(f"Distance class 1 to class 2: {dist_c1_c2.item():.4f}") print(f"Distance class 1 to class 3: {dist_c1_c3.item():.4f}") Epoch 30: Loss = 0.0351 Epoch 60: Loss = 0.0629 Epoch 90: Loss = 0.0314 Epoch 120: Loss = 0.0000 Epoch 150: Loss = 0.0001 Distance within class 1: 0.9520 Distance class 1 to class 2: 2.0083 Distance class 1 to class 3: 4.0971 Код демонстрирует обучение embedding сети с Contrastive Loss. Модель учится размещать примеры одного класса близко друг к другу и далеко от другого класса. После обучения расстояние внутри класса существенно меньше расстояния между классами, что подтверждает формирование структурированного embedding пространства. Margin контролирует минимальное разделение: модель прекращает увеличивать расстояние между различными парами когда оно превышает margin, концентрируя усилия на проблемных случаях. Triplet Loss Функция Triplet Loss работает с тройками примеров: anchor, positive, negative. Расчетная формула: L = max(0, D(a, p) - D(a, n) + m) где: a — anchor (опорный пример); p — positive (похожий на anchor); n — negative (отличный от anchor); D — функция расстояния (обычно L2); m (margin) — минимальный отступ. Функция требует чтобы расстояние от anchor до positive было меньше расстояния до negative хотя бы на margin. Если D(a, p) + m < D(a, n), тройка уже разделена правильно и штраф нулевой. В противном случае модель штрафуется пропорционально нарушению условия. Triplet Loss эффективнее Contrastive Loss поскольку напрямую сравнивает относительные расстояния. Contrastive оптимизирует пары независимо, Triplet гарантирует правильную упорядоченность расстояний в тройке. Это приводит к более компактным кластерам схожих объектов. Однако тут надо учитывать, что качество обучения сильно зависит от того, как формируются тройки: Простая случайная выборка дает в основном легкие тройки, где negative уже далеко от anchor, и градиент мало информативен; Метод hard negative mining выбирает сложные negative-примеры — объекты другого класса, близкие к anchor; Semi-hard mining (полу-сложная выборка) выбирает negative дальше positive, но все еще в пределах margin (разрыва): D(a, p) < D(a, n) < D(a, p) + m. Такой подход позволяет получить наиболее информативный градиент и ускоряет обучение. import torch import torch.nn as nn class TripletLoss(nn.Module): def __init__(self, margin=1.0): super().__init__() self.margin = margin def forward(self, anchor, positive, negative): distance_positive = torch.nn.functional.pairwise_distance(anchor, positive) distance_negative = torch.nn.functional.pairwise_distance(anchor, negative) losses = torch.clamp(distance_positive - distance_negative + self.margin, min=0.0) return losses.mean() class TripletNet(nn.Module): def __init__(self, input_dim, embedding_dim): super().__init__() self.embedding = nn.Sequential( nn.Linear(input_dim, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, embedding_dim) ) def forward(self, x): return nn.functional.normalize(self.embedding(x), p=2, dim=1) # Генерация данных torch.manual_seed(42) n_samples = 100 class1 = torch.randn(n_samples, 10) * 1.2 + torch.tensor([1.0] * 10) class2 = torch.randn(n_samples, 10) * 1.2 + torch.tensor([0.0] * 10) class3 = torch.randn(n_samples, 10) * 1.2 + torch.tensor([-1.0] * 10) def mine_hard_triplets(n_triplets=50): triplets = [] classes = [class1, class2, class3] for _ in range(n_triplets): anchor_class = torch.randint(0, 3, (1,)).item() anchor_idx = torch.randint(0, n_samples, (1,)).item() positive_idx = torch.randint(0, n_samples, (1,)).item() anchor = classes[anchor_class][anchor_idx] positive = classes[anchor_class][positive_idx] negative_classes = [i for i in range(3) if i != anchor_class] negative_class = negative_classes[torch.randint(0, len(negative_classes), (1,)).item()] negative_idx = torch.randint(0, n_samples, (1,)).item() negative = classes[negative_class][negative_idx] triplets.append((anchor, positive, negative)) return triplets # Обучение model = TripletNet(input_dim=10, embedding_dim=8) criterion = TripletLoss(margin=0.5) optimizer = torch.optim.Adam(model.parameters(), lr=0.001) for epoch in range(200): triplets = mine_hard_triplets(n_triplets=30) total_loss = 0.0 for anchor, positive, negative in triplets: optimizer.zero_grad() emb_a = model(anchor.unsqueeze(0)) emb_p = model(positive.unsqueeze(0)) emb_n = model(negative.unsqueeze(0)) loss = criterion(emb_a, emb_p, emb_n) loss.backward() optimizer.step() total_loss += loss.item() if (epoch + 1) % 40 == 0: print(f"Epoch {epoch+1}: Loss = {total_loss/30:.4f}") # Анализ embedding пространства with torch.no_grad(): emb1 = model(class1[:10]) emb2 = model(class2[:10]) emb3 = model(class3[:10]) dist_within_c1 = torch.nn.functional.pairwise_distance(emb1[0:1], emb1[1:2]) dist_c1_c2 = torch.nn.functional.pairwise_distance(emb1[0:1], emb2[0:1]) dist_c1_c3 = torch.nn.functional.pairwise_distance(emb1[0:1], emb3[0:1]) print(f"\nDistance within class 1: {dist_within_c1.item():.4f}") print(f"Distance class 1 to class 2: {dist_c1_c2.item():.4f}") print(f"Distance class 1 to class 3: {dist_c1_c3.item():.4f}") Epoch 40: Loss = 0.0105 Epoch 80: Loss = 0.1694 Epoch 120: Loss = 0.0792 Epoch 160: Loss = 0.0031 Epoch 200: Loss = 0.0408 Distance within class 1: 0.5122 Distance class 1 to class 2: 1.4396 Distance class 1 to class 3: 1.9506 Код реализует Triplet Loss с простой стратегией hard negative mining. Модель обучается размещать эмбеддинги таким образом, чтобы расстояние anchor-positive было минимум на margin меньше расстояния anchor-negative. Нормализация эмбеддингов с помощью L2-нормы до единицы стабилизирует обучение и делает расстояния между объектами сопоставимыми. После обучения расстояния внутри класса становятся существенно меньше, чем между разными классами, а межклассовые расстояния остаются почти идентичными. Это наглядно показывает формирование четких кластеров в embedding-пространстве. Практические рекомендации по выбору функции потерь Выбор функции потерь определяется типом задачи, характеристиками данных и бизнес-метриками. Регрессия Базовый выбор — MSE (Mean Squared Error) или MAE (Mean Absolute Error). MSE лучше использовать, когда большие ошибки критичнее малых, например при прогнозировании цен активов: недооценка резких движений ведет к значительным потерям; MAE более устойчив к выбросам и эффективен, когда все ошибки имеют примерно одинаковую важность; Huber Loss сочетает преимущества MSE и MAE, требуя подбора параметра delta для переключения между квадратичной и линейной частью; Quantile Loss используется, когда важны доверительные интервалы или асимметричная оценка рисков, например в финансовом прогнозировании или страховании. Бинарная классификация Стандартно применяют Binary Cross-Entropy (BCE), которая хорошо калибрует вероятности и подходит для градиентного спуска. При сильном дисбалансе классов полезна Focal Loss, где параметр α (alpha) регулирует вес классов, а γ (gamma) фокусирует обучение на сложных примерах. Hinge Loss применяют, когда важна максимизация отступа (margin) между классами без необходимости предсказывать вероятности. Многоклассовая классификация Как правило используется функция потерь Categorical Cross-Entropy с one-hot метками. При больших объемах данных и большом числе классов можно рассмотреть Sparse Categorical Cross-Entropy - она принимает целочисленные метки и экономит память. KL Divergence актуальна, когда целевое распределение «мягкое» (soft labels), например в knowledge distillation или multi-label задачах, где метки отражают относительную принадлежность к классам. Специализированные функции потерь В задачах, где метрики, оценивающие точность по отдельным точкам или пикселям не отражают реальное качество модели, используют специальные (кастомные) функции потерь: Dice Loss — для сегментации с маленькими объектами, особенно в медицинской визуализации; IoU Loss — для оценки глобального перекрытия масок; Contrastive Loss и Triplet Loss — для формирования embedding-пространств в задачах similarity learning и метрического обучения, где важны относительные расстояния между объектами. Заключение Функция потерь — это ключевой инструмент, связывающий бизнес-цель и процесс обучения модели. Правильный выбор функции определяет, какие ошибки модель будет минимизировать в приоритетном порядке и на какие аспекты задачи она будет ориентироваться. Глубокое понимание свойств различных функций потерь позволяет создавать модели машинного обучения, которые не просто оптимизируют формальные метрики, а действительно решают задачу эффективно, учитывая специфику данных и требования бизнеса. ### Долгосрочное прогнозирование динамики облигаций с помощью ансамбля статистических моделей Долгосрочное прогнозирование динамики облигаций — одна из самых сложных задач финансовой аналитики. На горизонтах в 12 месяцев и более даже относительно стабильные рынки перестают вести себя «гладко»: усиливается влияние макроэкономических факторов, процентных ставок, медленных структурных сдвигов и поведенческих эффектов, что приводит к асимметричным реакциям на риски. В таких условиях многие модели машинного обучения формируют смещенные или чрезмерно уверенные прогнозы, а попытка полагаться на единственный метод делает оценку особенно чувствительной к смене рыночного режима. В этой статье я расскажу, как использовать ансамбль статистических моделей — подход, который объединяет несколько независимых методов и позволяет компенсировать их индивидуальные ограничения. Такой ансамбль повышает устойчивость и точность долгосрочного прогноза, улучшает интерпретируемость результатов и снижает риск переобучения, характерный для одиночных моделей. Архитектура ансамбля статистических моделей Ансамбль статистических моделей для временных рядов объединяет прогнозы нескольких базовых алгоритмов через взвешенное или простое усреднение. Такая архитектура снижает дисперсию ошибок и повышает устойчивость к выбросам в данных. Для прогнозирования доходности облигаций я буду использовать комбинацию трех моделей: AutoETS, AutoARIMA и SeasonalNaive. Каждая модель захватывает разные паттерны в временном ряде. Компоненты ансамбля AutoETS (Automatic Exponential Smoothing) автоматически выбирает оптимальную конфигурацию модели экспоненциального сглаживания из 30 возможных вариантов. Модель учитывает три компонента временного ряда: Уровень (level); Тренд (trend); Сезонность (seasonality). Параметр model='ZZZ' активирует автоматический подбор типа каждого компонента: аддитивный, мультипликативный или отсутствующий. Для доходности облигаций ETS эффективно захватывает плавные изменения уровня и краткосрочные колебания. AutoARIMA автоматически определяет порядки авторегрессии (p), интегрирования (d) и скользящего среднего (q) через минимизацию информационного критерия Акаике (AIC). Модель подходит для временных рядов с явным трендом и автокорреляцией. В контексте облигаций ARIMA хорошо работает на периодах постепенного изменения процентных ставок, но может давать завышенные прогнозы при резких разворотах монетарной политики. SeasonalNaive использует значения из предыдущего сезонного периода как прогноз для будущего. Для месячных данных с season_length=12 модель копирует значения годичной давности. Несмотря на простоту, SeasonalNaive служит надежным бейзлайном и защищает ансамбль от переобучения более сложных моделей. На рынке облигаций сезонность проявляется через налоговые циклы, квартальные аукционы Казначейства и паттерны ребалансировки портфелей институциональных инвесторов. Метод агрегации прогнозов Агрегация прогнозов в ансамбле реализуется через простое среднее арифметическое (Simple Average) с равными весами для каждой модели. Для трех компонентов это означает вес 1/3 для AutoETS, 1/3 для AutoARIMA и 1/3 для SeasonalNaive. Равновзвешенное усреднение не требует обучения мета-модели и защищает от переподгонки на исторических данных. Можно экспериментировать с весами. Хотя исследования показывают, что простое усреднение часто превосходит сложные схемы взвешивания, особенно при ограниченном объеме обучающей выборки. Для временных рядов доходности облигаций длиной 5-10 лет оптимизация весов на валидационной выборке приводит к нестабильности из-за изменения режимов монетарной политики. Равные веса обеспечивают устойчивость прогноза к структурным сдвигам: если одна модель ошибается на резком развороте тренда, две другие компенсируют ее вклад. Альтернативные методы агрегации включают: Взвешивание по обратной ошибке на валидации (Inverse Error Weighting) - присваивает больший вес моделям с меньшей RMSE на тестовом окне, но требует дополнительного разбиения данных; Стекинг с мета-регрессией - обучает линейную регрессию на прогнозах базовых моделей. Прогнозы могут стать точнее, однако растут риски переобучения в краткосрочных тенденциях. Для доходности облигаций с частыми режимными сдвигами простое усреднение - оптимальный выбор баланса между точностью и устойчивостью. Реализация на данных US Treasury 10Y Реализация ансамбля статистических моделей для прогнозирования доходности 10-летних казначейских облигаций США включает три этапа: загрузку и предобработку исторических данных, обучение компонентов ансамбля и визуализацию результатов. Я буду использовать библиотеку statsforecast - они заточена под быстрое и эффективное обучение моделей временных рядов и библиотеку yfinance для получения рыночных данных. !pip install statsforecast Подготовка данных Исторические данные по доходности 10-летних облигаций США загружаются через тикер ^TNX (CBOE Interest Rate 10-Year T-Note). Данные содержат дневные значения доходности в процентах годовых. Для прогнозирования на месячном горизонте дневные данные агрегируются в месячные через среднее значение за месяц. import pandas as pd import matplotlib.pyplot as plt import yfinance as yf from statsforecast import StatsForecast from statsforecast.models import AutoETS, AutoARIMA, SeasonalNaive import warnings warnings.filterwarnings('ignore') # Загрузка данных по доходности 10-летних облигаций США ticker = yf.Ticker("^TNX") df_daily = ticker.history(period="10y") # Проверка на MultiIndex if isinstance(df_daily.columns, pd.MultiIndex): df_daily.columns = df_daily.columns.droplevel(1) # Преобразование в месячные данные df_daily['Date'] = df_daily.index df_monthly = df_daily.resample('MS', on='Date').agg({'Close': 'mean'}).reset_index() df_monthly.columns = ['Date', 'Yield'] # Удаление пропусков df_monthly = df_monthly.dropna() print(f"Загружено {len(df_monthly)} месячных наблюдений") print(f"Период: {df_monthly['Date'].min()} - {df_monthly['Date'].max()}") print(f"Доходность: min={df_monthly['Yield'].min():.2f}%, max={df_monthly['Yield'].max():.2f}%") plt.figure(figsize=(12, 6)) plt.plot(df_monthly['Date'], df_monthly['Yield']) plt.title("Доходность 10-летних облигаций США (месячные данные)") plt.xlabel("Дата") plt.ylabel("Доходность, %") plt.grid(True) plt.tight_layout() plt.show() Загружено 121 месячных наблюдений Период: 2015-11-01 00:00:00-05:00 - 2025-11-01 00:00:00-05:00 Доходность: min=0.62%, max=4.80% Рис. 1: График доходности 10-летних облигаций США за период с 2015-2025, месячные данные Код загружает 10-летнюю историю дневных данных и агрегирует их в месячные через среднее значение доходности за месяц. Проверка на MultiIndex необходима из-за особенностей работы yfinance при загрузке нескольких тикеров. Удаление пропусков обеспечивает непрерывность временного ряда для корректной работы моделей ARIMA и ETS. Результат: датафрейм с двумя столбцами Date (первый день месяца) и Yield (средняя доходность в процентах). Обучение ансамбля Функция forecast_bonds_ensemble реализует обучение ансамбля из 3-х статистических моделей на временном ряде доходности облигаций. Ключевые параметры: season_length=12 для захвата годовой сезонности в месячных данных и разделение на обучающую и тестовую выборки для валидации точности моделей. def forecast_bonds_ensemble(df, target_column='Yield', periods=24, train_test_split=0.8): """ Прогноз доходности облигаций на основе ансамбля трех моделей Parameters: ----------- df : DataFrame Датафрейм с колонками Date и Yield target_column : str Название столбца с целевой переменной periods : int Количество месяцев для прогноза train_test_split : float Доля данных для обучения (остальное для валидации) Returns: -------- DataFrame с историческими данными и прогнозом dict с метриками качества """ df_forecast = df.copy() df_forecast['Date'] = pd.to_datetime(df_forecast['Date']) df_forecast = df_forecast.sort_values('Date').reset_index(drop=True) # Разделение на обучение и валидацию n_train = int(len(df_forecast) * train_test_split) df_train = df_forecast.iloc[:n_train].copy() df_test = df_forecast.iloc[n_train:].copy() print(f"Обучающая выборка: {len(df_train)} месяцев ({df_train['Date'].min()} - {df_train['Date'].max()})") print(f"Тестовая выборка: {len(df_test)} месяцев ({df_test['Date'].min()} - {df_test['Date'].max()})") # Подготовка данных для StatsForecast sf_data = pd.DataFrame({ 'unique_id': 'US_10Y', 'ds': df_train['Date'], 'y': df_train[target_column].values }) # Создание ансамбля из трех моделей models = [ AutoETS(season_length=12, model='ZZZ'), AutoARIMA(season_length=12), SeasonalNaive(season_length=12) ] model = StatsForecast( models=models, freq='MS', n_jobs=-1 ) # Обучение моделей print("\nОбучение моделей...") model.fit(sf_data) # Прогноз на горизонте валидации + будущего forecast_horizon = len(df_test) + periods forecast_df = model.predict(h=forecast_horizon) # Вычисление ансамблевого прогноза (простое среднее) forecast_df['Ensemble'] = forecast_df[['AutoETS', 'AutoARIMA', 'SeasonalNaive']].mean(axis=1) # Добавление дат прогноза last_train_date = df_train['Date'].max() forecast_dates = pd.date_range( start=last_train_date + pd.DateOffset(months=1), periods=forecast_horizon, freq='MS' ) forecast_df['Date'] = forecast_dates # Разделение прогноза на валидационный и будущий периоды forecast_df['Type'] = ['Validation'] * len(df_test) + ['Forecast'] * periods # Объединение исторических данных и прогноза df_train['Type'] = 'Historical' df_train['AutoETS'] = df_train[target_column] df_train['AutoARIMA'] = df_train[target_column] df_train['SeasonalNaive'] = df_train[target_column] df_train['Ensemble'] = df_train[target_column] df_result = pd.concat([ df_train[['Date', target_column, 'Type', 'AutoETS', 'AutoARIMA', 'SeasonalNaive', 'Ensemble']], forecast_df[['Date', 'Type', 'AutoETS', 'AutoARIMA', 'SeasonalNaive', 'Ensemble']].assign(**{target_column: None}) ], ignore_index=True) # Вычисление метрик на валидационной выборке validation_mask = forecast_df['Type'] == 'Validation' y_true = df_test[target_column].values metrics = {} for model_name in ['AutoETS', 'AutoARIMA', 'SeasonalNaive', 'Ensemble']: y_pred = forecast_df.loc[validation_mask, model_name].values rmse = np.sqrt(np.mean((y_true - y_pred) ** 2)) mae = np.mean(np.abs(y_true - y_pred)) mape = np.mean(np.abs((y_true - y_pred) / y_true)) * 100 metrics[model_name] = { 'RMSE': rmse, 'MAE': mae, 'MAPE': mape } print("\n=== Метрики на валидационной выборке ===") for model_name, model_metrics in metrics.items(): print(f"{model_name:15} | RMSE: {model_metrics['RMSE']:.4f} | MAE: {model_metrics['MAE']:.4f} | MAPE: {model_metrics['MAPE']:.2f}%") return df_result, metrics # Обучение ансамбля import numpy as np df_with_forecast, metrics = forecast_bonds_ensemble( df_monthly, target_column='Yield', periods=24, train_test_split=0.8 ) print(f"\nПрогноз завершен. Итоговый датафрейм: {len(df_with_forecast)} строк") Обучающая выборка: 96 месяцев (2015-11-01 00:00:00-05:00 - 2023-10-01 00:00:00-05:00) Тестовая выборка: 25 месяцев (2023-11-01 00:00:00-05:00 - 2025-11-01 00:00:00-05:00) Обучение моделей... === Метрики на валидационной выборке === AutoETS | RMSE: 1.1671 | MAE: 1.1374 | MAPE: 27.05% AutoARIMA | RMSE: 0.8558 | MAE: 0.8285 | MAPE: 19.78% SeasonalNaive | RMSE: 0.6429 | MAE: 0.5942 | MAPE: 13.86% Ensemble | RMSE: 0.6124 | MAE: 0.5283 | MAPE: 12.79% Прогноз завершен. Итоговый датафрейм: 145 строк Функция разделяет данные на обучающую (80%) и тестовую (20%) выборки для валидации точности моделей. Компоненты ансамбля настроены с season_length=12 для захвата годовой сезонности в доходности облигаций. Параметр freq='MS' указывает на месячную частоту данных с датами на начало месяца (Month Start). Обучение всех моделей происходит параллельно по всем ядрам CPU через n_jobs=-1. После обучения модели генерируют прогнозы на горизонте тестовой выборки плюс 24 месяца в будущее. Ансамблевый прогноз вычисляется как простое среднее трех базовых моделей. Функция возвращает датафрейм с историческими данными, валидационными и будущими прогнозами, а также словарь с метриками RMSE, MAE и MAPE для каждой модели и ансамбля. Метрики вычисляются только на валидационной части, где известны фактические значения доходности: RMSE (Root Mean Squared Error) измеряет среднеквадратичную ошибку прогноза; MAE (Mean Absolute Error) показывает среднюю абсолютную ошибку прогноза; MAPE (Mean Absolute Percentage Error) выражает ошибку в процентах от фактического значения доходности. Визуализация результатов Визуализация прогнозов ансамбля позволяет оценить поведение моделей на исторических данных и адекватность будущих прогнозов. Функция строит график с разделением на три зоны: исторические данные (черный), валидационный период (прогнозы vs факт) и будущие прогнозы (фиолетовый). import matplotlib.pyplot as plt import matplotlib.dates as mdates def plot_bond_forecast(df, target_column='Yield', save_path="bond_forecast.jpg"): """ Визуализация прогноза доходности облигаций Parameters: ----------- df : DataFrame Датафрейм с результатами прогнозирования target_column : str Название столбца с целевой переменной save_path : str Путь для сохранения графика """ df_plot = df.copy() df_plot['Date'] = pd.to_datetime(df_plot['Date']) # Разделение на исторические данные, валидацию и прогноз historical = df_plot[df_plot['Type'] == 'Historical'] validation = df_plot[df_plot['Type'] == 'Validation'] forecast = df_plot[df_plot['Type'] == 'Forecast'] fig, ax = plt.subplots(1, 1, figsize=(14, 6)) # Исторические данные ax.plot(historical['Date'], historical[target_column], color='black', linewidth=1.5, label='Исторические данные') # Валидация: факт vs прогноз ансамбля if len(validation) > 0: # Фактические значения на валидации из исходного df_monthly validation_actual = df_monthly[ (df_monthly['Date'] >= validation['Date'].min()) & (df_monthly['Date'] <= validation['Date'].max()) ].sort_values('Date') # Соединительная линия от истории к валидации (прогноз) connect_dates = [historical['Date'].iloc[-1]] + validation['Date'].tolist() connect_ensemble = [historical['Ensemble'].iloc[-1]] + validation['Ensemble'].tolist() ax.plot(connect_dates, connect_ensemble, color='#A23B72', linewidth=1.5, alpha=0.7, label='Прогноз ансамбля (валидация)') # Фактические значения на валидации (темно-синяя линия) ax.plot(validation_actual['Date'], validation_actual[target_column], color='#00008B', linewidth=1.5, label='Факт (валидация)', zorder=5) # Будущий прогноз if len(forecast) > 0: last_date = validation['Date'].iloc[-1] if len(validation) > 0 else historical['Date'].iloc[-1] last_value = validation['Ensemble'].iloc[-1] if len(validation) > 0 else historical['Ensemble'].iloc[-1] connect_dates = [last_date] + forecast['Date'].tolist() connect_ensemble = [last_value] + forecast['Ensemble'].tolist() ax.plot(connect_dates, connect_ensemble, color='#A23B72', linewidth=2, label='Прогноз ансамбля (будущее)') # Разделительные линии if len(validation) > 0: ax.axvline(validation['Date'].iloc[0], color='gray', linestyle=':', linewidth=1, alpha=0.5) if len(forecast) > 0: ax.axvline(forecast['Date'].iloc[0], color='gray', linestyle='--', linewidth=1, alpha=0.5) ax.set_title('Прогноз доходности US Treasury 10Y: ансамбль', fontsize=12, pad=10) ax.set_ylabel('Доходность, %', fontsize=10) ax.set_xlabel('Дата', fontsize=10) ax.grid(True, alpha=0.3, linewidth=0.5) ax.legend(fontsize=9, loc='best') ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m')) ax.xaxis.set_major_locator(mdates.YearLocator()) plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right') plt.tight_layout() fig.savefig(save_path, dpi=150, format="jpg", bbox_inches="tight") plt.show() print(f"График сохранен: {save_path}") # Построение графиков plot_bond_forecast( df_with_forecast, target_column='Yield', save_path="bond_yield_forecast.jpg" ) Рис. 2: Прогноз доходности 10-летних казначейских облигаций США. График демонстрирует качество работы ансамбля на исторических данных, валидационном периоде и будущем прогнозе. Сопоставление темно-синей линии (факт) с фиолетовой (прогноз ансамбля) на валидационном участке позволяет визуально оценить точность модели. Продолжение фиолетовой линии вправо показывает прогноз на 24 месяца вперед с сохранением выявленных тенденций Оценка качества прогнозов Оценка качества прогнозов ансамбля включает количественное сравнение с базовыми моделями через стандартные метрики точности и анализ устойчивости прогнозов к изменениям рыночных условий. Валидация проводится на отложенной выборке, которая не участвовала в обучении моделей. Сравнение ансамбля с отдельными моделями на валидационной выборке показывает снижение ошибок прогноза через усреднение базовых компонентов: AutoETS: RMSE = 1.17%, MAE = 1.14%, MAPE = 27.05% AutoARIMA: RMSE = 0.86%, MAE = 0.83%, MAPE = 19.78% SeasonalNaive: RMSE = 0.64%, MAE = 0.59%, MAPE = 13.86% Ensemble: RMSE = 0.61%, MAE = 0.53%, MAPE = 12.79% Ансамбль снижает RMSE на 5% относительно лучшей отдельной модели (SeasonalNaive) и на 29% относительно худшей (AutoETS). MAPE ансамбля на 1.07 процентных пункта ниже SeasonalNaive и на 6.99 процентных пункта ниже AutoARIMA. MAE улучшается на 11% по сравнению с SeasonalNaive. SeasonalNaive неожиданно демонстрирует лучшие результаты среди отдельных моделей на валидационной выборке. Это указывает на наличие устойчивой годовой сезонности в доходности 10-летних казначейских облигаций США, которую сложные модели (AutoETS, AutoARIMA) переусложняют через подгонку под краткосрочные флуктуации. Простое копирование значений годичной давности оказывается более надежным базовым прогнозом, чем авторегрессионные и экспоненциальные модели. Ансамблевое усреднение улучшает даже лучшую базовую модель за счет компенсации ошибок на разных участках временного ряда. AutoARIMA переоценивает резкие изменения доходности, AutoETS недооценивает долгосрочные тренды, SeasonalNaive игнорирует отклонения от годового цикла. Комбинация трех подходов с равными весами балансирует эти эффекты и снижает дисперсию ошибок на 5-11% относительно отдельных моделей. Ограничения подхода и направления улучшений Ансамбль статистических моделей хорошо подходит для прогнозирования доходности облигаций на горизонте 12–24 месяцев, особенно в условиях плавных изменений процентных ставок. Однако у подхода есть ограничения: он хуже справляется со структурными сдвигами в монетарной политике и не учитывает влияние ключевых макроэкономических факторов. Повысить точность и устойчивость таких прогнозов можно за счет расширения ансамбля — например, добавив модели машинного обучения и экзогенные признаки. Варианты расширения ансамбля Добавление моделей машинного обучения расширяет возможности ансамбля через нелинейное моделирование и автоматическое выделение признаков из временного ряда. Так, модели LightGBM и XGBoost эффективны для прогнозирования временных рядов с созданием лаговых признаков (значения доходности за последние 1, 3, 6, 12 месяцев), скользящих статистик (среднее, медиана, стандартное отклонение за окно 3-6 месяцев) и календарных признаков (месяц, квартал, год). Градиентный бустинг захватывает нелинейные зависимости между лагами и текущей доходностью, которые недоступны для ARIMA и ETS. Можно также рассмотреть нейросетевые модели для анализа временных рядов. Речь идет об архитектурах LSTM (Long Short-Term Memory) и Temporal Fusion Transformer. LSTM хорошо справляется с улавливанием долгосрочных зависимостей в данных и обычно работает с входным окном в 50–100 предыдущих точек. Типичная конфигурация модели состоит из 2–3 слоев LSTM с 64–128 нейронами, dropout на уровне 0.2–0.3 для предотвращения переобучения. Добавление нейросетевых моделей в ансамбль целесообразно при наличии длинной истории (10+ лет месячных данных или 3+ года дневных данных) и вычислительных ресурсов для обучения. Веса нейросетевых компонентов в ансамбле назначаются через валидацию или мета-модель стекинга. Гибридный ансамбль (статистика + бустинг + нейросети) снижает RMSE на 15-20% относительно базового статистического подхода, но увеличивает время обучения в 5-10 раз. Обогащение признаками В качестве дополнительных признаков стоит в первую очередь рассмотреть макроэкономические индикаторы. Они влияют на доходность облигаций через механизм формирования инфляционных ожиданий и решений центрального банка: Инфляция (CPI, PCE) — определяет реальную доходность облигаций и ожидания по ставкам ФРС; Безработица (unemployment rate) — индикатор состояния экономики, влияет на решения ФРС по процентным ставкам; Динамика ВВП (GDP growth) — отражает общий экономический рост и спрос на капитал, эффективна для долгосрочных прогнозов (12+ месяцев). Данные денежного рынка предоставляют высокочастотную информацию о ожиданиях участников: Ставки межбанковского кредитования (SOFR, EFFR) — отражают краткосрочную стоимость капитала; Спреды долгосрочных и краткосрочных ставок (10Y-2Y, 10Y-3M) — сигнализируют о ожиданиях рецессии при инверсии кривой; Кредитные спреды (разница между корпоративными и казначейскими облигациями) — отражают восприятие риска и бегство в качество. Технические индикаторы волатильности захватывают краткосрочную неопределенность: Индекс MOVE — измеряет ожидаемую волатильность доходности на горизонте 30 дней; Скользящие средние доходности (20, 50, 200 дней) — индикаторы смены тренда через пересечения; Объемы торгов — аномально высокие значения предшествуют значительным движениям доходности. Комплексное обогащение может включать до 15-25 экзогенных переменных. Модели машинного обучения (LightGBM, XGBoost) автоматически выбирают значимые признаки через механизм feature importance. Статистические модели (ARIMAX, Prophet) требуют ручного отбора 3-5 ключевых признаков для избежания переобучения. Выводы Ансамбль из AutoETS, AutoARIMA и SeasonalNaive показал себя неплохо на реальных финансовых данных. Продемонстрированный подход позволяет уменьшить ошибку прогноза на 5–11% по сравнению с каждой моделью по отдельности. Тем не менее сами метрики (RMSE = 0.61%, MAE = 0.53%, MAPE = 12.79%) говорят о том, что модель еще далека от идеала. Графики валидации показывают, что ансамбль работает неравномерно: на одних участках линия прогноза почти совпадает с фактической доходностью, а на других — заметно отстает и расходится с реальными значениями. Повысить точность прогнозов можно, добавив в ансамбль модели машинного обучения, например LightGBM или XGBoost, которые умеют улавливать нелинейные зависимости. Также полезно включить дополнительные признаки: инфляцию, уровень безработицы, спреды процентных ставок и показатели волатильности рынка облигаций. Эти факторы напрямую влияют на доходность US Notes и могут снизить RMSE еще на 15–20%. Также отмечу, что текущая реализация уже достаточно хороша в плане: Обучение ансамбля занимает минуты на обычном ноутбуке, не требовательно к GPU и больших объемам данных; Модели полностью интерпретируемы: можно точно понять, какой компонент (тренд, сезонность или авторегрессия) и в какой степени повлиял на прогноз. Благодаря этому статистический ансамбль становится идеальным бейзлайном для сравнения с более сложными методами и удобным инструментом для быстрых прогнозов доходности облигаций, когда важен баланс между скоростью получения результата и приемлемой точностью. ### Градиенты: от затухания до взрыва. Методы стабилизации Глубокие нейронные сети решают задачи классификации, регрессии и прогнозирования временных рядов. Обучение таких моделей основано на методе обратного распространения ошибки (backpropagation), который вычисляет градиенты функции потерь по параметрам сети. Градиенты определяют направление и величину обновления весов. Чем больше нейронная сеть, тем сложнее контролировать градиенты. В сетях с десятками слоев возникает проблема: градиенты либо экспоненциально уменьшаются (затухают), либо экспоненциально растут (взрываются) по мере распространения от выходного слоя к входному. Затухающие градиенты приводят к остановке обучения ранних слоев сети. Взрывающиеся градиенты вызывают нестабильность оптимизации и переполнение числовых значений. Обе проблемы делают невозможным обучение глубоких архитектур без специальных техник стабилизации. Современные подходы включают архитектурные модификации, методы нормализации, продвинутые оптимизаторы и правильную инициализацию весов. Проблема затухающих градиентов Затухание градиентов проявляется в сетях с множеством последовательных слоев. При обратном распространении градиент умножается на производные функций активации и веса каждого слоя. Если эти производные меньше единицы, произведение быстро стремится к нулю. Механизм затухания Рассмотрим простую сеть с L слоями. Градиент потерь по весам первого слоя вычисляется через цепочку производных: ∂L/∂W₁ = ∂L/∂aₗ · ∂aₗ/∂aₗ₋₁ · ... · ∂a₂/∂a₁ · ∂a₁/∂W₁ где: L — функция потерь; aᵢ — активации i-го слоя; Wᵢ — веса i-го слоя. Каждый член ∂aᵢ/∂aᵢ₋₁ включает производную функции активации. Для сигмоиды σ(x) = 1/(1 + e⁻ˣ) производная равна σ'(x) = σ(x)(1 - σ(x)). Максимальное значение этой производной составляет 0.25 при x = 0. При прохождении через 10 слоев градиент уменьшается в 0.25¹⁰ ≈ 10⁻⁶ раз. Функция tanh дает аналогичный результат. Ее производная tanh'(x) = 1 - tanh²(x) максимальна при x = 0 и равна 1, но быстро падает при отклонении от нуля. В реальных сетях активации редко находятся в точке максимума производной, что усиливает затухание. Последствия для обучения сети Затухающие градиенты блокируют обучение ранних слоев. Веса этих слоев обновляются с шагом, близким к нулю, и остаются практически неизменными. Сеть не может извлекать низкоуровневые признаки из входных данных. Проблема проявляется в нескольких формах: Медленная сходимость — функция потерь снижается крайне медленно, обучение занимает сотни тысяч итераций; Застревание в плохих локальных минимумах — сеть не может выйти из неоптимальной конфигурации весов; Деградация точности — добавление новых слоев ухудшает результат вместо улучшения. Диагностика затухания включает мониторинг норм градиентов по слоям. Если градиенты первых слоев на 3-4 порядка меньше градиентов последних слоев, то это признак проблемы. Визуализация распределения градиентов показывает смещение в сторону нуля для ранних слоев. Проблема взрывающихся градиентов Взрывающиеся градиенты возникают при обратном распространении через глубокие сети или рекуррентные архитектуры. Градиент экспоненциально растет, достигая значений, которые вызывают переполнение типа данных или делают обучение нестабильным. Механизм взрыва Взрыв градиентов происходит, когда произведение производных превышает единицу. В рекуррентных сетях (RNN, LSTM) одна и та же матрица весов W применяется на каждом временном шаге. Градиент по весам пропорционален произведению W^T на длину последовательности T. Для последовательности длиной 100 шагов и матрицы весов с максимальным собственным значением 1.1 градиент увеличивается в 1.1¹⁰⁰ ≈ 10⁴ раз. Это приводит к числовой нестабильности. В обычных feed-forward сетях взрыв возникает при неправильной инициализации весов. Если веса инициализированы большими значениями, произведение градиентов быстро растет. Функции активации ReLU усиливают эффект: их производная равна 1 для положительных входов, не ограничивая рост градиента. Признаки проблемы Взрывающиеся градиенты легко обнаружить по поведению метрик обучения: Значения функции потерь становятся NaN или Inf через несколько итераций; Веса модели принимают экстремальные значения (10⁶ и выше); Функция потерь резко возрастает вместо снижения; Норма градиентов превышает порог 10-100 (зависит от архитектуры). Логирование норм градиентов на каждой итерации позволяет отследить момент начала проблемы. Резкий скачок нормы градиента на 2-3 порядка указывает на взрыв. В PyTorch проверка на NaN выполняется через torch.isnan() для тензоров весов и градиентов. Взрывающиеся градиенты чаще встречаются в задачах обработки последовательностей с длинными зависимостями. Временные ряды финансовых данных, текстовые корпуса и аудио требуют особого внимания к стабильности градиентов. Архитектурные решения Модификации архитектуры нейронной сети устраняют проблемы градиентов на фундаментальном уровне. Эти подходы изменяют поток информации и градиентов через сеть, обеспечивая стабильное обучение глубоких моделей. Residual connections Residual connections (skip connections) добавляют прямой путь для градиентов от выходных слоев к входным. Вместо обучения функции H(x) блок обучает остаток: F(x) = H(x) - x Выход блока вычисляется как F(x) + x. Преимущество residual connections в том, что градиент может проходить напрямую через сложение, минуя нелинейные преобразования. Градиент по входу блока: ∂L/∂x = ∂L/∂(F(x) + x) = ∂L/∂F · ∂F/∂x + ∂L/∂x Второе слагаемое ∂L/∂x обеспечивает прямой путь для градиента без умножения на производные активаций. Это предотвращает затухание даже в сетях со 100+ слоями. Архитектура ResNet применяет residual connections каждые 2-3 слоя. Базовый residual блок содержит два сверточных слоя с batch normalization и ReLU активацией. Identity mapping (прямое копирование входа) работает при совпадении размерностей. При изменении размерности используется projection shortcut — линейное преобразование через свертку 1×1. import torch.nn as nn class ResidualBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1) self.bn1 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(out_channels) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride), nn.BatchNorm2d(out_channels) ) def forward(self, x): residual = x out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += self.shortcut(residual) out = self.relu(out) return out Код реализует residual блок с двумя свертками и skip connection. Shortcut адаптируется к изменению размерности через projection. Активация ReLU применяется после сложения, сохраняя нелинейность. Batch Normalization Batch Normalization нормализует активации каждого слоя по батчу примеров. Это стабилизирует распределение входов последующих слоев и ограничивает рост градиентов. Для батча активаций x нормализация вычисляется как: x̂ = (x - μ_B) / √(σ²_B + ε) y = γx̂ + β Здесь: μ_B — среднее по батчу; σ²_B — дисперсия по батчу; ε — малая константа для численной стабильности (10⁻⁵); γ, β — обучаемые параметры масштаба и сдвига. Нормализация приводит активации к нулевому среднему и единичной дисперсии. Параметры γ и β позволяют сети восстановить оптимальное распределение активаций. Без них нормализация могла бы ограничить выразительность модели. Batch Normalization решает три задачи: Уменьшает internal covariate shift — изменение распределения активаций в процессе обучения; Позволяет использовать более высокие learning rate без риска расходимости; Снижает зависимость от инициализации весов. В сверточных сетях нормализация применяется по каналам. Для батча размером (N, C, H, W) статистики вычисляются по размерностям N, H, W отдельно для каждого канала C. В полносвязных слоях нормализация идет по признакам. Batch Normalization добавляет небольшие вычислительные затраты, но существенно ускоряет сходимость. Модели обучаются в 2-3 раза быстрее при тех же гиперпараметрах. Layer Normalization Layer Normalization нормализует активации по признакам одного примера, а не по батчу. Метод крайне популярен в архитектуре рекуррентных сетей и трансформеров, где размер батча может быть малым или изменяться. Для вектора активаций x нормализация: x̂ = (x - μ) / √(σ² + ε) y = γx̂ + β Здесь μ и σ² вычисляются по всем элементам вектора x одного примера. Layer Normalization не зависит от размера батча и работает одинаково на обучении и inference. В трансформерах Layer Normalization применяется после каждого sub-layer (self-attention и feed-forward). Это стабилизирует обучение моделей с десятками слоев. Архитектура BERT использует Layer Normalization перед каждым блоком (pre-LN) вместо после (post-LN), что улучшает стабильность. Выбор между Batch и Layer Normalization зависит от архитектуры: Batch Normalization — для CNN на изображениях с большими батчами; Layer Normalization — для RNN, LSTM, трансформеров и малых батчей; Group Normalization — компромисс для средних батчей в свертках. Layer Normalization показывает лучшие результаты в задачах обработки последовательностей. Batch Normalization превосходит в компьютерном зрении при батчах от 32 примеров. Методы оптимизации Оптимизаторы и техники регуляризации градиентов контролируют процесс обновления весов. Эти методы применяются поверх архитектурных решений и дополняют их. Gradient clipping Gradient clipping ограничивает норму градиента порогом, предотвращая взрыв. Если норма градиента превышает threshold, градиент масштабируется пропорционально. Для градиента g и порога θ обрезанный градиент: ĝ = g · θ / ||g||, если ||g|| > θ ĝ = g, если ||g|| ≤ θ Здесь ||g|| — L2-норма градиента по всем параметрам модели. Clipping сохраняет направление градиента, изменяя только величину шага. В библиотеке PyTorch реализованы две функции clipping: import torch.nn as nn # Clipping по норме nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # Clipping по значению каждого элемента nn.utils.clip_grad_value_(model.parameters(), clip_value=0.5) Первый метод ограничивает общую норму градиента. Второй обрезает каждый элемент градиента независимо. Clipping по норме предпочтителен, так как сохраняет соотношение между градиентами разных параметров. Выбор порога зависит от задачи: Для рекуррентных сетей типичные значения: 1.0-5.0; Для feed-forward сетей: 5.0-10.0. Слишком низкий порог замедляет обучение, слишком высокий не предотвращает взрыв. Gradient clipping применяется перед шагом оптимизатора: optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() Порядок операций имеет решающее значение: clipping выполняется после вызова backward() и перед step(). Это обеспечивает передачу в оптимизатор уже обрезанных градиентов. Адаптивные оптимизаторы Адаптивные оптимизаторы автоматически настраивают learning rate для каждого параметра на основе истории градиентов. Это смягчает проблему взрывающихся градиентов и ускоряет сходимость. Adam (Adaptive Moment Estimation) поддерживает экспоненциально взвешенные средние первого и второго моментов градиента. Вот как вычисляется обновление параметров: m_t = β₁m_{t-1} + (1 - β₁)g_t v_t = β₂v_{t-1} + (1 - β₂)g_t² θ_t = θ_{t-1} - α · m̂_t / (√v̂_t + ε) Здесь: m_t — первый момент (среднее градиента); v_t — второй момент (дисперсия градиента); β₁, β₂ — коэффициенты затухания (типично 0.9 и 0.999); α — learning rate; m̂_t, v̂_t — bias-corrected моменты; ε — константа стабильности (10⁻⁸). Деление на √v̂_t нормализует шаг обновления. Параметры с большими градиентами получают меньший эффективный learning rate, параметры с малыми градиентами — больший. Это предотвращает как взрыв, так и затухание. AdamW добавляет decoupled weight decay — регуляризацию, отделенную от градиентов. Это улучшает обобщение модели: optimizer = torch.optim.AdamW( model.parameters(), lr=1e-3, betas=(0.9, 0.999), weight_decay=0.01 ) Weight decay применяется напрямую к весам, не влияя на адаптацию learning rate. Это дает лучший контроль над регуляризацией. Сравнение оптимизаторов: SGD с momentum — базовый выбор, требует тщательной настройки learning rate; Adam — универсальный оптимизатор, работает из коробки с learning rate 1e-3; AdamW — улучшенная версия Adam с правильной регуляризацией; RMSprop — альтернатива Adam для рекуррентных сетей; AdaGrad — для задач с разреженными градиентами. AdamW показывает лучшие результаты в большинстве задач глубокого обучения. Для файнтюнинга предобученных моделей предпочтителен SGD с малым learning rate (1e-4 - 1e-5) и momentum 0.9. Инициализация весов Правильная инициализация весов предотвращает затухание и взрыв градиентов с первых итераций обучения. Случайная инициализация должна поддерживать дисперсию активаций и градиентов постоянной при прохождении через слои. Xavier/Glorot инициализация Инициализация Xavier разработана для функций активации с симметричной производной (tanh, sigmoid). Веса инициализируются из равномерного или нормального распределения с дисперсией, зависящей от числа входов и выходов слоя. Для равномерного распределения: W ~ U(-√(6/(n_in + n_out)), √(6/(n_in + n_out))) Для нормального распределения: W ~ N(0, √(2/(n_in + n_out))) Здесь: n_in — количество входных нейронов слоя; n_out — количество выходных нейронов слоя. Дисперсия активаций остается примерно постоянной при прямом проходе. Дисперсия градиентов сохраняется при обратном проходе. Это обеспечивает стабильное обучение сетей с 10-20 слоями. В PyTorch инициализация Xavier доступна через функции: import torch.nn as nn # Xavier равномерная nn.init.xavier_uniform_(layer.weight) # Xavier нормальная nn.init.xavier_normal_(layer.weight) Инициализация Xavier оптимальна для tanh активации. Для sigmoid результаты хуже из-за асимметрии функции и насыщения в крайних точках. He инициализация He инициализация (Kaiming initialization) разработана специально для ReLU и ее вариантов. ReLU обнуляет половину активаций, что меняет дисперсию сигнала. He инициализация компенсирует это удвоением дисперсии весов. Для нормального распределения: W ~ N(0, √(2/n_in)) Для равномерного распределения: W ~ U(-√(6/n_in), √(6/n_in)) Коэффициент 2 вместо 1 учитывает, что ReLU пропускает только положительные значения. Дисперсия активаций сохраняется при прямом проходе через ReLU слои. # He нормальная (для ReLU) nn.init.kaiming_normal_(layer.weight, mode='fan_in', nonlinearity='relu') # He равномерная nn.init.kaiming_uniform_(layer.weight, mode='fan_in', nonlinearity='relu') Параметр mode определяет, какое количество нейронов использовать для расчета дисперсии: fan_in — по входным нейронам (рекомендуется для обучения); fan_out — по выходным нейронам; fan_avg — среднее между входными и выходными. Параметр nonlinearity указывает функцию активации. Для leaky ReLU с параметром α: W ~ N(0, √(2/(1 + α²) · n_in)) He инициализация стала стандартом для современных сверточных сетей с ReLU. Она используется в ResNet, VGG, EfficientNet и других архитектурах. Выбор инициализации по функции активации: ReLU, Leaky ReLU, PReLU — He инициализация; tanh — Xavier инициализация; sigmoid — Xavier с осторожностью (лучше избегать sigmoid в скрытых слоях); SELU — LeCun инициализация (похожа на Xavier, но с n_in вместо среднего). Правильная инициализация имеет ключевое значение для сетей без batch normalization. В архитектурах с batch normalization ее влияние уменьшается, однако на ранних этапах обучения корректная инициализация все равно остается важной. Практическая реализация Комбинация описанных техник обеспечивает стабильное обучение глубоких моделей. Рассмотрим реализацию residual сети с мониторингом градиентов. import torch import torch.nn as nn import torch.optim as optim class ResNet(nn.Module): def __init__(self, num_classes=10): super().__init__() self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # Residual blocks self.layer1 = self._make_layer(64, 64, num_blocks=2) self.layer2 = self._make_layer(64, 128, num_blocks=2, stride=2) self.layer3 = self._make_layer(128, 256, num_blocks=2, stride=2) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.fc = nn.Linear(256, num_classes) # He initialization self._init_weights() def _make_layer(self, in_channels, out_channels, num_blocks, stride=1): layers = [] layers.append(ResidualBlock(in_channels, out_channels, stride)) for _ in range(1, num_blocks): layers.append(ResidualBlock(out_channels, out_channels)) return nn.Sequential(*layers) def _init_weights(self): for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') elif isinstance(m, nn.BatchNorm2d): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) def forward(self, x): x = self.maxpool(self.relu(self.bn1(self.conv1(x)))) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.avgpool(x) x = torch.flatten(x, 1) x = self.fc(x) return x # Функция для мониторинга градиентов def monitor_gradients(model): total_norm = 0.0 layer_norms = {} for name, param in model.named_parameters(): if param.grad is not None: param_norm = param.grad.data.norm(2) total_norm += param_norm.item() ** 2 layer_norms[name] = param_norm.item() total_norm = total_norm ** 0.5 return total_norm, layer_norms # Training loop с gradient clipping model = ResNet(num_classes=10) optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01) criterion = nn.CrossEntropyLoss() for epoch in range(num_epochs): for inputs, targets in train_loader: optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() # Gradient clipping torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # Мониторинг (выполнять периодически, не на каждой итерации) if iteration % 100 == 0: total_norm, layer_norms = monitor_gradients(model) print(f"Total gradient norm: {total_norm:.4f}") # Проверка на взрыв if total_norm > 10.0: print("Warning: gradient explosion detected") # Проверка на затухание first_layer_norm = layer_norms.get('conv1.weight', 0) last_layer_norm = layer_norms.get('fc.weight', 0) if first_layer_norm > 0 and last_layer_norm / first_layer_norm > 100: print("Warning: potential gradient vanishing") optimizer.step() Код объединяет residual архитектуру, batch normalization, He инициализацию, gradient clipping и адаптивный оптимизатор. Функция monitor_gradients вычисляет L2-норму градиентов по всей модели и отдельно по слоям. Периодический мониторинг позволяет обнаружить проблемы до их критического проявления. Пороги для детекции проблем зависят от архитектуры. Для ResNet с 20-50 слоями норма градиентов 1-5 считается нормальной. Значения выше 10 указывают на начало взрыва. Разница между нормами первого и последнего слоев более чем в 100 раз сигнализирует о затухании. Дополнительные техники стабилизации включают learning rate scheduling и warmup. Linear warmup постепенно увеличивает learning rate от малого значения до целевого за первые 1000-5000 итераций. Это предотвращает резкие обновления весов на начальном этапе: from torch.optim.lr_scheduler import LinearLR, SequentialLR warmup_scheduler = LinearLR(optimizer, start_factor=0.1, total_iters=1000) main_scheduler = CosineAnnealingLR(optimizer, T_max=num_epochs) scheduler = SequentialLR(optimizer, schedulers=[warmup_scheduler, main_scheduler], milestones=[1000]) Warmup необходим при использовании больших значений learning rate (>1e-3) и больших батчей (>256). Без warmup оптимизация может расходиться в первые эпохи. Выводы и рекомендации по стабилизации градиентов Качество и стабильность обучения deep learning моделей напрямую зависят от того, насколько предсказуемо ведут себя градиенты по мере роста глубины сети. Поэтому внимание к их динамике и способность корректировать возникающие отклонения — обязательная часть работы с современными архитектурами. Корректный выбор инициализации, архитектуры, нормализации и оптимизатора играет решающую роль, однако даже при правильной теоретической основе обучение может становиться нестабильным. В таких случаях помогает системный подход к отладке, основанный на постепенной проверке ключевых компонентов модели и наблюдении за поведением градиентов. Стратегия отладки проблем с градиентами: Начать с простой архитектуры (2-3 слоя) и убедиться в стабильном обучении; Постепенно увеличивать глубину, добавляя residual connections каждые 2-3 слоя; Применять batch normalization после каждого сверточного слоя; Использовать He инициализацию для ReLU, Xavier для tanh; Установить gradient clipping с порогом 1-5; Выбрать AdamW с learning rate 1e-3 и weight decay 0.01; Добавить warmup на 1000 итераций при больших learning rates; Мониторить нормы градиентов каждые 100 итераций. Эта последовательность минимизирует риск проблем с градиентами и позволяет быстро локализовать источник нестабильности. Современные фреймворки включают большинство решений по умолчанию. PyTorch автоматически применяет правильную инициализацию при создании слоев. Batch normalization встроена в стандартные архитектуры. Gradient clipping и продвинутые оптимизаторы доступны через простой API. Понимание механизмов проблем градиентов позволяет эффективно использовать эти инструменты и диагностировать нетипичные случаи. ### Методы анализа финансовых деривативов Финансовые деривативы - это опционы, фьючерсы, свопы. Они используются для хеджирования рисков, спекуляций и арбитража. Анализ финансовых деривативов требует сочетания математических моделей, статистических методов и понимания рыночной микроструктуры. Качественный анализ позволяет оценить справедливую цену дериватива, измерить его чувствительность к рыночным факторам и построить эффективные торговые стратегии. Методы анализа деривативов делятся на три направления: Количественные модели ценообразования - определяют теоретическую стоимость инструмента на основе параметров базового актива; Анализ чувствительности позиций - показывает как изменение рыночных условий влияет на стоимость позиции; Оценка рисков портфеля и расчет потенциальных убытков при различных рыночных сценариях. Количественные модели ценообразования Модели ценообразования деривативов базируются на концепции безарбитражного рынка и репликации выплат. Справедливая цена дериватива равна стоимости портфеля базовых активов, который воспроизводит его выплаты. Разные классы деривативов требуют специфических моделей в зависимости от условий исполнения и структуры выплат. Модель Блэка-Шоулза для европейских опционов Модель Блэка-Шоулза — аналитическое решение для оценки европейских опционов на акции. Модель предполагает логнормальное распределение цен базового актива, постоянную волатильность и возможность непрерывного хеджирования. Цена европейского колл-опциона рассчитывается по формуле: C = S₀N(d₁) - Ke⁻ʳᵀN(d₂) где: S₀ — текущая цена базового актива; K — страйк опциона; r — безрисковая процентная ставка; T — время до экспирации; N(x) — функция стандартного нормального распределения; d₁ = [ln(S₀/K) + (r + σ²/2)T] / (σ√T); d₂ = d₁ - σ√T; σ — волатильность базового актива. Цена пут-опциона рассчитывается через put-call паритет или аналогичную формулу с заменой N(d) на N(-d). Модель применима для ликвидных акций с известной волатильностью. Ограничения модели: константная волатильность, отсутствие дивидендов, европейский стиль исполнения. Для американских опционов требуются численные методы. Биномиальная модель для американских опционов Биномиальная модель дискретизирует эволюцию цены базового актива и позволяет оценивать американские опционы с возможностью досрочного исполнения. На каждом шаге цена может вырасти с вероятностью p или упасть с вероятностью (1-p). Параметры модели: u — коэффициент роста цены (up move); d — коэффициент падения цены (down move); p — риск-нейтральная вероятность роста. Стандартная калибровка Кокса-Росса-Рубинштейна: u = e^(σ√Δt) d = e^(-σ√Δt) p = (e^(rΔt) - d) / (u - d) где Δt = T/N, N — количество шагов. Алгоритм оценки работает обратным ходом от экспирации к текущему моменту. На каждом узле сравнивается внутренняя стоимость опциона при немедленном исполнении и дисконтированная ожидаемая стоимость при продолжении удерживания позиции. Для американского колла в узле (i, j) формула будет следующей: C(i,j) = max[S(i,j) - K, e^(-rΔt)[pC(i+1,j+1) + (1-p)C(i+1,j)]] Увеличение количества шагов N повышает точность оценки. При N→∞ биномиальная модель сходится к модели Блэка-Шоулза для европейских опционов. Симуляции Монте-Карло для экзотических деривативов Метод Монте-Карло используется для оценки деривативов путем моделирования множества возможных траекторий базового актива и усреднения дисконтированных выплат. Этот подход особенно удобен для сложных деривативов с выплатами, зависящими от пути движения цены, таких как азиатские опционы, барьерные опционы и lookback-опционы. Базовая процедура симуляции для геометрического броуновского движения: S(t + Δt) = S(t)exp[(r - σ²/2)Δt + σ√Δt·Z] где Z — стандартная нормальная случайная величина. Этапы оценки: Генерация N траекторий цены базового актива; Расчет выплаты по деривативу для каждой траектории; Дисконтирование выплат к текущему моменту; Усреднение дисконтированных выплат. Точность оценки увеличивается пропорционально √N. Для уменьшения дисперсии применяются методы снижения дисперсии (variance reduction): Антитетические переменные; Стратифицированная выборка; Контрольные переменные (control variates). В продвинутом анализе используют квази-случайные последовательности (Соболя, Холтона). Они обеспечивают более равномерное покрытие пространства и ускоряют сходимость метода. Греки (Greeks) и анализ чувствительности Показатели чувствительности цены опциона к различным рыночным параметрам называются греками. К основным относятся: Delta (изменение цены опциона при изменении цены актива); Gamma (изменение Delta при изменении цены актива); Vega (чувствительность к волатильности); Theta (влияние времени); Rho (чувствительность к процентной ставке). Эти метрики критичны для хеджирования и управления рисками опционных портфелей. Каждый показатель показывает частную производную цены по соответствующему параметру. Дельта и дельта-хеджирование Дельта измеряет изменение цены опциона при изменении цены базового актива на единицу: Δ = ∂C/∂S Формула расчета для европейского колл-опциона в модели Блэка-Шоулза: Δ_call = N(d₁) Для пут-опциона: Δ_put = N(d₁) - 1 Дельта варьируется в зависимости от типа опциона: Дельта колл-опциона находится в диапазоне [0, 1], а пут-опциона — в диапазоне [-1, 0]; Опцион на деньгах (at-the-money, ATM) имеет дельту около 0.5 для колла и -0.5 для пута; Опционы в деньгах (in-the-money, ITM) приближаются к дельте 1 для колла и -1 для пута; Опционы вне денег (out-of-the-money, OTM) имеют дельту, близкую к 0. Дельта-хеджирование создает рыночно-нейтральную позицию путем покупки или продажи базового актива. Для хеджирования короткой позиции в опционе требуется держать Δ единиц базового актива. По мере изменения цены актива дельта меняется, что требует ребалансировки хеджа. Гамма и управление выпуклостью Показатель Гамма показывает скорость изменения дельты при движении цены базового актива: Γ = ∂²C/∂S² = ∂Δ/∂S Для европейского опциона в модели Блэка-Шоулза: Γ = N'(d₁) / (S₀σ√T) где N'(x) — плотность стандартного нормального распределения. Гамма максимальна для опционов at-the-money и убывает при движении в сторону in-the-money или out-of-the-money. Высокая гамма означает быстрое изменение дельты и необходимость частой ребалансировки хеджа. Позиции с положительной гаммой выигрывают от волатильности, с отрицательной — проигрывают. Для фьючерсов и форвардов гамма равна нулю, так как их дельта постоянна и равна 1. Гамма-хеджирование требует использования опционов с заданной выпуклостью для нейтрализации риска второго порядка. Вега и волатильность Вега измеряет чувствительность цены опциона к изменению волатильности: ν = ∂C/∂σ Для европейского опциона: ν = S₀√T·N'(d₁) Вега всегда положительна для длинных опционных позиций независимо от типа опциона. Опционы at-the-money имеют максимальную вегу. Позиции с положительной вегой выигрывают от роста волатильности, с отрицательной — от ее снижения. Вега особенно важна при торговле волатильностью через стратегии типа straddle или strangle. Вега-хеджирование создает позиции нейтральные к изменениям подразумеваемой волатильности. Тета и временной распад Тета показывает изменение цены опциона с течением времени при неизменных остальных параметрах: Θ = ∂C/∂t Для европейского колл-опциона: Θ = -(S₀σN'(d₁))/(2√T) - rKe^(-rT)N(d₂) Тета обычно отрицательна для длинных опционных позиций — временная стоимость опциона убывает при приближении к экспирации. Опционы at-the-money имеют максимальный временной распад. Короткие опционные позиции имеют положительную тету и зарабатывают на временном распаде. Для фьючерсов тета равна нулю, так как они не имеют временной стоимости. Управление тетой крайне важно для стратегий продажи опционов и календарных спредов. Анализ волатильности Волатильность — ключевой параметр ценообразования деривативов и мера риска базового актива. Анализ волатильности включает измерение исторической волатильности, извлечение подразумеваемой волатильности из рыночных цен и моделирование динамики волатильности. Историческая и подразумеваемая волатильность Историческая волатильность измеряет реализованную изменчивость цены актива за прошлый период. Стандартный расчет использует логарифмические доходности: σ_hist = √(252/(n-1) Σ(r_i - r̄)²) где: r_i = ln(S_i/S_(i-1)) — логарифмическая доходность; r̄ — средняя доходность; n — количество наблюдений; 252 — количество торговых дней для аннуализации. Подразумеваемая волатильность извлекается из рыночных цен опционов путем решения уравнения Блэка-Шоулза относительно σ. Это единственный неизвестный параметр модели при наблюдаемой рыночной цене опциона. Разница между подразумеваемой и исторической волатильностью отражает ожидания рынка. Если подразумеваемая волатильность выше исторической, это сигнализирует о повышенных прогнозах будущей изменчивости. При росте подразумеваемой волатильности цена опционов, как правило, увеличивается. Поверхность волатильности и улыбка волатильности Поверхность волатильности — трехмерное представление подразумеваемой волатильности как функции страйка и времени до экспирации. Идеальная модель Блэка-Шоулза предполагает константную волатильность, хотя рыночные данные демонстрируют структуру зависимости волатильности от монетности опциона. Улыбка волатильности — это профиль подразумеваемой волатильности при фиксированной дате экспирации. Подразумеваемая волатильность выше для опционов, сильно в деньгах (in-the-money, ITM) и вне денег (out-of-the-money, OTM), по сравнению с опционами на деньгах (at-the-money, ATM). Такая форма отражает рыночное ценообразование рисков хвостов распределения (tail risk) и отклонения от логнормального распределения. Типы структур волатильности: Volatility smile — симметричная U-образная форма; Volatility smirk (skew) — асимметричная форма с повышенной волатильностью для low-strike путов; Forward volatility — волатильность для будущих периодов, извлеченная из календарных спредов. Калибровка моделей ценообразования на рыночную поверхность волатильности обеспечивает согласованность теоретических цен с наблюдаемыми котировками. Параметрические модели поверхности (SVI, SABR) аппроксимируют рыночную структуру гладкими функциями. Модели стохастической волатильности Модели стохастической волатильности рассматривают волатильность как случайный процесс наряду с ценой базового актива. Эти модели точнее отражают динамику реальных рынков и позволяют оценивать опционы на волатильность. Модель Хестона — это популярная модель с процессом среднего возвращения (mean-reverting) для дисперсии: dS_t = μS_t dt + √V_t S_t dW₁ dV_t = κ(θ - V_t)dt + ξ√V_t dW₂ где: V_t — мгновенная дисперсия; κ — скорость возврата к среднему; θ — долгосрочное среднее дисперсии; ξ — волатильность волатильности; W₁, W₂ — коррелированные броуновские движения. Корреляция между W₁ и W₂ описывает эффект левереджа (leverage effect) — тенденцию волатильности расти при падении цен. Модель Хестона позволяет получить полуаналитическое решение для европейских опционов с использованием характеристических функций. Другие распространенные модели: GARCH для дискретного времени, SABR для описания динамики форвардных ставок и волатильности, локальная волатильность Дюпира для детерминированной волатильности зависящей от цены и времени. Арбитражный анализ Арбитражный анализ выявляет несоответствия в ценах связанных деривативов и конструирует безрисковые позиции для извлечения прибыли. Арбитражные возможности возникают при нарушении фундаментальных соотношений между ценами или при временных неэффективностях рынка. Put-call паритет Put-call паритет — фундаментальное соотношение между ценами европейских колл и пут опционов с одинаковым страйком и экспирацией. Рассчитывается по формуле: C - P = S₀ - Ke^(-rT) где: C — цена колл-опциона; P — цена пут-опциона; S₀ — текущая цена базового актива; K — страйк; r — безрисковая ставка; T — время до экспирации. Нарушение паритета создает арбитражную возможность: Если C - P > S₀ - Ke^(-rT), арбитражная стратегия: продать колл, купить пут, купить акцию, занять Ke^(-rT); Если C - P < S₀ - Ke^(-rT), обратная позиция: купить колл, продать пут, продать акцию в шорт, инвестировать Ke^(-rT). Таким образом, любое отклонение от паритета создает возможность арбитража, которая исчезает по мере выравнивания цен на рынке. Put-call паритет применим только для европейских опционов без дивидендов. Для американских опционов существуют неравенства: S₀ - K ≤ C - P ≤ S₀ - Ke^(-rT). Дивиденды корректируют паритет вычитанием дисконтированной стоимости дивидендов из цены акции. Календарные спреды Календарный спред конструируется из опционов с одинаковым страйком, но разными датами экспирации. К примеру, длинный календарный спред - это покупка дальнего опциона и продажа ближнего опциона того же типа и страйка. Профиль выплат календарного спреда зависит от динамики временного распада и изменения волатильности. Стратегия зарабатывает на различии в тете между краткосрочными и долгосрочными опционами. Ближний опцион теряет временную стоимость быстрее, создавая положительный денежный поток для держателя спреда. Условия прибыльности: цена базового актива остается близкой к страйку опционов; подразумеваемая волатильность растет или остается стабильной; временной распад ближнего опциона превышает дальний. Календарные спреды применяются для арбитража структуры сроков (term structure) подразумеваемой волатильности и для торговли изменениями наклона кривой волатильности. Конверсионный и реверсионный арбитраж Конверсия и реверсия — синтетические арбитражные стратегии эксплуатирующие нарушения put-call паритета. Этапы арбитража типа Конверсия (conversion): купить акцию; купить пут; продать колл с тем же страйком и экспирацией. Синтетическая позиция эквивалентна безрисковому займу. Выплата на экспирации фиксирована и равна K независимо от цены акции. Прибыль возникает если стоимость конструкции меньше дисконтированного страйка. Этапы арбитража типа Реверсия (reversal) — обратная позиция: продать акцию в шорт; продать пут; купить колл. Синтетическая позиция эквивалентна безрисковому кредитованию. Выплата на экспирации равна -K. Прибыль возникает если полученный кредит превышает дисконтированный страйк. Конверсии и реверсии активно применяются маркет-мейкерами для хеджирования опционных портфелей и получения прибыли на микроструктурных рыночных неэффективностях. Однако транзакционные издержки и стоимость финансирования ограничивают объем доступного арбитража. Риск-анализ деривативных портфелей Риск-анализ деривативных портфелей количественно оценивает потенциальные убытки при неблагоприятных рыночных сценариях. Деривативы создают нелинейные экспозиции требующие специальных методов измерения риска. Эффективный риск-менеджмент включает расчет метрик риска, стресс-тестирование и валидацию моделей. Value at Risk для опционных позиций Value at Risk (VaR) оценивает максимальный ожидаемый убыток портфеля за заданный период с заданной вероятностью. Для портфеля опционов VaR учитывает нелинейную зависимость цены от факторов риска. Методы расчета VaR: Параметрический метод — аппроксимация изменения стоимости через дельту и гамму; Историческая симуляция — применение исторических изменений факторов к текущему портфелю; Симуляция Монте-Карло — генерация случайных сценариев на основе стохастической модели. Формула Дельта-нормальной аппроксимации для опционного портфеля: ΔP ≈ Δ·ΔS + 0.5·Γ·(ΔS)² где: ΔP — изменение стоимости портфеля; ΔS — изменение цены базового актива. Параметрический VaR предполагает нормальное распределение факторов риска. Для опционов с высокой гаммой квадратичная аппроксимация улучшает точность по сравнению с линейной дельта-аппроксимацией. Симуляция Монте-Карло обеспечивает наиболее точную оценку VaR для сложных портфелей с множественными опционами и экзотическими деривативами. Метод требует моделирования совместного распределения всех факторов риска и полной переоценки портфеля для каждого сценария. Стресс-тестирование Стресс-тестирование анализирует поведение портфеля при экстремальных рыночных условиях, выходящих за пределы обычной волатильности. В отличие от VaR, стресс-тесты не привязаны к вероятностным оценкам и фокусируются на наихудших возможных (worst-case) сценариях. Типы стресс-тестов: Исторические сценарии — воспроизведение прошлых кризисов (крах 1987, кризис 2008); Гипотетические сценарии — конструирование экстремальных но правдоподобных событий; Анализ чувствительности — систематическое изменение отдельных факторов риска. Для опционных портфелей стресс-тесты включают шоки волатильности, параллельные сдвиги кривой процентных ставок, резкие движения базового актива. Анализ греков при стрессовых сценариях выявляет концентрации риска и потенциальные убытки. Обратные стресс-тесты определяют рыночные условия, приводящие к заданному уровню убытков или банкротству. Метод помогает идентифицировать критические уязвимости портфеля. Бэктестинг стратегий Бэктестинг проверяет эффективность деривативных стратегий на исторических данных. Процесс включает симуляцию торговых решений, расчет прибылей и убытков, анализ метрик производительности. Метрики оценки стратегий: Совокупная доходность и аннуализированная доходность; Коэффициент Шарпа — избыточная доходность на единицу волатильности; Максимальная просадка (maximum drawdown); Винрейт (win rate) и Профит-фактор (profit factor); Метрики с учетом транзакционных издержек. Каждая метрика важна и позволяет оценить качество торговой стратегии с разных сторон. Лично я считаю наиболее важной Коэффициент Шарпа. Он рассчитывается по формуле: SR = (R_p - R_f) / σ_p где: R_p — доходность портфеля; R_f — безрисковая ставка; σ_p — волатильность доходности портфеля. Ключевые аспекты корректного бэктестинга включают: Корректный инжиринг признаков и избегание look-ahead bias (использования будущей информации); Учет проскальзывания и комиссий; Реалистичное моделирование исполнения ордеров; Проверку стратегии на данных вне обучаемой выборки (out-of-sample data). Для повышения надежности результатов используют walk-forward анализ, при котором исторические данные делятся на скользящие обучающие и тестовые периоды. Такой подход позволяет оценить устойчивость стратегии к изменению рыночных условий и снижает риск переобучения на исторических данных. В сочетании с тщательным бэктестингом это помогает создавать более стабильные и реалистичные торговые стратегии, готовые к применению в реальной торговле. Заключение Анализ финансовых деривативов сочетает математическую строгость с глубоким пониманием рыночной динамики. Количественные модели ценообразования создают основу для оценки справедливой стоимости инструментов. Греки опционов предоставляют практические метрики для управления рисками и хеджирования позиций. Арбитражный анализ позволяет выявлять дисбалансы в ценообразовании и находить возможности для безрисковой прибыли. Вместе с тем эффективная работа с деривативами требует тщательного бэктестинга и оценки стратегии на исторических данных с учетом транзакционных издержек и рыночных ограничений. Только комплексное сочетание математического моделирования, анализа рисков и практического тестирования позволяет создавать устойчивые торговые стратегии и принимать обоснованные инвестиционные решения. ### Что такое деривативы? И для чего они используются? Деривативы - это производные финансовые инструменты, полученные синтетически из разных активов. Это крайне популярные инструменты. Ежедневный оборот на организованных биржах превышает $2–3 трлн по рыночной стоимости контрактов. Деривативы позволяют управлять рисками, извлекать прибыль из рыночных неэффективностей и создавать сложные инвестиционные стратегии. Крупные корпорации используют их для хеджирования валютных и процентных рисков, институциональные инвесторы строят портфельные стратегии, маркет-мейкеры обеспечивают ликвидность рынков. Финансовые деривативы: концепция и особенности Деривативы представляют собой контракты, стоимость которых зависит от цены базового актива: акций, облигаций, валют, товаров, процентных ставок или индексов. Ключевое отличие: владение деривативом не означает владение самим активом. Дериватив устанавливает отношения между двумя сторонами по поводу будущих платежей, связанных с изменением цены базового актива. Контракт определяет условия расчета, сроки исполнения и обязательства сторон. Расчеты происходят либо поставкой базового актива, либо денежными выплатами. Цена дериватива математически связана с ценой базового актива, но эта связь не всегда линейна: опционы демонстрируют нелинейную зависимость, т. е. небольшое изменение цены базового актива приводит к непропорциональному изменению цены опциона. Отличия от базовых активов Базовый актив существует физически или юридически, дериватив существует только как договор между сторонами. При покупке акции инвестор получает права акционера, при покупке фьючерса на акцию — только обязательства по контракту без прав акционера. Деривативы торгуются с использованием маржи или премии, что создает эффект кредитного плеча. Для открытия фьючерсной позиции требуется внести гарантийное обеспечение в размере 5-15% от номинала контракта, тогда как покупка базового актива требует 100% оплаты. Срочность — характерная черта большинства деривативов: контракт имеет дату экспирации, после которой прекращает существование. Зачем нужны деривативы на рынке Деривативы решают три основные задачи: Хеджирование рисков: экспортер, получающий платежи в долларах через 3 месяца, использует валютный форвард для фиксации курса обмена, авиакомпании хеджируют цены на авиакеросин фьючерсами; Спекуляция: спекулянты обеспечивают ликвидность рынка, принимая противоположную позицию за премию или спред; Арбитраж: арбитражеры устраняют расхождения между ценами деривативов и базовых активов, возвращая цены к равновесным уровням. Форварды и фьючерсы Форварды и фьючерсы относятся к классу линейных деривативов с симметричным профилем риска. Обе стороны контракта несут обязательства, в отличие от опционов где одна сторона имеет право выбора. Форвардные контракты Форвард — соглашение между двумя сторонами о купле-продаже актива в будущем по цене, зафиксированной сегодня. Контракт заключается напрямую между контрагентами без участия биржи, условия полностью кастомизированы. Покупатель форварда обязуется купить актив на дату экспирации по форвардной цене, продавец обязуется поставить актив. Прибыль длинной позиции равна разнице между спот-ценой на дату экспирации и форвардной ценой. Форвардная цена рассчитывается на основе спот-цены с учетом стоимости финансирования и дивидендов. Расчеты происходят на дату экспирации либо поставкой актива, либо денежными выплатами разницы между спот-ценой и форвардной ценой. Фьючерсы Фьючерс — стандартизированный форвард, торгуемый на бирже. Биржа определяет все параметры контракта: размер, качество базового актива, даты экспирации, тип расчетов. Стандартизация обеспечивает ликвидность: объемы торгов фьючерсами на популярные активы измеряются сотнями тысяч контрактов ежедневно, спреды между bid и ask составляют всего 1-2 тика. Биржа выступает центральным контрагентом через клиринговую палату, что устраняет кредитный риск контрагента. Ежедневная переоценка позиций (mark-to-market) отличает фьючерсы от форвардов: клиринговая палата рассчитывает прибыль или убыток по позиции ежедневно, при падении баланса ниже поддерживающей маржи участник получает margin call и обязан довнести средства. Отличия и применение Форварды используются для хеджирования специфических рисков в ситуациях, когда стандартные биржевые контракты не подходят. Фьючерсы предпочтительны для спекуляций и краткосрочных стратегий благодаря ликвидности и низким издержкам. В форварде риск дефолта контрагента сохраняется до экспирации, фьючерсы устраняют этот риск через ежедневные расчеты и маржинальную систему. Опционы: колл и пут Опционы вводят асимметрию в структуру выплат: покупатель опциона получает право без обязательства, продавец принимает обязательство за премию. Структура опционного контракта Опцион дает покупателю право купить (колл) или продать (пут) базовый актив по заранее определенной цене исполнения (страйк) до или на дату экспирации. Выделяют 2 основных типа опционов: Американский опцион исполняется в любой момент до экспирации; Европейский — только на дату экспирации. Покупатель платит премию продавцу при открытии позиции — это максимальный убыток для покупателя и максимальная прибыль для продавца. Внутренняя стоимость опциона колл равна максимуму из нуля и разности между текущей ценой актива и страйком. По стоимостному фактору опционы разделяют: Опцион с положительной внутренней стоимостью называется in-the-money; с нулевой — at-the-money; с отрицательной — out-of-the-money. Временная стоимость представляет часть премии сверх внутренней стоимости и убывает по мере приближения к экспирации. Права и обязательства сторон Покупатель колл-опциона получает право купить актив по страйку. Если на дату экспирации цена актива превышает страйк, покупатель исполняет опцион и фиксирует прибыль, равную разнице между рыночной ценой и страйком минус премия. Продавец колл-опциона обязан продать актив по страйку, если покупатель исполняет опцион, получая премию как компенсацию. Покупатель пут-опциона получает право продать актив по страйку — пут работает как страховка от снижения цены. Продавец пут-опциона обязан купить актив по страйку при исполнении, риск ограничен снизу нулевой ценой актива. Ключевые параметры Страйк определяет уровень цены, при котором опцион переходит из out-of-the-money в in-the-money. Премия опциона складывается из внутренней и временной стоимости. Факторы влияния на премию: текущая цена базового актива; страйк; время до экспирации; волатильность; процентные ставки; дивиденды. Рост волатильности увеличивает премию для колл и пут опционов. Опционы с большим сроком до экспирации дороже, временной распад ускоряется по мере приближения экспирации. Спецификация контракта включает мультипликатор: для опционов на акции стандартный мультипликатор 100, один контракт дает право на 100 акций. Свопы Свопы представляют соглашения об обмене платежами между сторонами по определенному графику. Стороны не обмениваются номинальными суммами — оплачивая только разницу в платежах. Процентные свопы Процентный своп предполагает обмен фиксированных платежей на плавающие. Типичная структура: одна сторона платит фиксированную ставку, другая — плавающую ставку, привязанную к LIBOR, SOFR или другой референсной ставке. Компания с кредитом под плавающую ставку заключает своп, где платит фиксированную и получает плавающую ставку, трансформируя плавающий долг в фиксированный. Расчеты по свопу происходят периодически: ежеквартально, раз в полгода или ежегодно. Неттинг платежей снижает кредитный риск и транзакционные издержки. Рыночная стоимость свопа изменяется с движением процентных ставок. Валютные свопы Валютный своп включает обмен номинальных сумм в разных валютах в начале контракта, периодические процентные платежи и обратный обмен номиналов на дату экспирации. Многие корпорации, получающие займы в долларах, но генерирующие доходы в евро, используют валютные свопы для трансформации валютной структуры обязательств. Процентные платежи бывают фиксированными или плавающими в обеих валютах. Валютные свопы подвержены кредитному риску в большей степени, чем процентные, из-за обмена номиналами. Механизм обмена Документация свопов стандартизирована соглашениями ISDA. Досрочное прекращение свопа происходит путем заключения офсетной сделки или выплаты отступных. Все сделки по свопам ведет центральный клиринг. Этот институт введен после кризиса 2008 для снижения системного риска: стандартизированные процентные свопы подлежат обязательному клирингу через центральных контрагентов. Экзотические деривативы Экзотические деривативы отличаются от стандартных опционов сложной структурой выплат, зависящих от траектории цены базового актива или множественных условий. Барьерные опционы Барьерный опцион активируется или деактивируется при достижении ценой определенного уровня. Например, Knock-in опцион начинает существовать, когда цена пересекает барьер, а knock-out опцион прекращает существование при пересечении барьера. Барьерные опционы обычно обходятся дешевле стандартных на 30-50%. Чем ближе к текущей цене барьер, тем дешевле стоит опцион. Азиатские опционы Азиатский опцион использует среднюю цену базового актива за период вместо цены на дату экспирации. Усреднение снижает влияние краткосрочных колебаний и манипуляций ценой. Пример применения: компания, покупающая нефть ежемесячно, хеджирует среднюю цену закупок за год азиатским колл-опционом. Премия азиатского опциона ниже стандартного европейского на 20-40%. Азиатские опционы относят к экзотическим, так как их сложнее оценить математически: аналитическая формула существует только для геометрического среднего, арифметическое среднее требует численных методов Монте-Карло. Это увеличивает спреды. Ликвидность азиатских опционов ниже стандартных, большинство контрактов торгуется внебиржево с индивидуальными параметрами усреднения под конкретные потребности хеджера. Структурированные продукты Структурированные продукты представляют собой сочетание облигации или банковского депозита с одним или несколькими деривативами, что позволяет сформировать заданный профиль риска и доходности. Типичная конструкция выглядит так: облигация с нулевым купоном обеспечивает защиту капитала, а встроенный опцион дает возможность участвовать в росте базового актива. Примеры: Облигация с защитой капитала (principal-protected note) гарантирует возврат номинала к дате погашения плюс доход, привязанный к движению выбранного индекса; Обратимая конвертируемая облигация (reverse convertible) предлагает повышенный купон, но при снижении цены базового актива ниже установленного барьера может быть конвертирована в акции. Структурированные продукты создаются для розничных инвесторов, желающих получить экспозицию на рынок с ограниченным риском. Эмитент конструирует продукт из стандартных компонентов: покупает облигацию, продает или покупает опционы, встраивает барьеры. Таким образом, инвестор получает единый инструмент вместо самостоятельной сборки портфеля из деривативов. Структурирование позволяет создавать продукты с асимметричными выплатами: участие в росте 80-100%, защита от падения 100%. Риски структурированных продуктов включают: Кредитный риск эмитента; Сложность оценки справедливой стоимости; Низкая ликвидность вторичного рынка; Комиссии эмитента. Они встроены в цену продукта неявно, снижая доходность по сравнению с самостоятельной покупкой компонентов; Досрочное погашение часто невозможно или происходит с существенным дисконтом. Структурированные продукты подходят инвесторам, понимающим механику деривативов и готовым держать инструмент до погашения. Участники рынка деривативов Рынок деривативов объединяет участников с различными целями. Взаимодействие хеджеров, спекулянтов и арбитражеров формирует ликвидность и ценовую эффективность. Хеджеры Хеджеры используют деривативы для защиты от неблагоприятного изменения цен активов. Так, к примеру, производитель пшеницы продает фьючерсы на урожай за несколько месяцев до сбора, фиксируя цену реализации. В то же время, портфельные менеджеры хеджируют рыночный риск продажей индексных фьючерсов или покупкой пут-опционов на индекс. Эффективность хеджирования зависит от корреляции между хеджируемой позицией и деривативом. Спекулянты Спекулянты принимают риск ценовых изменений ради прибыли без владения базовым активом, обеспечивая ликвидность. Кредитное плечо, встроенное в деривативы, усиливает доходность: фьючерсный контракт на индекс S&P 500 с маржой 10% обеспечивает десятикратное плечо. Спекулянты повышают информационную эффективность рынка: агрегация спекулятивных позиций множества участников явно или неявно отражает информацию на цены деривативов. Арбитражеры Арбитражеры извлекают безрисковую прибыль из временных расхождений между связанными инструментами. Существует множество стратегий арбитража. К примеру, Cash-and-carry арбитраж использует отклонение цены фьючерса от теоретической справедливой стоимости. А конверсионный арбитраж использует паритет пут-колл опционов. Межбиржевой арбитраж эксплуатирует разницу цен на один актив на разных площадках. Ключевая особенность всех арбитражных возможностей - то, что они быстро появляются и исчезают. Доходность арбитражных стратегий падает по мере роста капитала: больше участников конкурируют за ограниченное число возможностей, скорость закрытия спредов ускоряется. Арбитражная торговля сталкивается и с другими ограничениями: транзакционные издержки; маржинальные требования; ограничения на короткие продажи; крупные позиции двигают цены; регуляторные ограничения на размер позиций и левередж. Эти барьеры делают арбитраж доступным преимущественно институциональным участникам с существенным капиталом и инфраструктурой. От чего зависит цена дериватива Цена дериватива определяется совокупностью факторов, связанных с базовым активом и контрактными параметрами. Текущая цена базового актива — первичный фактор. Рост спот-цены увеличивает стоимость колл-опционов и снижает стоимость пут-опционов. Для фьючерсов связь линейна: фьючерсная цена растет пропорционально спот-цене с поправкой на стоимость владения активом. Время до экспирации влияет по-разному: для опционов больший срок увеличивает вероятность благоприятного движения цены, повышая премию; для фьючерсов срок влияет через стоимость финансирования и дивиденды. В ценообразовании опционов наибольшее влияние оказывает волатильность. Рост подразумеваемой волатильности на 10 процентных пунктов увеличивает премию at-the-money опциона на 20-30%. Процентные ставки влияют через стоимость финансирования. Дивиденды снижают стоимость колл-опционов и повышают стоимость пут-опционов. Внутренняя и временная стоимость опциона Внутренняя стоимость - это прибыль от немедленного исполнения опциона. Временная стоимость отражает потенциал изменения внутренней стоимости до экспирации. Например, опцион At-the-money имеет нулевую внутреннюю стоимость, вся премия — временная. Распад временной стоимости (time decay) происходит нелинейно. За последний месяц at-the-money опцион теряет около трети оставшейся временной стоимости, за последнюю неделю — более половины. Показатель Theta измеряет скорость распада: количество стоимости, теряемое за день. Дополнительные факторы Ликвидность инструмента влияет на bid-ask спред. Высоколиквидные опционы имеют спред в один тик, экзотические опционы на малоликвидные акции — десятки процентов от премии. Показатель Open Interest (открытый интерес) считает количество открытых контрактов: высокий открытый интерес коррелирует с ликвидностью и узкими спредами. Корпоративные действия требуют корректировки параметров опционов: сплит акций изменяет количество акций на контракт и страйк пропорционально. Риски при работе с деривативами Деривативы усиливают потенциальную доходность и убытки через встроенное кредитное плечо. Кредитный риск Кредитный риск возникает, когда контрагент не выполняет обязательства по контракту. Чаще всего под этот риск попадают внебиржевые деривативы. Биржевые деривативы минимизируют кредитный риск через центрального контрагента: клиринговая палата становится покупателем для продавца и продавцом для покупателя. Рыночный риск Рыночный риск представляет убытки от неблагоприятного движения цены базового актива. Для линейных деривативов риск пропорционален размеру позиции. Опционы создают нелинейный риск: продавец непокрытого колла несет неограниченный риск. Волатильность влияет на стоимость опционных позиций через показатель vega. Gamma риск представляет нелинейное изменение delta при движении цены базового актива: короткая gamma позиция требует частой ребалансировки хеджа. Риск ликвидности Риск ликвидности проявляется в невозможности закрыть позицию быстро без существенного влияния на цену. Экзотические деривативы торгуются эпизодически с широкими спредами. Рыночные кризисы усугубляют проблемы ликвидности: ширина спредов увеличивается в разы, открытый интерес падает. Маржин-коллы при просадке ликвидности создают замкнутый круг: участник теряет на позиции, получает маржин-колл, вынужден продавать активы в неликвидном рынке. Заключение Деривативы трансформировали финансовые рынки, превратив управление рисками в точную науку. Форварды фиксируют цены будущих сделок, опционы создают асимметричные профили выплат, свопы позволяют трансформировать структуру обязательств, а структурированные продукты объединяют деривативы в готовые решения для специфических задач. Понимание механики работы деривативов открывает доступ к профессиональным инструментам управления портфелем и позволяет корректно оценивать риски. ### Прогнозирование временных рядов с xLSTM Классические модели прогнозирования временных рядов, такие как градиентный бустинг, хорошо работают на табличных данных и с короткими историческими окнами. Однако при анализе длинных последовательностей и рядов с взаимозависимыми признаками их эффективность драматически падает. В таких случаях стоит присмотреться к нейронным сетям, так как они лучше моделируют сложные временные зависимости. Одним из интересных решений в этой области стала архитектура xLSTM (Extended LSTM). Она расширяет возможности стандартной LSTM, позволяя учитывать более длинный контекст и сочетать рекуррентные механизмы с современными приемами оптимизации. В этой статье я покажу, как устроена xLSTM, в чем ее преимущества перед обычной LSTM и как применить ее для прогнозирования временных рядов с помощью PyTorch. Архитектура xLSTM Классическая LSTM архитектура использует 3 гейта (forget, input, output) с сигмоидальной активацией и аддитивное обновление cell state. Такая конструкция ограничивает выразительность модели: сигмоиды насыщаются, cell state растет линейно, память имеет фиксированную емкость. В xLSTM эти компоненты переработаны для увеличения модельной емкости и стабильности обучения на длинных последовательностях. Ключевое отличие архитектуры xLSTM от LSTM заключается в том, что экспоненциальные гейтинг (gating) механизмы заменяют классические сигмоидальные функции. В стандартной LSTM forget gate вычисляется как: f_t = σ(W_f · [h_{t-1}, x_t] + b_f) где: f_t — forget gate в момент t; σ — сигмоидальная функция; W_f — матрица весов; h_{t-1} — предыдущее скрытое состояние; x_t — входной вектор; b_f — bias вектор. Сигмоида ограничивает выход диапазоном [0, 1], что приводит к насыщению градиентов. В xLSTM используется экспоненциальная активация, позволяющая gate принимать значения в расширенном диапазоне и обеспечивающая более гибкое управление информационным потоком. Механизм памяти расширен двумя вариантами: scalar memory (sLSTM) и matrix memory (mLSTM). sLSTM сохраняет архитектуру классической LSTM, но модифицирует операции обновления состояния. mLSTM заменяет скалярный cell state матрицей, увеличивая емкость памяти квадратично относительно размерности hidden state. Матричная память позволяет хранить более сложные представления временных зависимостей. Рис. 1: Демонстрация отличий LSTM моделей при обучении. Верхний ряд показывает поведение активационных функций и градиентов. График градиентов демонстрирует замедленное затухание в xLSTM при обратном распространении ошибки. Нижний ряд сравнивает емкость памяти и производительность на длинных последовательностях — xLSTM сохраняет качество прогнозирования при увеличении временного горизонта Нормализация и стабилизация обучения обеспечиваются через модифицированные обновления cell state. В классической LSTM состояние обновляется аддитивно, что может приводить к неограниченному росту значений. xLSTM использует нормализованные обновления с учетом экспоненциальных весов, предотвращая численную нестабильность. Это более эффективный способ для финансовых временных рядов с высокой волатильностью и аномальными значениями. Варианты архитектуры sLSTM (scalar LSTM) сохраняет одномерный cell state, но модифицирует механизм обновления. Forget gate и input gate используют экспоненциальную активацию, cell state обновляется через взвешенную комбинацию с нормализацией. Архитектура эффективна для последовательностей средней длины (100-500 шагов) и требует меньше памяти по сравнению с матричным вариантом. В контексте трейдинга sLSTM подходит для внутридневных данных с интервалом 1-5 минут. mLSTM (matrix LSTM) расширяет скалярное состояние до матрицы размерности d×d, где d — размер hidden state. Матричная память позволяет модели хранить более богатые представления зависимостей между признаками. Input gate формирует матричное обновление через внешнее произведение, forget gate применяется поэлементно. Вычислительная сложность растет квадратично, но емкость памяти увеличивается пропорционально. Рис. 2: Архитектура xLSTM: нейросеть состоит из блоков, где классические LSTM превращаются в sLSTM (экспоненциальные гейты) и mLSTM (матричная память), а затем собираются в масштабируемый стек mLSTM эффективен для мультивариантных временных рядов: одновременное моделирование цены, объема, bid-ask spread, order flow. Гибридные конфигурации комбинируют sLSTM и mLSTM слои в одной архитектуре. Типичная схема: начальные слои используют sLSTM для извлечения базовых паттернов, финальные слои — mLSTM для моделирования сложных взаимодействий. Такая комбинация балансирует вычислительные затраты и выразительность модели. Реализация на PyTorch Библиотека xlstm предоставляет готовую имплементацию обеих архитектур с поддержкой GPU ускорения. Установка через pip включает зависимости PyTorch и необходимые компоненты для эффективных матричных операций. # Установка основной библиотеки !pip install xlstm Библиотека xlstm требует PyTorch версии 2.0 или выше. Для GPU вычислений необходим CUDA toolkit 11.8+. Проверка доступности GPU: import torch print(f"PyTorch version: {torch.__version__}") print(f"CUDA available: {torch.cuda.is_available()}") if torch.cuda.is_available(): print(f"CUDA version: {torch.version.cuda}") print(f"GPU device: {torch.cuda.get_device_name(0)}") Давайте посмотрим на что способна эта нейросеть. Загрузим реальные биржевые данные через yfinance. Для демонстрации используем акции Taiwan Semiconductor Manufacturing Company (TSMC) — высоколиквидный актив с характерной волатильностью технологического сектора. import yfinance as yf import pandas as pd import numpy as np from datetime import datetime, timedelta # Загрузка данных TSMC ticker = "TSM" end_date = datetime.now() start_date = end_date - timedelta(days=730) # 2 года данных data = yf.download(ticker, start=start_date, end=end_date, progress=False) # Проверка на MultiIndex (yfinance иногда возвращает MultiIndex columns) if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.get_level_values(0) # Используем Close вместо Adjusted Close prices = data['Close'].values dates = data.index print(f"Загружено {len(prices)} наблюдений для {ticker}") print(f"Период: {dates[0]} - {dates[-1]}") print(f"Диапазон цен: ${prices.min():.2f} - ${prices.max():.2f}") Загружено 502 наблюдений для TSM Период: 2023-11-14 00:00:00 - 2025-11-13 00:00:00 Диапазон цен: $93.88 - $305.09 Пайплайн предобработки данных включает нормализацию и формирование окон последовательностей. Нормализация через MinMaxScaler обеспечивает стабильность обучения, окна фиксированной длины создают input-output пары для supervised learning. from sklearn.preprocessing import MinMaxScaler # Нормализация scaler = MinMaxScaler(feature_range=(0, 1)) prices_scaled = scaler.fit_transform(prices.reshape(-1, 1)).flatten() # Создание последовательностей def create_sequences(data, lookback=60, forecast_horizon=1): X, y = [], [] for i in range(len(data) - lookback - forecast_horizon + 1): X.append(data[i:i + lookback]) y.append(data[i + lookback:i + lookback + forecast_horizon]) return np.array(X), np.array(y) lookback = 60 # 60 торговых дней (≈3 месяца) forecast_horizon = 1 # Прогноз на 1 день вперед X, y = create_sequences(prices_scaled, lookback, forecast_horizon) # Train/validation/test split train_size = int(0.7 * len(X)) val_size = int(0.15 * len(X)) X_train, y_train = X[:train_size], y[:train_size] X_val, y_val = X[train_size:train_size+val_size], y[train_size:train_size+val_size] X_test, y_test = X[train_size+val_size:], y[train_size+val_size:] # Конвертация в PyTorch tensors X_train = torch.FloatTensor(X_train).unsqueeze(-1) # Shape: (batch, seq, features) y_train = torch.FloatTensor(y_train) X_val = torch.FloatTensor(X_val).unsqueeze(-1) y_val = torch.FloatTensor(y_val) X_test = torch.FloatTensor(X_test).unsqueeze(-1) y_test = torch.FloatTensor(y_test) print(f"Train: {X_train.shape}, Val: {X_val.shape}, Test: {X_test.shape}") Train: torch.Size([309, 60, 1]), Val: torch.Size([66, 60, 1]), Test: torch.Size([67, 60, 1]) Выбор модели xLSTM выполняется с помощью API библиотеки xlstm. В конфигурации задаются вариант архитектуры (sLSTM или mLSTM), размер скрытого состояния (hidden state), число слоев и параметр dropout для регуляризации. import torch import torch.nn as nn from xlstm import xLSTMBlockStack, xLSTMBlockStackConfig, mLSTMBlockConfig, sLSTMBlockConfig, mLSTMLayerConfig, sLSTMLayerConfig, FeedForwardConfig class xLSTMForecaster(nn.Module): def __init__(self, input_size=1, hidden_size=128, num_layers=2, variant='slstm', dropout=0.2, forecast_horizon=1): super(xLSTMForecaster, self).__init__() self.input_size = input_size self.hidden_size = hidden_size # Embedding слой для проекции входа в hidden_size self.embedding = nn.Linear(input_size, hidden_size) # Конфигурация блоков в зависимости от варианта if variant == 'slstm': # sLSTM конфигурация block_config = sLSTMBlockConfig( slstm=sLSTMLayerConfig( backend="vanilla", num_heads=4, conv1d_kernel_size=4, bias_init="powerlaw_blockdependent" ) ) else: # mlstm # mLSTM конфигурация block_config = mLSTMBlockConfig( mlstm=mLSTMLayerConfig( num_heads=4, backend="vanilla" ) ) # xLSTM stack конфигурация xlstm_config = xLSTMBlockStackConfig( mlstm_block=block_config if variant == 'mlstm' else None, slstm_block=block_config if variant == 'slstm' else None, context_length=60, # Максимальная длина последовательности num_blocks=num_layers, embedding_dim=hidden_size, add_post_blocks_norm=True, bias=False, dropout=dropout ) # Создание xLSTM блока self.xlstm = xLSTMBlockStack(xlstm_config) # Output layer self.fc = nn.Linear(hidden_size, forecast_horizon) def forward(self, x): # x shape: (batch, seq_len, input_size) batch_size, seq_len, _ = x.shape # Проекция входа в hidden_size x = self.embedding(x) # (batch, seq_len, hidden_size) # xLSTM обработка xlstm_out = self.xlstm(x) # (batch, seq_len, hidden_size) # Берем последний timestep last_hidden = xlstm_out[:, -1, :] # (batch, hidden_size) # Прогноз prediction = self.fc(last_hidden) # (batch, forecast_horizon) return prediction # Инициализация модели device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = xLSTMForecaster( input_size=1, hidden_size=128, num_layers=2, variant='slstm', # или 'mlstm' dropout=0.2, forecast_horizon=1 ).to(device) print(f"Модель инициализирована на {device}") print(f"Параметров: {sum(p.numel() for p in model.parameters()):,}") # Тестовый forward pass test_input = torch.randn(4, 60, 1).to(device) # (batch=4, seq=60, features=1) test_output = model(test_input) print(f"Test input shape: {test_input.shape}") print(f"Test output shape: {test_output.shape}") Модель инициализирована на gpu Параметров: 216,577 Test input shape: torch.Size([4, 60, 1]) Test output shape: torch.Size([4, 1]) Представленный выше код создает нейронную сеть для прогнозирования временных рядов на основе архитектуры xLSTM. Класс xLSTMForecaster инкапсулирует три основных компонента: embedding слой для проекции входных данных в размерность hidden state, стек xLSTM блоков для обработки последовательности, и fully connected слой для генерации финального прогноза. Входная последовательность проходит через embedding, затем обрабатывается xLSTM блоками, которые извлекают временные зависимости, после чего последний timestep подается на линейный слой для получения предсказания. Метод forward реализует прямой проход данных через сеть. Входной тензор формы (batch, sequence_length, features) сначала проецируется в пространство hidden_size через embedding слой. Затем xLSTM блоки последовательно обрабатывают все timesteps, сохраняя информацию о долгосрочных зависимостях через механизм расширенной памяти. Финальный шаг извлекает последнее скрытое состояние и преобразует его в прогноз через fully connected слой. Основные компоненты: Embedding layer: проецирует входные признаки (размерность 1) в hidden_size (128) для обеспечения достаточной репрезентативной емкости; xLSTMBlockStack: стек из 2 блоков sLSTM или mLSTM для обработки последовательности длиной до 60 элементов с dropout 0.2 для регуляризации; Output layer: линейный слой преобразует последнее скрытое состояние (128 измерений) в прогноз (1 значение); Configuration: параметры num_heads=4 определяют количество attention heads, context_length=60 задает максимальную длину обрабатываемой последовательности. Обучение модели Цикл обучения реализован с использованием стандартного градиентного спуска с оптимизатором Adam. Планировщик скорости обучения (learning rate scheduler) уменьшает шаг обучения, когда валидационная функция потерь перестает улучшаться. Механизм ранней остановки (early stopping) предотвращает переобучение модели. import torch.optim as optim from torch.utils.data import TensorDataset, DataLoader from tqdm import tqdm # Создание DataLoaders batch_size = 32 train_dataset = TensorDataset(X_train, y_train) val_dataset = TensorDataset(X_val, y_val) test_dataset = TensorDataset(X_test, y_test) train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False) test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False) # Loss и optimizer criterion = nn.MSELoss() optimizer = optim.Adam(model.parameters(), lr=0.001) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5) # Training функция def train_epoch(model, loader, criterion, optimizer, device): model.train() total_loss = 0 for X_batch, y_batch in loader: X_batch, y_batch = X_batch.to(device), y_batch.to(device) optimizer.zero_grad() predictions = model(X_batch) loss = criterion(predictions, y_batch) loss.backward() # Gradient clipping для стабильности torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() return total_loss / len(loader) # Validation функция def validate(model, loader, criterion, device): model.eval() total_loss = 0 with torch.no_grad(): for X_batch, y_batch in loader: X_batch, y_batch = X_batch.to(device), y_batch.to(device) predictions = model(X_batch) loss = criterion(predictions, y_batch) total_loss += loss.item() return total_loss / len(loader) # Обучение с early stopping epochs = 100 best_val_loss = float('inf') patience = 10 patience_counter = 0 train_losses, val_losses = [], [] # Цикл обучения с прогресс баром for epoch in tqdm(range(epochs), desc='Training'): train_loss = train_epoch(model, train_loader, criterion, optimizer, device) val_loss = validate(model, val_loader, criterion, device) train_losses.append(train_loss) val_losses.append(val_loss) scheduler.step(val_loss) if val_loss < best_val_loss: best_val_loss = val_loss patience_counter = 0 # Сохранение лучшей модели torch.save(model.state_dict(), 'best_xlstm_model.pth') else: patience_counter += 1 if patience_counter >= patience: print(f'\nEarly stopping at epoch {epoch+1}') break # Загрузка лучшей модели model.load_state_dict(torch.load('best_xlstm_model.pth')) print(f'Обучение завершено. Best validation loss: {best_val_loss:.6f}') Обучение модели занимает от 10 до 30 минут на современных GPU, в зависимости от размера скрытого состояния (hidden state). Early stopping at epoch 23 Обучение завершено. Best validation loss: 0.000226 Для предотвращения взрыва градиентов при работе с последовательностями, содержащими резкие изменения, используется градиентное отсечение (gradient clipping) с max_norm=1.0. Планировщик скорости обучения ReduceLROnPlateau уменьшает learning rate при стагнации функции потерь на валидационном наборе — это типичная ситуация после 30–40 эпох, когда модель уже захватила основные паттерны. Оценка модели на тестовом наборе (evaluation) позволяет измерить ее реальную производительность. Для сравнения с базовой моделью (baseline) используются метрики RMSE, MAE и MAPE. from sklearn.metrics import mean_squared_error, mean_absolute_error # Prediction на test set model.eval() test_predictions = [] with torch.no_grad(): for X_batch, _ in test_loader: X_batch = X_batch.to(device) preds = model(X_batch) test_predictions.append(preds.cpu().numpy()) test_predictions = np.concatenate(test_predictions, axis=0) # Денормализация для вычисления метрик в исходном масштабе y_test_original = scaler.inverse_transform(y_test.numpy().reshape(-1, 1)).flatten() predictions_original = scaler.inverse_transform(test_predictions.reshape(-1, 1)).flatten() # Метрики rmse = np.sqrt(mean_squared_error(y_test_original, predictions_original)) mae = mean_absolute_error(y_test_original, predictions_original) mape = np.mean(np.abs((y_test_original - predictions_original) / y_test_original)) * 100 print(f"\nТестовые метрики xLSTM:") print(f"RMSE: ${rmse:.2f}") print(f"MAE: ${mae:.2f}") print(f"MAPE: {mape:.2f}%") # Baseline сравнение: Naive forecast (предыдущее значение) X_test_last = X_test[:, -1, 0].numpy() baseline_predictions = scaler.inverse_transform(X_test_last.reshape(-1, 1)).flatten() baseline_rmse = np.sqrt(mean_squared_error(y_test_original, baseline_predictions)) baseline_mae = mean_absolute_error(y_test_original, baseline_predictions) baseline_mape = np.mean(np.abs((y_test_original - baseline_predictions) / y_test_original)) * 100 print(f"\nBaseline (Naive) метрики:") print(f"RMSE: ${baseline_rmse:.2f}") print(f"MAE: ${baseline_mae:.2f}") print(f"MAPE: {baseline_mape:.2f}%") print(f"\nУлучшение против baseline:") print(f"RMSE: {((baseline_rmse - rmse) / baseline_rmse * 100):.1f}%") print(f"MAE: {((baseline_mae - mae) / baseline_mae * 100):.1f}%") print(f"MAPE: {((baseline_mape - mape) / baseline_mape * 100):.1f}%") Тестовые метрики xLSTM: RMSE: $13.25 MAE: $10.92 MAPE: 3.83% Baseline метрики: RMSE: $21.23 MAE: $17.75 MAPE: 8.72% Улучшение против baseline: RMSE: 37.6% MAE: 38.5% MAPE: 56.1% Визуализация результатов показывает динамику обучения и качество прогнозов на тестовой выборке. import matplotlib.pyplot as plt fig, axes = plt.subplots(2, 2, figsize=(14, 10)) # График 1: Training history ax1 = axes[0, 0] ax1.plot(train_losses, label='Train Loss', color='#3498DB', linewidth=2) ax1.plot(val_losses, label='Validation Loss', color='#E74C3C', linewidth=2) ax1.set_xlabel('Epoch', fontsize=10) ax1.set_ylabel('MSE Loss', fontsize=10) ax1.set_title('Динамика обучения xLSTM', fontsize=11, fontweight='bold') ax1.legend(fontsize=9) ax1.grid(True, alpha=0.3) # График 2: Test predictions ax2 = axes[0, 1] test_dates_subset = dates[train_size+val_size+lookback:train_size+val_size+lookback+len(y_test_original)] ax2.plot(test_dates_subset, y_test_original, label='Actual', color='#2C3E50', linewidth=1.5, alpha=0.7) ax2.plot(test_dates_subset, predictions_original, label='xLSTM Prediction', color='#E74C3C', linewidth=1.5, linestyle='--') ax2.set_xlabel('Date', fontsize=10) ax2.set_ylabel('Price ($)', fontsize=10) ax2.set_title(f'{ticker} Прогнозы на тестовой выборке', fontsize=11, fontweight='bold') ax2.legend(fontsize=9) ax2.grid(True, alpha=0.3) plt.setp(ax2.xaxis.get_majorticklabels(), rotation=45) # График 3: Residuals ax3 = axes[1, 0] residuals = y_test_original - predictions_original ax3.hist(residuals, bins=30, color='#3498DB', alpha=0.7, edgecolor='#2C3E50') ax3.axvline(x=0, color='#E74C3C', linestyle='--', linewidth=2) ax3.set_xlabel('Prediction Error ($)', fontsize=10) ax3.set_ylabel('Frequency', fontsize=10) ax3.set_title('Распределение ошибок прогноза', fontsize=11, fontweight='bold') ax3.grid(True, alpha=0.3, axis='y') # График 4: Сравнение метрик ax4 = axes[1, 1] metrics = ['RMSE', 'MAE', 'MAPE'] xlstm_values = [rmse, mae, mape] baseline_values = [baseline_rmse, baseline_mae, baseline_mape] x_pos = np.arange(len(metrics)) width = 0.35 bars1 = ax4.bar(x_pos - width/2, baseline_values, width, label='Baseline (Naive)', color='#95A5A6', alpha=0.7) bars2 = ax4.bar(x_pos + width/2, xlstm_values, width, label='xLSTM', color='#E74C3C', alpha=0.7) ax4.set_ylabel('Значение метрики', fontsize=10) ax4.set_title('Сравнение производительности', fontsize=11, fontweight='bold') ax4.set_xticks(x_pos) ax4.set_xticklabels(metrics) ax4.legend(fontsize=9) ax4.grid(True, alpha=0.3, axis='y') # Добавляем значения на бары for bars in [bars1, bars2]: for bar in bars: height = bar.get_height() ax4.text(bar.get_x() + bar.get_width()/2., height, f'{height:.1f}', ha='center', va='bottom', fontsize=8) plt.tight_layout() plt.show() Рис. 3: Результаты обучения и тестирования xLSTM модели. Верхний левый график показывает сходимость training и validation loss. Верхний правый график демонстрирует прогнозы на тестовой выборке: модель хорошо улавливает общую тенденцию, но немного запаздывает на резких разворотах. Нижний левый график отображает распределение ошибок прогноза — чем ближе они сконцентрированы возле нуля, тем меньше смещение (bias). Нижний правый график сравнивает метрики xLSTM с naive baseline: xLSTM выигрывает по всем 3 метрикам Как мы могли убедится по верхнему правому графику, модель xLSTM достаточно хорошо повторяет цены акций. В моих тестах с разными акциями техсектора разброс составлял по MAE 4–10% от средней цены, по MAPE — 2.5–5%. И это с обычными настройками. Еще я заметил, что модель показывает высокую эффективность на трендовых движениях, однако сталкивается с трудностями при резких разворотах рынка. Полагаю это связано с ограничением окна исторических данных (lookback) в 60 дней, что не всегда позволяет учесть структурные изменения рынка. Ограничения и альтернативы Нейронная сеть xLSTM показывает заметное улучшение по сравнению с классическими LSTM, однако сохраняет присущие рекуррентным архитектурам ограничения. Во-первых, это вычислительные требования. У mLSTM они растут квадратично с размером скрытого состояния. Например, при 𝑑=256 матричное состояние содержит около 65 тыс. параметров, тогда как скалярная версия — всего 256. Во-вторых, скорость работы. Задержка инференса для mLSTM с тремя слоями составляет 30–50 мс на CPU для последовательности длиной 100, что критично для стратегий с высокой частотой торгов (high-frequency). Использование GPU снижает задержку до 5–10 мс, однако добавляет дополнительные инфраструктурные сложности при внедрении в продакшен. Чувствительность модели к гиперпараметрам проявляется в значительных различиях результатов при разных конфигурациях. Например, скрытое состояние (hidden size) в 64 часто приводит к недообучению, тогда как 512 — к переобучению на небольших датасетах (менее 1000 примеров). Количество слоев также играет весомую роль: один слой недостаточен для сложных паттернов, тогда как четыре и более требуют тщательной регуляризации. Значения dropout в диапазоне 0.1–0.3 позволяют достигать баланса, однако оптимальный уровень сильно зависит от особенностей данных. Проведение grid search по этим гиперпараметрам может потребовать 20–50 запусков обучения, что значительно увеличивает время разработки. На небольших датасетах xLSTM может легко переобучаться: метрики test значительно отличаются от train. После предварительной обработки финансовые временные ряды часто содержат всего 500–2000 наблюдений, тогда как модель с более чем 200 000 параметров легко запоминает тренировочные данные. Для борьбы с переобучением применяются методы регуляризации: dropout с вероятностью 0.2–0.3, L2-регуляризация с коэффициентом 1e-5 и ранняя остановка (early stopping) с patience 10–15 эпох. Дополнительно используются техники увеличения данных, такие как добавление случайного шума или temporal jittering, однако их эффективность ограничена высокой автокорреляцией финансовых временных рядов. И наконец, интерпретируемость прогнозов xLSTM. Она крайне низка. Модель выдает только точечные прогнозы (point estimates) без явных мер неопределенности, что затрудняет оценку рисков. В отличие от трансформеров с механизмами внимания (attention), которые показывают, какие временные точки влияют на прогноз, xLSTM не предоставляет таких инсайтов. Это ограничивает его применение в стратегиях с высокой регуляторной ответственностью, например в институциональной торговле. Сравнение с конкурентами Чаще всего в качестве альтернативы xLSTM рассматривают трансформеры. Модели на базе трансформеров используют механизм self-attention для параллельной обработки последовательностей. Их вычислительная сложность составляет O(n²·d). Линейные варианты трансформеров, такие как Linformer и Performer, снижают сложность до O(n⋅d), при этом сохраняя высокую точность прогнозов. Другие преимущества трансформеров перед xLSTM: Лучшая интерпретируемость за счет наличия весов внимания; Эффективная обработка длинных зависимостей с помощью positional encoding; Возможность предварительного обучения (pre-training) на больших датасетах. Однако трансформеры требовательны к данным. Они плохо обучаются на небольших датасетах (до 5 000 примеров). Плюс имеют более высокую задержку инференса по сравнению с xLSTM и склонны к переобучению на небольших датасетах. Также в качестве альтернативы можно рассмотреть Temporal Fusion Transformer (TFT). Эта нейронная сеть специально разработана для прогнозирования временных рядов. Она комбинирует LSTM для обработки признаков и использует механизм multi-head attention для моделирования временных зависимостей. TFT также включает сеть выбора переменных (variable selection network) для оценки важности признаков и использует квантильную регрессию для оценки неопределенности прогнозов. Архитектура TFT демонстрирует передовые результаты на бенчмарках, таких как Electricity и Traffic. Однако у модели есть недостатки: высокая сложность (более 500 000 параметров), необходимость данных в формате multivariate time series для полной эффективности и время обучения в 2–3 раза больше, чем у xLSTM. Еще один конкурент xLSTM - нейросеть N-BEATS. Она использует полностью связную (fully connected) архитектуру с обратными и прямыми остаточными связями (residual connections). Модель разлагает временной ряд на компоненты тренда и сезонности с помощью обучаемых базисных функций. Архитектура обеспечивает интерпретируемые прогнозы и показывает высокую эффективность на данных в формате univariate time series. Результаты бенчмарков, включая M4 competition, продемонстрировали топ-3 производительность. Однако у модели N-BEATS есть ограничения: ее фокус на univariate данных затрудняет использование экзогенных переменных. Плюс также она менее эффективна на нестационарных рядах с резкими структурными изменениями, что часто присутствует на финансовых рынках. Заключение Нейросеть xLSTM представляет собой практичное развитие классических LSTM: экспоненциальные механизмы управления (gates) решают проблему затухающих градиентов, расширенная память через скалярные и матричные варианты увеличивает емкость модели, а улучшенная стабильность обучения облегчает сходимость. В контексте прогнозирования финансовых временных рядов архитектура этой нейросети обеспечивает улучшение метрик по сравнению с классическим LSTM при разумных вычислительных затратах. В сравнении с трансформерами и специализированными архитектурами xLSTM занимает практичную нишу: достаточная производительность для большинства задач при меньших требованиях к данным и вычислительным ресурсам. ### Гипотеза эффективного рынка: слабая, полусильная, сильная формы Гипотеза эффективного рынка (Efficient Market Hypothesis, EMH) утверждает, что цены активов полностью отражают всю доступную информацию. Из этого следует прямой вывод: систематическое получение избыточной (альфа) доходности невозможно, поскольку любая новая информация мгновенно включается в цену. Гипотеза формализована Юджином Фамой в 1970 году и остается центральной концепцией современной финансовой теории. EMH разделяется на три формы в зависимости от типа информации, отраженной в ценах: слабую, полусильную и сильную. Понимание различий между этими формами важно для разработки торговых стратегий и оценки реалистичности получения альфа-доходности. Основы гипотезы эффективного рынка Эффективность рынка означает, что текущая цена актива является несмещенной оценкой его справедливой стоимости. Когда поступает новая информация, цена мгновенно корректируется до нового равновесного уровня. Это исключает возможность арбитража и систематической переоценки или недооценки активов. Информационная эффективность подразумевает, что участники рынка рациональны и используют всю доступную информацию для формирования ожиданий. Цена актива в момент t отражает математическое ожидание будущих денежных потоков. Это можно выразить формулой: P_t = E[∑(CF_{t+i} / (1+r)^i) | Ω_t] где: P_t — цена актива в момент t; CF_{t+i} — денежный поток в период t+i; r — ставка дисконтирования; Ω_t — информационное множество в момент t. Информационное множество Ω_t включает все данные, доступные участникам рынка. Содержание этого множества определяет форму эффективности: Слабая форма ограничивается историческими ценами; Полусильная форма добавляет публичную информацию; Сильная форма включает инсайдерские данные. Предпосылки гипотезы Гипотеза эффективного рынка базируется на трех ключевых допущениях: Участники рынка рациональны и правильно оценивают активы на основе фундаментальной стоимости; Даже если некоторые инвесторы иррациональны, их действия случайны и взаимно компенсируются; Когда иррациональные инвесторы действуют согласованно, рациональные арбитражеры нейтрализуют возникающие искажения цен. Транзакционные издержки в идеальной модели отсутствуют, информация распространяется мгновенно и бесплатно. Разумеется, на практике сегодня эти условия не выполняются, что создает пространство для множества активных стратегий. Задержки в распространении информации, различия в вычислительных мощностях и доступе к данным формируют временные неэффективности. Три формы EMH Классификация исследователя Фамы разделяет гипотезу на три уровня в зависимости от объема информации, отраженной в ценах. Каждая форма имеет конкретные эмпирические следствия и проверяется специфическими тестами. Понимание границ применимости каждой формы определяет выбор торговой стратегии. Слабая форма Слабая форма утверждает, что текущие цены отражают всю информацию из исторических цен и объемов торгов. Доходность актива следует случайному блужданию: R_t = μ + ε_t где: R_t — доходность в момент t; μ — средняя доходность; ε_t — случайная ошибка с нулевым средним. Из этого следует, что технический анализ не эффективен на практике: прошлые ценовые паттерны не несут информации о будущих изменениях цен. Доходности не демонстрируют заметной взаимосвязи с предыдущими значениями. Проверка гипотезы слабой формы эффективности рынка включает: анализ автокорреляции, проверку последовательностей направленных движений (runs-test), а также тест отношения дисперсий (variance ratio tests), который используется для выявления отклонений от случайного характера ценовых колебаний. Эмпирические исследования показывают смешанные результаты: На развитых рынках автокорреляция дневных доходностей близка к нулю, что подтверждает слабую форму; На развивающихся рынках на длительных горизонтах, и на внутридневных данных большинства рынков иногда обнаруживаются статистически значимые корреляции -> есть подтверждения неэффективности рынка; Momentum эффекты (продолжение тренда на горизонте 3-12 месяцев) и reversal эффекты (разворот на коротких и длинных горизонтах) противоречат слабой форме EMH. Полусильная форма Полусильная форма расширяет информационное множество до всей публичной информации: финансовые отчеты, новости, макроэкономические данные, аналитические отчеты. Согласно ней, цены мгновенно и корректно реагируют на публикацию новой информации. Однако классический фундаментальный анализ сегодня почти не создает преимуществ, поскольку вся публичная информация уже учтена в цене. Исследование событий (event studies) — основной метод проверки полусильной формы эффективности рынка. В его рамках анализируются отклонения доходности от нормального уровня в периоды, связанные с корпоративными событиями: публикацией отчетности, выплатой дивидендов, дроблением акций, слияниями и поглощениями. Если рынок эффективен, необычная (аномальная) доходность возникает лишь в момент объявления новости и исчезает сразу после того, как информация отражается в рыночной цене. Результаты исследований событий дают неоднозначные выводы: Так называемый эффект запаздывания реакции на публикацию отчетности (Post-earnings announcement drift, PEAD) показывает, что акции компаний, чьи финансовые результаты оказались лучше ожиданий, продолжают расти в течение до 30-60 дней после объявления. Это противоречит полусильной форме эффективности рынка и указывает на наличие возможности получения повышенной прибыли; Аналогично, акции компаний, объявивших о выкупе собственных акций (buyback), часто демонстрируют избыточную доходность в последующие месяцы. Сильная форма Сильная форма эффективности рынка утверждает, что цены отражают всю информацию, включая инсайдерскую. Даже участники с доступом к непубличной информации не могут систематически получать избыточную доходность. Эта форма наименее реалистична и противоречит наблюдаемым фактам. Эмпирические исследования однозначно отвергают сильную форму: Инсайдеры компаний (топ-менеджмент, директора) получают избыточную доходность на покупках собственных акций. Регуляторы требуют раскрытия инсайдерских сделок именно потому, что признают информационное преимущество; Хедж-фонды с доступом к альтернативным источникам данных обычно имеют доходность выше средней по рынку. Ограничения арбитража ограничивают возможность быстрой корректировки рыночных неэффективностей. Однако проведение арбитражных сделок требует значительных финансовых ресурсов, а участники рынка сталкиваются с риском того, что цены могут продолжить расходиться вопреки ожиданиям, а также с ограничениями на короткие продажи. В результате временные неэффективности могут сохраняться на рынке продолжительное время, даже несмотря на наличие инвесторов, стремящихся их использовать. Критика гипотезы эффективного рынка Эта теория подвергается критике с нескольких направлений: Поведенческая финансовая теория указывает на систематические когнитивные искажения участников; Эмпирические исследования документируют рыночные аномалии, которые сохраняются в течение длительных периодов (3, 5, и даже 10 лет); Финансовые кризисы демонстрируют периоды массовой иррациональности, несовместимой с концепцией эффективности. Поведенческие аномалии Эффект импульса (momentum-эффект) — одна из самых устойчивых аномалий на финансовых рынках. Акции, показавшие высокую доходность за предыдущие 3–6 месяцев, как правило, продолжают опережать рынок и в последующие 3–6 месяцев. Этот эффект наблюдается на большинстве рынков и во всех классах активов. Основное объяснение заключается в том, что инвесторы недостаточно быстро реагируют на новую информацию, проявляя излишний консерватизм и склонность опираться на уже сложившиеся ожидания. Эффекты разворота (reversal-эффекты) проявляются как на коротких, так и на длинных временных интервалах: На коротком горизонте (около недели) они связаны с микроструктурными особенностями рынка — колебаниями между ценами покупки и продажи, а также управлением позициями маркет-мейкеров; На длительном горизонте (в несколько месяцев) эффект разворота объясняется переоценкой активов инвесторами и последующей коррекцией цен, возникающей из-за их чрезмерной реакции на прошлые события. Календарные аномалии включают несколько устойчивых эффектов: Эффект января проявляется в повышенной доходности в начале года, особенно у акций компаний с малой капитализацией; Эффект понедельника выражается в тенденции к отрицательной доходности в начале недели; Эффект смены месяца — в росте цен в последние и первые дни месяца. После того как эти закономерности стали широко известны, их выраженность снизилась, однако они не исчезли полностью, что указывает на существующие ограничения для арбитража и неполную эффективность рынка. Рыночные неэффективности Финансовые пузыри и кризисы — наиболее наглядные примеры отклонения рынков от эффективности. «Дотком-пузырь» 1999–2000 годов, ипотечный пузырь 2006–2007 годов и последовавший за ним мировой финансовый кризис демонстрируют массовую переоценку активов, которую невозможно объяснить рациональными ожиданиями. В течение нескольких лет рыночные цены значительно отклонялись от фундаментальных показателей, несмотря на то что вся необходимая информация была публично доступна. Исследования в областях ограничений ля арбитража объясняют, почему рыночные неэффективности могут сохраняться длительное время: Короткие продажи сопряжены с высокими затратами и ограничениями со стороны регуляторов; Непредсказуемые действия спекулянтов создают дополнительные колебания цен, увеличивая риск для арбитражеров; Требования к марже и возможность принудительного закрытия позиций ограничивают временной горизонт инвестиций. В итоге даже рациональные участники не способны полностью устранить переоценку или недооценку активов. Альтернативные модели Модель рынка с названием Adaptive Market Hypothesis (AMH) Эндрю Ло предлагает эволюционную перспективу: Рыночная эффективность не постоянна, а зависит от среды, числа участников и доступности возможностей для прибыли. Когда появляется новая неэффективность, она привлекает капитал и конкуренцию, что приводит к ее исчезновению. Затем возникают новые источники альфа-доходности. AMH объясняет изменчивость аномалий во времени. Например, эффект импульса усиливается после периодов низкой волатильности и ослабевает после кризисов, а премия за «ценность» (value premium) изменяется в зависимости от макроэкономического цикла. Рынок постоянно адаптируется, что делает необходимым использование динамических стратегий вместо статичных правил инвестирования. Практические следствия для трейдинга Гипотеза эффективного рынка оказывает прямое влияние на механику построения торговых систем: Если рынок эффективен, активное управление бессмысленно — следует инвестировать пассивно в диверсифицированный портфель; Если эффективность неполная, возникают возможности для генерации альфа-доходности, однако требуется идентификация специфических неэффективностей и построение стратегий их эксплуатации. Пассивные стратегии Если исходить из полусильной формы гипотезы эффективного рынка, оптимальной стратегией становится долгосрочное индексное инвестирование. Покупка рыночного портфеля (например, S&P 500 или MSCI World) позволяет минимизировать издержки и получить доходность, соответствующую рынку в целом. Все предельно просто. Тем не менее, эмпирические исследования показывают, что 80–90% фондов уступают соответствующему индексу после вычета комиссий на горизонте свыше десяти лет. То есть ключевой риск инвестора тут смещается с фокуса выбора акций на выбор конкретной управляющей компании или ETF. Пассивный подход обоснован несколькими факторами: Транзакционные издержки при активной торговле снижают итоговую доходность. Комиссии управляющих фондов обычно составляют 0,5–2% годовых; Рыночное воздействие крупных ордеров дополнительно снижает прибыль. В совокупности эти расходы создают значительный барьер для получения положительной альфы. Стратегии «умного бета» (smart beta) представляют собой промежуточный подход между пассивным и активным инвестированием. Вместо стандартного взвешивания по капитализации применяются альтернативные схемы: Равное распределение (equal weight); Минимальная волатильность (minimum variance); Стратегия основе факторов (value, momentum, quality). Такие стратегии систематически отклоняются от рыночного портфеля, используя документированные премии за риск. Активное управление Активное управление имеет смысл только при наличии устойчивого преимущества и способности получать, а также использовать информацию или технологии эффективнее других участников рынка. Источники такого преимущества включают альтернативные данные (инсайдерская информация, сентимент-анализ, спутниковые снимки, транзакции по кредитным картам), продвинутые методы машинного обучения и инфраструктурные преимущества, например близость к бирже или каналы с низкой задержкой. Поиск рыночных неэффективностей требует высокой специализации и умением четко выбирать стратегию: Стратегии по сравнению акций (cross-sectional equity strategies) сосредоточены на относительной оценке стоимости отдельных бумаг; Статистический арбитраж (statistical arbitrage) использует краткосрочные отклонения от исторических корреляций; Макростратегии (macro strategies) извлекают прибыль из структурных дисбалансов между странами и классами активов; Стратегии, ориентированные на события (event-driven strategies), зарабатывают на корпоративных событиях. Ключевым фактором является емкость стратегии (capacity). Небольшая рыночная неэффективность может приносить высокую прибыль при управлении $10 млн, но исчезает при масштабировании до $300 млн из-за влияния крупных сделок на рынок. Это объясняет эффект убывающей доходности с ростом масштаба в управлении активами: малые фонды часто демонстрируют более высокую доходность с поправкой на риск. Алгоритмический трейдинг Существует несколько популярных стратегий алготрейдинга: Статистический арбитраж основывается на явлениях возврата к среднему (mean reversion) между коррелированными активами; Стратегия парного трейдинга (pairs trading) выявляет пары акций с исторической коинтеграцией и торгует отклонения от равновесного спреда; Стратегии на корзинах (basket strategies) расширяют этот подход на портфели из десятков инструментов. Горизонт удержания позиций обычно составляет от нескольких часов до нескольких недель. Есть еще одна - стратегия маркет-мейкинга (market making). Ею обычно пользуются профессиональные институциональные участники рынка. Стратегия маркет-мейкинга обеспечивает ликвидность на рынке, а ее организаторы получают прибыль за счет разницы между ценой покупки и продажи (bid-ask spread). Алгоритм выставляет лимитные ордера с обеих сторон рынка и управляет риском портфеля (inventory risk). Доход формируется за счет микроструктурных факторов: неблагоприятного отбора сделок (adverse selection), дисбаланса потоков ордеров и волатильности. Для успеха требуется минимизация задержек (latency) и эффективное управление рисками. Доказывают ли эти стратегии что рынок не эффективен? Не факт. Они не противоречат гипотезе эффективного рынка напрямую. Статистический арбитраж использует краткосрочные отклонения, которые быстро устраняются рыночными механизмами. Маркет-мейкинг получает прибыль за счет предоставления ликвидности и принятия риска портфеля, а не за счет систематического предугадывания цен. Главное отличие от классической интерпретации EMH заключается в том, что эффективность достигается через активность участников рынка, а не существует как внешнее свойство. Заключение Гипотеза эффективного рынка является основой современной финансовой теории, однако реальная картина гораздо сложнее академической модели. Три формы EMH задают диапазон от минимальной эффективности, учитывающей лишь исторические цены, до максимальной, включающей всю информацию, в том числе инсайдерскую. Эмпирические данные подтверждают слабую форму на развитых рынках при дневных интервалах, но полусильная и сильная формы отвергаются из-за зафиксированных аномалий и фактов успешной торговли инсайдерами. Практическая ценность гипотезы эффективного рынка заключается не в ее верности или точности, а в определении границ возможного: Современные финансовые рынки достаточно эффективны. Систематическое получение альфа-доходности на таких рынках является крайне сложной задачей, требующей специализации, технологических преимуществ или доступа к уникальной информации; Для большинства участников барьеры слишком велики и рационально выбрать пассивное инвестирование; Активные стратегии инвестирования на таких рынках требуют четкой идентификации преимуществ и постоянной адаптации к изменяющимся рыночным условиям. На мой взгляд, ключ к успеху на рынках заключается в понимании одного простого тезиса: Сегодня рынки хаотичны и слабопредсказуемы, и ни одна стратегия не гарантирует легкой победы. Простые аномалии быстро исчезают, а настоящие возможности требуют наблюдательности, инноваций и умения адаптироваться. Тот, кто умеет видеть скрытые паттерны и строить эффективные, устойчивые модели, в конечном счете добьется преимущества. При этом нужно помнить: любое найденное преимущество носит временный характер. ### Продвинутые способы кросс-валидации, разделения выборок рядов: Expanding Window Splitter, Blocked Time Series Split и другие Традиционно в задачах прогнозирования для разделения выборок временных рядов используется метод TimeSeriesSplit из библиотеки scikit-learn. Этот подход гарантирует сохранение хронологической последовательности: обучающая выборка всегда предшествует тестовой, исключая утечку информации из будущего. TimeSeriesSplit создает несколько последовательных сплитов с постепенно расширяющимся окном обучения, что позволяет надежно оценить обобщающую способность модели на будущих данных и является стандартом в большинстве прикладных задач машинного обучения. Однако в ряде случаев, особенно при работе со сложными финансовыми рядами, высокочастотными данными или в условиях выраженной автокорреляции и структурных сдвигов, стандартного TimeSeriesSplit оказывается недостаточно для построения устойчивой прогнозной модели с высоким качеством. Возникает потребность в более тонких стратегиях валидации, которые учитывают перекрытие информации, блокирование временных сегментов или очищение от пересекающихся наблюдений. Именно поэтому специалисты прибегают к нестандартным методам — ExpandingWindowSplitter, Purged Cross-Validation, Blocked Time Series Split и другим, о которых мы подробно расскажем в этой статье. Expanding Window Splitter В отличие от TimeSeriesSplit, где размер обучающего окна на каждом фолде остается фиксированным или растет по строгой схеме с непересекающимися тестовыми блоками, ExpandingWindowSplitter использует постепенно расширяющееся обучающее окно с фиксированным или настраиваемым шагом приращения. Тестовые сегменты тут так же следуют непосредственно за текущим обучающим окном, но их размер и частота могут варьироваться в зависимости от настроек. Такое разбиение особенно эффективно для моделей, зависящих от накопленной статистики — например, моделей волатильности (GARCH), оценки спредов, факторных моделей риска или любых систем, где качество прогноза улучшается с увеличением объема исторических данных. Обучающая выборка растет по мере продвижения во времени, что точно отражает реальную практику: чем больше данных доступно на момент принятия решения, тем устойчивее и точнее становятся оценки параметров модели. Принцип работы Пусть временной ряд имеет длину N. Первое разбиение использует первые T точек для обучения и следующие H для теста. Далее длина обучающего сегмента увеличивается, тестовый сегмент сдвигается вперед, и цикл повторяется: Сначала: Обучающее окно: [0 : T], Тестовое окно: [T : T+H]; Далее: Обучающее окно: [0 : T+H], Тестовое окно: [T+H : T+2H]. Этот подход сохраняет историческую непрерывность и избегает внезапных изменений «репрезентативности» обучающей выборки. Пример кода, демонстрирующий особенности метода на временных рядах казначейских облигаций США: import yfinance as yf import pandas as pd import matplotlib.pyplot as plt import numpy as np from sklearn.model_selection import TimeSeriesSplit # Загрузка данных data = yf.download("^FVX", start="2015-01-01", end="2025-10-31") ts = data["Close"].dropna() dates = ts.index # Функция ExpandingWindowSplitter def expanding_window_split(series, initial_train, step_size, test_size, gap=0): splits = [] train_end = initial_train while train_end + test_size + gap <= len(series): train_start = 0 test_start = train_end + gap test_end = test_start + test_size splits.append({ 'train': (train_start, train_end), 'test': (test_start, test_end) }) train_end += step_size # ключевой параметр return splits # Параметры initial_train = 400 test_size = 100 # Вариант A: TimeSeriesSplit (step == test_size) tscv = TimeSeriesSplit(n_splits=5, test_size=test_size) tscv_splits = [(np.arange(0, i*(initial_train//5) + initial_train), np.arange(i*(initial_train//5) + initial_train, i*(initial_train//5) + initial_train + test_size)) for i in range(1, 6)] # Вариант B: Expanding + перекрытие (step < test_size) overlap_splits = expanding_window_split(ts, initial_train, step_size=75, test_size=test_size) # Вариант C: Expanding + пропуск (step > test_size + gap) gap_splits = expanding_window_split(ts, initial_train, step_size=90, test_size=test_size, gap=20) # Визуализация fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True) strategies = [ ("TimeSeriesSplit (scikit-learn)", tscv_splits[:5], "Фиксированный шаг = test_size"), ("ExpandingWindow + Перекрытие", overlap_splits[:5], "step=75, test=100 → перекрытие"), ("ExpandingWindow + Пропуск", gap_splits[:5], "step=90, test=100, gap=20 → пропуск") ] for ax, (title, splits, subtitle) in zip(axes, strategies): for i, split in enumerate(splits): if isinstance(split, dict): train_slice = slice(split['train'][0], split['train'][1]) test_slice = slice(split['test'][0], split['test'][1]) else: train_slice = slice(split[0][0], split[0][-1]+1) test_slice = slice(split[1][0], split[1][-1]+1) train_dates = dates[train_slice] test_dates = dates[test_slice] # Train ax.fill_between(train_dates, i, i + 0.8, color='skyblue', edgecolor='navy', alpha=0.8) # Test ax.fill_between(test_dates, i, i + 0.8, color='lightcoral', edgecolor='crimson', alpha=0.9) # Подписи только на первом и последнем if i == 0: ax.text(dates[train_slice.stop-1], i + 0.4, f"Train → {dates[train_slice.stop-1].date()}", va='center', ha='right', fontsize=8, color='navy') ax.text(dates[test_slice.start], i + 0.4, f"Test", va='center', ha='left', fontsize=8, color='crimson') if i == len(splits) - 1: ax.text(dates[train_slice.stop-1], i + 0.4, f"→ {dates[train_slice.stop-1].date()}", va='center', ha='right', fontsize=8, color='navy') ax.set_yticks(range(len(splits)), [f"Фолд {j+1}" for j in range(len(splits))]) ax.set_title(f"{title}\n{subtitle}", fontsize=12, pad=10) ax.grid(True, axis='x', alpha=0.3) axes[0].legend(['Train', 'Test'], loc="upper left", bbox_to_anchor=(0, 1)) axes[-1].set_xlabel("Дата") plt.tight_layout() plt.subplots_adjust(top=0.92) plt.show() Рис. 1: Сравнение стратегий кросс-валидации для временных рядов. Верхний график: TimeSeriesSplit (scikit-learn) с фиксированным шагом. 2 графика ниже: ExpandingWindowSplitter с перекрытием (step < test_size) и с пропуском данных (gap). Гибкие стратегии позволяют моделировать реальные сценарии бэктестинга, недоступные в стандартном TimeSeriesSplit Преимущества и недостатки метода Плюсы: Накопление знаний - модель использует всю доступную историю, качество растет с увеличением данных; Реалистичность - точно отражает real-world практику: в продакшене у вас всегда есть вся история до текущего момента; Стабильность оценок - больше данных = более устойчивые параметры модели (особенно для GARCH, факторных моделей); Непрерывность - нет резких скачков в составе обучающей выборки, плавное развитие модели; Честная независимая оценка на каждом фолде. Минусы: Растущая сложность - каждый следующий фолд обучается дольше (больше данных = больше времени); Неактуальность данных в train - модель "помнит" устаревшие паттерны, которые могут уже не работать (плохо при concept drift); Дисбаланс фолдов - последние фолды имеют намного больше train данных, чем первые (неравномерность оценок). Обычно метод Expanding Window Splitter используют в кредитном скоринге, математическом моделирования, построении сложных моделей волатильности. В общем везде, где чем больше истории, тем лучше прогноз и важна статистическая устойчивость. Purged & Embargoed Cross-Validation В задачах, где метки или сигналы пересекаются по времени, может возникать перекрытие событий между обучающей и тестовой выборками. Это приводит к утечке информации. Метод Purged & Embargoed CV удаляет пересекающиеся ряды (purging) и вводит временной «запрет» (embargo) между фолдами. Этот подход особенно важен при моделировании торговых сигналов, построенных на переходах между состояниями или длительных эффектах (holding period). Если не исключить пересечения выборок, то оценка эффективности модели будет завышена, что чревато неприятными сюрпризами в продакшене. Пример кода и визуализации: import yfinance as yf import pandas as pd import matplotlib.pyplot as plt import numpy as np from sklearn.model_selection import TimeSeriesSplit def purged_embargoed_split(n, n_splits, test_size, embargo_pct=0.02, purge_pct=0.04): """ Purged & Embargoed Cross-Validation Параметры: - n: количество наблюдений - n_splits: количество фолдов - test_size: размер test выборки - embargo_pct: процент embargo после test - purge_pct: процент purge перед test """ splits = [] purge_size = int(test_size * purge_pct) embargo_size = int(test_size * embargo_pct) # Расчет шага между фолдами step = (n - test_size - purge_size - embargo_size) // n_splits for i in range(n_splits): test_start = step * (i + 1) test_end = test_start + test_size if test_end + embargo_size > n: break # Train: от начала до purge зоны train_indices = np.arange(0, test_start - purge_size) # Test test_indices = np.arange(test_start, test_end) splits.append((train_indices, test_indices)) return splits # Загрузка данных data = yf.download("^FVX", start="2023-10-31", end="2025-10-31", progress=False) ts = data["Close"].dropna() dates = ts.index # Параметры n_splits = 5 test_size = 100 # 1. Обычный TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=n_splits, test_size=test_size) tscv_splits = list(tscv.split(ts)) # 2. Purged & Embargoed purged_splits = purged_embargoed_split(len(ts), n_splits, test_size, embargo_pct=0.02, purge_pct=0.04) # Визуализация fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True) # График 1: TimeSeriesSplit ax = axes[0] for i, (train, test) in enumerate(tscv_splits): ax.fill_between(dates[train], i, i + 0.8, color='skyblue', edgecolor='navy', alpha=0.7) ax.fill_between(dates[test], i, i + 0.8, color='lightcoral', edgecolor='crimson', alpha=0.9) ax.set_yticks(range(n_splits), [f"Фолд {j+1}" for j in range(n_splits)]) ax.set_title("A) TimeSeriesSplit (стандартный)\nБЕЗ purge/embargo", fontsize=12, fontweight='bold') ax.grid(True, axis='x', alpha=0.3) # График 2: Purged & Embargoed ax = axes[1] for i, (train, test) in enumerate(purged_splits): # Train ax.fill_between(dates[train], i, i + 0.8, color='skyblue', edgecolor='navy', alpha=0.7) # Purge зона (между train и test) purge_start = train[-1] + 1 purge_end = test[0] if purge_end > purge_start: ax.fill_between(dates[purge_start:purge_end], i, i + 0.8, color='orange', edgecolor='darkorange', alpha=0.6) # Test ax.fill_between(dates[test], i, i + 0.8, color='lightcoral', edgecolor='crimson', alpha=0.9) # Embargo зона (после test) embargo_size = int(test_size * 0.01) embargo_start = test[-1] + 1 embargo_end = min(embargo_start + embargo_size, len(dates)) if embargo_end > embargo_start: ax.fill_between(dates[embargo_start:embargo_end], i, i + 0.8, color='yellow', edgecolor='gold', alpha=0.6) ax.set_yticks(range(len(purged_splits)), [f"Фолд {j+1}" for j in range(len(purged_splits))]) ax.set_title("B) Purged & Embargoed Cross-Validation \n" + "С purge (4%) и embargo (2%)", fontsize=12, fontweight='bold') ax.grid(True, axis='x', alpha=0.3) ax.set_xlabel("Дата", fontsize=11) # Легенда from matplotlib.patches import Patch legend_elements = [ Patch(facecolor='skyblue', edgecolor='navy', label='Train'), Patch(facecolor='lightcoral', edgecolor='crimson', label='Test'), Patch(facecolor='orange', edgecolor='darkorange', label='Purge'), Patch(facecolor='yellow', edgecolor='gold', label='Embargo') ] axes[1].legend(handles=legend_elements, loc='upper left', fontsize=10) plt.tight_layout() plt.show() Рис. 2: Сравнение TimeSeriesSplit и Purged & Embargoed Cross-Validation. Стандартный подход не защищен от возможной утечки данных, в то время как Purged & Embargoed CV (B) использует защитные зоны purge и embargo для предотвращения перекрытия событий между train и test выборками Преимущества и недостатки метода Плюсы: Предотвращает все возможные варианты утечки данных - purge удаляет перекрывающиеся события между train и test (holding periods), а embargo создает временной буфер, что исключает влияние признаков с возможными значениями из будущего на модель; Честная оценка - дает реалистичную оценку модели без завышения метрик из-за пересечений. Минусы: Меньше данных для train - purge и embargo удаляют значительную часть наблюдений (до 15%), из-за чего модель может хуже учиться; Сложность настройки - нужно правильно подобрать процент purge/embargo; Больше вычислений - дополнительная логика для вычисления перекрытий событий; Требует метаданные - нужны временные метки окончания событий (t1), которые не всегда доступны. Blocked Time Series Split Этот метод разрезает ряд на последовательные блоки одинакового размера. Блоки используются как фолды. Ряд разбивается на K блоков: Блоки: B1, B2, B3, ..., BK Для оценки используется комбинация блоков (например, обучение на B1+B2+B3 и тест на B4). Метод Blocked Time Series Split снижает влияние автокорреляции, характерной для финансовых временных рядов. Когда важно оценить переносимость модели в условиях похожей структуры зависимости, разбиение на блоки позволяет избежать тесной связности соседних наблюдений. Пример кода: import yfinance as yf import pandas as pd import matplotlib.pyplot as plt import numpy as np from sklearn.model_selection import TimeSeriesSplit def blocked_time_series_split(n, n_blocks): """ Blocked Time Series Split Параметры: - n: количество наблюдений - n_blocks: количество блоков """ block_size = n // n_blocks splits = [] for i in range(1, n_blocks): # Train: все блоки до текущего train_end = i * block_size train_indices = np.arange(0, train_end) # Test: текущий блок test_start = train_end test_end = min(test_start + block_size, n) test_indices = np.arange(test_start, test_end) splits.append((train_indices, test_indices)) return splits # Загрузка данных data = yf.download("^FVX", start="2019-01-01", end="2025-10-31", progress=False) ts = data["Close"].dropna() dates = ts.index # Параметры n_splits = 5 n_blocks = 6 # Для blocked split # 1. Обычный TimeSeriesSplit tscv = TimeSeriesSplit(n_splits=n_splits, test_size=100) tscv_splits = list(tscv.split(ts)) # 2. Blocked Time Series Split blocked_splits = blocked_time_series_split(len(ts), n_blocks) # Визуализация fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True) # График 1: TimeSeriesSplit ax = axes[0] for i, (train, test) in enumerate(tscv_splits): ax.fill_between(dates[train], i, i + 0.8, color='skyblue', edgecolor='navy', alpha=0.7) ax.fill_between(dates[test], i, i + 0.8, color='lightcoral', edgecolor='crimson', alpha=0.9) ax.set_yticks(range(n_splits), [f"Фолд {j+1}" for j in range(n_splits)]) ax.set_title("A) TimeSeriesSplit (стандартный)\nПеременный размер train, фиксированный test", fontsize=12, fontweight='bold') ax.grid(True, axis='x', alpha=0.3) # График 2: Blocked Time Series Split ax = axes[1] # Сначала показываем все блоки block_size = len(ts) // n_blocks colors_palette = plt.cm.Set3(np.linspace(0, 1, n_blocks)) for block_idx in range(n_blocks): block_start = block_idx * block_size block_end = min(block_start + block_size, len(dates)) # Рисуем тонкую полоску для всех блоков на фоне ax.fill_between(dates[block_start:block_end], -0.5, -0.2, color=colors_palette[block_idx], edgecolor='black', alpha=0.5, linewidth=1) ax.text(dates[block_start + (block_end-block_start)//2], -0.35, f'B{block_idx+1}', ha='center', va='center', fontsize=8, fontweight='bold') # Теперь рисуем фолды for i, (train, test) in enumerate(blocked_splits): ax.fill_between(dates[train], i, i + 0.8, color='skyblue', edgecolor='navy', alpha=0.7) ax.fill_between(dates[test], i, i + 0.8, color='lightcoral', edgecolor='crimson', alpha=0.9) ax.set_yticks(range(len(blocked_splits)), [f"Фолд {j+1}" for j in range(len(blocked_splits))]) ax.set_title("B) Blocked Time Series Split\n" + f"Данные разделены на {n_blocks} блоков равного размера", fontsize=12, fontweight='bold') ax.set_ylim(-0.6, len(blocked_splits)) ax.grid(True, axis='x', alpha=0.3) ax.set_xlabel("Дата", fontsize=11) # Легенда from matplotlib.patches import Patch legend_elements = [ Patch(facecolor='skyblue', edgecolor='navy', label='Train'), Patch(facecolor='lightcoral', edgecolor='crimson', label='Test'), Patch(facecolor='gray', alpha=0.5, label='Блоки (B1...B6)') ] axes[1].legend(handles=legend_elements, loc='upper left', fontsize=10) plt.tight_layout() plt.show() # Статистика print("=" * 80) print("BLOCKED TIME SERIES SPLIT - АНАЛИЗ") print("=" * 80) print() print(f"Размер блока: {block_size} наблюдений") print(f"Количество блоков: {n_blocks}") print() for i, (train, test) in enumerate(blocked_splits): n_train_blocks = len(train) // block_size print(f"Фолд {i+1}: Train = B1...B{n_train_blocks} ({len(train)} наблюдений), " f"Test = B{n_train_blocks+1} ({len(test)} наблюдений)") Рис. 3: Сравнение TimeSeriesSplit и Blocked Time Series Split. Стандартный метод использует переменный размер train с фиксированным test, в то время как Blocked Split (B) делит временной ряд на равные блоки (B1-B6), что снижает влияние автокорреляции и создает четкие границы между train и test Представленный выше график наглядно показывает разницу между методам разделения выборок. А пояснительная таблица позволяет оценить консистентность каждой выборки. ================================================================================ BLOCKED TIME SERIES SPLIT - АНАЛИЗ ================================================================================ Размер блока: 286 наблюдений Количество блоков: 6 Фолд 1: Train = B1...B1 (286 наблюдений), Test = B2 (286 наблюдений) Фолд 2: Train = B1...B2 (572 наблюдений), Test = B3 (286 наблюдений) Фолд 3: Train = B1...B3 (858 наблюдений), Test = B4 (286 наблюдений) Фолд 4: Train = B1...B4 (1144 наблюдений), Test = B5 (286 наблюдений) Фолд 5: Train = B1...B5 (1430 наблюдений), Test = B6 (286 наблюдений) Преимущества и недостатки метода Плюсы: Снижение эффекта автокорреляций - блоки создают естественные границы, уменьшая влияние связности соседних наблюдений; Оптимально для рядов с выраженной сезонностью. Когда известно, что есть четкая сезонность (например, циклы внутри года), то можно определять размеры тестовых фолдов в определенный горизонт (год); Равномерное распределение - сбалансированные фолды легче анализировать и интерпретировать. Минусы: Потеря временной близости - может пропустить краткосрочные паттерны на границах блоков; Жесткость - размер блока фиксирован, не адаптируется к изменениям волатильности или частоте данных; Граничные эффекты - разрыв между блоками может исключить важные переходные периоды; Меньше контекста - первые блоки обучаются на меньшем объеме истории, чем в Expanding Window или TimeSeriesSplit. Метод Blocked Time Series Split обычно применяют в моделях с сильной автокорреляцией временных рядов, сильной сезонностью, а также в HFT (высокочастотном трейдинге). Nested Cross-Validation для временных рядов Nested CV позволяет разделить оценку модели на два уровня: Внешний (оценка качества); Внутренний (подбор параметров). Временная структура сохраняется в обоих уровнях. Такой подход дает более устойчивую оценку моделей, чувствительных к гиперпараметрам. Принцип работы метода Nested CV следующий: Внешний цикл формирует обучающие и тестовые фолды по времени; Для каждого обучающего фолда запускается внутренний цикл разбиений (например, Expanding Window) для подбора гиперпараметров. Пример схемы разбиения: import yfinance as yf import pandas as pd import matplotlib.pyplot as plt import numpy as np from sklearn.model_selection import TimeSeriesSplit def nested_cv_split(n, n_outer_splits=3, n_inner_splits=3): """ Nested Cross-Validation для временных рядов Параметры: - n: количество наблюдений - n_outer_splits: количество внешних фолдов (оценка качества) - n_inner_splits: количество внутренних фолдов (подбор параметров) """ outer_splits = [] # Внешний цикл: делим данные на крупные фолды outer_tscv = TimeSeriesSplit(n_splits=n_outer_splits) for outer_idx, (outer_train, outer_test) in enumerate(outer_tscv.split(range(n))): # Внутренний цикл: подбор параметров на outer_train inner_splits = [] inner_tscv = TimeSeriesSplit(n_splits=n_inner_splits) for inner_train, inner_val in inner_tscv.split(outer_train): # Преобразуем локальные индексы во глобальные global_inner_train = outer_train[inner_train] global_inner_val = outer_train[inner_val] inner_splits.append((global_inner_train, global_inner_val)) outer_splits.append({ 'outer_train': outer_train, 'outer_test': outer_test, 'inner_splits': inner_splits }) return outer_splits def standard_cv_split(n, n_splits=3): """ Стандартный TimeSeriesSplit (одноуровневый) """ tscv = TimeSeriesSplit(n_splits=n_splits) return list(tscv.split(range(n))) # Загрузка данных data = yf.download("^FVX", start="2022-10-31", end="2025-10-31", progress=False) ts = data["Close"].dropna() dates = ts.index # Параметры n_outer = 3 n_inner = 3 # 1. Стандартный одноуровневый CV standard_splits = standard_cv_split(len(ts), n_splits=n_outer) # 2. Nested CV nested_splits = nested_cv_split(len(ts), n_outer_splits=n_outer, n_inner_splits=n_inner) # Визуализация fig, axes = plt.subplots(2, 1, figsize=(14, 10), sharex=True) # График 1: Стандартный CV ax = axes[0] for i, (train, test) in enumerate(standard_splits): ax.fill_between(dates[train], i, i + 0.8, color='skyblue', edgecolor='navy', alpha=0.7) ax.fill_between(dates[test], i, i + 0.8, color='lightcoral', edgecolor='crimson', alpha=0.9) ax.set_yticks(range(len(standard_splits)), [f"Фолд {j+1}" for j in range(len(standard_splits))]) ax.set_title("A) Стандартный TimeSeriesSplit (одноуровневый)\n" + "Подбор параметров и оценка качества на одних и тех же разбиениях", fontsize=12, fontweight='bold') ax.grid(True, axis='x', alpha=0.3) ax.legend(['Train', 'Test'], loc='upper left', fontsize=10) # График 2: Nested CV ax = axes[1] row_idx = 0 for outer_idx, outer_split in enumerate(nested_splits): outer_train = outer_split['outer_train'] outer_test = outer_split['outer_test'] inner_splits = outer_split['inner_splits'] # Рисуем внешний test (финальная оценка) ax.fill_between(dates[outer_test], row_idx, row_idx + 0.8, color='lightcoral', edgecolor='crimson', alpha=0.9, label='Outer Test (оценка)' if outer_idx == 0 else '') # Подпись для внешнего фолда ax.text(dates[0], row_idx + 0.4, f'Outer {outer_idx+1}', fontsize=9, fontweight='bold', va='center') row_idx += 1 # Рисуем внутренние разбиения (подбор параметров) for inner_idx, (inner_train, inner_val) in enumerate(inner_splits): # Inner train ax.fill_between(dates[inner_train], row_idx, row_idx + 0.6, color='lightblue', edgecolor='blue', alpha=0.6, label='Inner Train' if outer_idx == 0 and inner_idx == 0 else '') # Inner validation ax.fill_between(dates[inner_val], row_idx, row_idx + 0.6, color='lightyellow', edgecolor='orange', alpha=0.8, label='Inner Val (подбор)' if outer_idx == 0 and inner_idx == 0 else '') # Подпись для внутреннего фолда ax.text(dates[0], row_idx + 0.3, f' Inner {inner_idx+1}', fontsize=8, va='center', style='italic') row_idx += 1 # Пустая строка между внешними фолдами row_idx += 0.3 ax.set_yticks([]) ax.set_title("B) Nested Cross-Validation (двухуровневый) \n" + "Внешний цикл = оценка качества, Внутренний цикл = подбор гиперпараметров", fontsize=12, fontweight='bold') ax.set_ylim(-0.5, row_idx) ax.grid(True, axis='x', alpha=0.3) ax.set_xlabel("Дата", fontsize=11) # Легенда ax.legend(loc='upper left', fontsize=10, ncol=2) plt.tight_layout() plt.show() # Статистика print("=" * 80) print("NESTED CROSS-VALIDATION - АНАЛИЗ") print("=" * 80) print() for outer_idx, outer_split in enumerate(nested_splits): outer_train = outer_split['outer_train'] outer_test = outer_split['outer_test'] inner_splits = outer_split['inner_splits'] print(f"OUTER FOLD {outer_idx+1}:") print(f" Outer Train: {len(outer_train)} наблюдений") print(f" Outer Test: {len(outer_test)} наблюдений (финальная оценка)") print(f" ") print(f" Внутренние разбиения (подбор параметров на outer train):") for inner_idx, (inner_train, inner_val) in enumerate(inner_splits): print(f" Inner Fold {inner_idx+1}:") print(f" Train: {len(inner_train)} наблюдений") print(f" Val: {len(inner_val)} наблюдений") print() Рис. 4: Сравнение стандартного CV и Nested Cross-Validation. Одноуровневый подход (A) использует одни и те же разбиения для подбора параметров и оценки качества, и не пригоден для честного подбора гиперпараметров. Nested CV (B) разделяет эти задачи: внешний цикл оценивает качество на независимых данных, а внутренний цикл подбирает оптимальные гиперпараметры на обучающей выборке ================================================================================ NESTED CROSS-VALIDATION - АНАЛИЗ ================================================================================ OUTER FOLD 1: Outer Train: 189 наблюдений Outer Test: 188 наблюдений (финальная оценка) Внутренние разбиения (подбор параметров на outer train): Inner Fold 1: Train: 48 наблюдений Val: 47 наблюдений Inner Fold 2: Train: 95 наблюдений Val: 47 наблюдений Inner Fold 3: Train: 142 наблюдений Val: 47 наблюдений OUTER FOLD 2: Outer Train: 377 наблюдений Outer Test: 188 наблюдений (финальная оценка) Внутренние разбиения (подбор параметров на outer train): Inner Fold 1: Train: 95 наблюдений Val: 94 наблюдений Inner Fold 2: Train: 189 наблюдений Val: 94 наблюдений Inner Fold 3: Train: 283 наблюдений Val: 94 наблюдений OUTER FOLD 3: Outer Train: 565 наблюдений Outer Test: 188 наблюдений (финальная оценка) Внутренние разбиения (подбор параметров на outer train): Inner Fold 1: Train: 142 наблюдений Val: 141 наблюдений Inner Fold 2: Train: 283 наблюдений Val: 141 наблюдений Inner Fold 3: Train: 424 наблюдений Val: 141 наблюдений Таким образом, мы видим что обучение гиперпараметров происходит только внутри внешнего фолда, что снижает переобучение и делает ML-модель более устойчивой к будущим данным. Преимущества метода Nested CV: Разделяет подбор параметров и оценку качества; Предотвращает переобучение при оптимизации гиперпараметров; Дает более честную оценку обобщающей способности модели; Сохраняет временную структуру данных на обоих уровнях. Минус метода - значительно сокращение обучающей выборки, что может привести к тому, что модель при обучении не увидит достаточно данных для поиска закономерностей и различных паттернов. Sliding Window with Overlapping Test Segments Данный метод использует фиксированное окно обучения и перекрывающиеся тестовые сегменты. Подходит, когда важно измерять стабильность метрик во времени и быстро обновлять модель. import yfinance as yf import pandas as pd import matplotlib.pyplot as plt import numpy as np def sliding_window_overlapping(n, window_size, test_size, step_size): """ Sliding Window with Overlapping Test Segments Параметры: - n: количество наблюдений - window_size: фиксированный размер окна обучения - test_size: размер тестового сегмента - step_size: шаг сдвига окна (если < test_size, то перекрытие) """ splits = [] start = 0 while start + window_size + test_size n: break # Train растет от начала до test train_indices = np.arange(0, test_start) test_indices = np.arange(test_start, test_end) splits.append((train_indices, test_indices)) return splits # Загрузка данных data = yf.download("^FVX", start="2023-01-01", end="2025-10-31", progress=False) ts = data["Close"].dropna() dates = ts.index # Параметры window_size = 400 test_size = 100 step_size = 50 # < test_size → перекрытие test сегментов initial_train = 400 # 1. Expanding Window expanding_splits = expanding_window_non_overlapping(len(ts), initial_train=initial_train, test_size=test_size, n_splits=5) # 2. Sliding Window with Overlapping Test Segments sliding_splits = sliding_window_overlapping(len(ts), window_size=window_size, test_size=test_size, step_size=step_size) # Ограничиваем количество фолдов для визуализации sliding_splits = sliding_splits[:10] # Визуализация fig, axes = plt.subplots(2, 1, figsize=(14, 10), sharex=True) # График 1: Expanding Window ax = axes[0] for i, (train, test) in enumerate(expanding_splits): ax.fill_between(dates[train], i, i + 0.8, color='skyblue', edgecolor='navy', alpha=0.7) ax.fill_between(dates[test], i, i + 0.8, color='lightcoral', edgecolor='crimson', alpha=0.9) ax.set_yticks(range(len(expanding_splits)), [f"Фолд {j+1}" for j in range(len(expanding_splits))]) ax.set_title("A) Expanding Window\n" + "Растущий train, test сегменты последовательные БЕЗ пересечений", fontsize=12, fontweight='bold') ax.grid(True, axis='x', alpha=0.3) ax.legend(['Train', 'Test'], loc='upper left', fontsize=10) # График 2: Sliding Window with Overlapping ax = axes[1] # Визуализируем перекрытия test сегментов for i, (train, test) in enumerate(sliding_splits): # Train window ax.fill_between(dates[train], i, i + 0.8, color='skyblue', edgecolor='navy', alpha=0.7) # Test segment ax.fill_between(dates[test], i, i + 0.8, color='lightcoral', edgecolor='crimson', alpha=0.9) # Показываем перекрытие с предыдущим test if i > 0: prev_test = sliding_splits[i-1][1] current_test = test # Находим пересечение overlap = np.intersect1d(prev_test, current_test) if len(overlap) > 0: # Подсвечиваем зону перекрытия ax.fill_between(dates[overlap], i-0.1, i + 0.9, color='yellow', alpha=0.4, edgecolor='orange', linewidth=2, label='Перекрытие test' if i == 1 else '') ax.set_yticks(range(len(sliding_splits)), [f"Фолд {j+1}" for j in range(len(sliding_splits))]) ax.set_title("B) Sliding Window with Overlapping Test Segments \n" + f"Фиксированный train ({window_size}), step={step_size} < test={test_size} → test ПЕРЕСЕКАЮТСЯ", fontsize=12, fontweight='bold') ax.grid(True, axis='x', alpha=0.3) ax.set_xlabel("Дата", fontsize=11) # Легенда from matplotlib.patches import Patch legend_elements = [ Patch(facecolor='skyblue', edgecolor='navy', label='Train'), Patch(facecolor='lightcoral', edgecolor='crimson', label='Test'), Patch(facecolor='yellow', edgecolor='orange', alpha=0.4, label='Перекрытие test сегментов') ] ax.legend(handles=legend_elements, loc='upper left', fontsize=10) plt.tight_layout() plt.show() Рис. 5: Сравнение методов Expanding Window и Sliding Window with Overlapping Test. Expanding Window (A) использует растущую обучающую выборку с последовательными непересекающимися test сегментами (каждый следующий test идет сразу после предыдущего). Sliding Window (B) применяет фиксированное окно обучения и сдвигает его с шагом меньше размера test (step=50 < test=100), создавая перекрытие между тестовыми сегментами (желтые зоны), что позволяет получить больше оценок модели во времени Преимущества и недостатки метода Достоинства метода Sliding Window with Overlapping Test следующие: Больше оценок - перекрытие дает в 2x больше измерений производительности без дополнительных данных; Мониторинг во времени - частые оценки сигнализируют о деградации модели раньше других методов валидации (rolling performance); Фиксированная сложность - постоянный размер train = предсказуемое время обучения и память; Адаптация к изменениям - модель забывает старые, уже не актуальные данные, подстраивается под текущие условия (concept drift); Быстрое обновление - идеально для продакшен систем с частым переобучением. Минусы подхода: корреляция оценок (они не независимы) и возможная утечка данных из будущего (требуется дополнительный код на ее проверки). Данный метод часто применяется в анализе временных рядов в областях прогнозирования спроса, детекции фрода, аномалий, алгоритмической торговле. В общем, везде, где нужен быстрый мониторинг и адаптация к изменениям. Group Time Series Split Метод Group Time Series Split применяется когда данные имеют естественную группировку (например, несколько активов, клиентов, регионов или продуктов), и важно гарантировать, что наблюдения из одной группы не попадают одновременно в обучающую и тестовую выборки. Это важно для предотвращения утечки информации через групповые паттерны. Традиционный TimeSeriesSplit разделяет данные только по времени, игнорируя групповую структуру. Если у нас есть временные ряды для нескольких активов (например, акций, валютных пар, товаров), и мы просто делим по времени, то одна и та же акция может оказаться и в train, и в test на одном временном отрезке. Это приводит к переоценке качества модели из-за корреляций внутри группы. Group Time Series Split решает эту проблему, разделяя группы между train и test, при этом сохраняя временную структуру внутри каждой группы. Существует два основных подхода: Group-based split: Некоторые группы целиком идут в train, другие - в test (на каждом временном окне); Time-then-group split: Сначала делим по времени, затем внутри каждого временного окна делим группы. Второй подход более распространен в финансах, так как позволяет обучаться на исторических данных всех групп, но тестировать на будущем только части групп. import pandas as pd import matplotlib.pyplot as plt import numpy as np def generate_synthetic_multi_asset_data(n_companies=5, start_date='2023-01-01', n_days=700): """ Генерирует синтетические данные """ np.random.seed(42) companies = [f'Company{i+1}' for i in range(n_companies)] dates = pd.date_range(start=start_date, periods=n_days, freq='D') data_list = [] for company in companies: # Генерируем случайные цены с трендом prices = 100 + np.cumsum(np.random.randn(n_days) * 2) returns = np.diff(prices, prepend=prices[0]) / prices[0] df = pd.DataFrame({ 'Close': prices, 'returns': returns, 'company': company }, index=dates) data_list.append(df) data = pd.concat(data_list) data = data.sort_index() return data, companies def group_time_series_split(data, group_col, n_splits=5, test_group_size=0.4): dates = data.index.unique() unique_groups = data[group_col].unique() n_groups = len(unique_groups) n_test_groups = max(1, int(n_groups * test_group_size)) # Создаем временные разбиения n_dates = len(dates) test_size = n_dates // (n_splits + 1) splits = [] # Создаем разные комбинации групп для каждого фолда np.random.seed(42) all_group_combinations = [] for i in range(n_splits): # Для каждого фолда выбираем разные группы для test test_groups = np.random.choice(unique_groups, n_test_groups, replace=False) train_groups = np.setdiff1d(unique_groups, test_groups) all_group_combinations.append((train_groups, test_groups)) for i in range(n_splits): train_end_idx = test_size * (i + 1) test_end_idx = min(test_size * (i + 2), n_dates) train_dates = dates[:train_end_idx] test_dates = dates[train_end_idx:test_end_idx] if len(test_dates) == 0: break train_groups, test_groups = all_group_combinations[i] train_mask = data.index.isin(train_dates) & data[group_col].isin(train_groups) test_mask = data.index.isin(test_dates) & data[group_col].isin(test_groups) train_indices = np.where(train_mask)[0] test_indices = np.where(test_mask)[0] if len(train_indices) > 0 and len(test_indices) > 0: splits.append((train_indices, test_indices, train_groups, test_groups)) return splits def standard_time_series_split_for_groups(data, n_splits=5): """ Стандартный TimeSeriesSplit """ dates = data.index.unique() n_dates = len(dates) test_size = n_dates // (n_splits + 1) splits = [] for i in range(n_splits): train_end_idx = test_size * (i + 1) test_end_idx = min(test_size * (i + 2), n_dates) train_dates = dates[:train_end_idx] test_dates = dates[train_end_idx:test_end_idx] if len(test_dates) == 0: break train_mask = data.index.isin(train_dates) test_mask = data.index.isin(test_dates) train_indices = np.where(train_mask)[0] test_indices = np.where(test_mask)[0] splits.append((train_indices, test_indices)) return splits # Генерация данных data, companies = generate_synthetic_multi_asset_data(n_companies=5, n_days=700) # Создание разбиений standard_splits = standard_time_series_split_for_groups(data, n_splits=5) group_splits = group_time_series_split(data, 'company', n_splits=5, test_group_size=0.4) # Визуализация fig, axes = plt.subplots(2, 1, figsize=(16, 10)) colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd'] # График 1: Стандартный TimeSeriesSplit ax = axes[0] for i, (train_idx, test_idx) in enumerate(standard_splits): train_data = data.iloc[train_idx] test_data = data.iloc[test_idx] y_position = i * 1.5 for comp_idx, company in enumerate(companies): # Train - горизонтальные линии train_comp = train_data[train_data['company'] == company] if len(train_comp) > 0: ax.plot([train_comp.index.min(), train_comp.index.max()], [y_position + comp_idx * 0.25, y_position + comp_idx * 0.25], color=colors[comp_idx], linewidth=4, alpha=0.6, solid_capstyle='butt') # Test - горизонтальные линии test_comp = test_data[test_data['company'] == company] if len(test_comp) > 0: ax.plot([test_comp.index.min(), test_comp.index.max()], [y_position + comp_idx * 0.25, y_position + comp_idx * 0.25], color=colors[comp_idx], linewidth=6, alpha=0.95, solid_capstyle='butt') # Легенда для компаний legend_elements = [plt.Line2D([0], [0], color=colors[i], linewidth=4, label=companies[i], alpha=0.7) for i in range(len(companies))] ax.legend(handles=legend_elements, loc='upper left', ncol=5, fontsize=10, framealpha=0.9, title='Компании') ax.set_yticks([i * 1.5 + 0.5 for i in range(len(standard_splits))], [f"Фолд {j+1}" for j in range(len(standard_splits))]) ax.set_title("A) Стандартный TimeSeriesSplit\n" + "Все группы присутствуют и в train (тонкие линии), и в test (толстые линии)\n" + "→ РИСК УТЕЧКИ через межгрупповые корреляции", fontsize=13, fontweight='bold', pad=15) ax.grid(True, axis='x', alpha=0.3, linestyle='--') ax.set_ylabel('', fontsize=11) ax.set_ylim(-0.3, len(standard_splits) * 1.5) # График 2: Group Time Series Split ax = axes[1] for i, (train_idx, test_idx, train_groups, test_groups) in enumerate(group_splits): train_data = data.iloc[train_idx] test_data = data.iloc[test_idx] y_position = i * 1.5 for comp_idx, company in enumerate(companies): # Train train_comp = train_data[train_data['company'] == company] if len(train_comp) > 0: ax.plot([train_comp.index.min(), train_comp.index.max()], [y_position + comp_idx * 0.25, y_position + comp_idx * 0.25], color=colors[comp_idx], linewidth=4, alpha=0.6, solid_capstyle='butt', label=f'{company} (train)' if i == 0 else '') # Test test_comp = test_data[test_data['company'] == company] if len(test_comp) > 0: ax.plot([test_comp.index.min(), test_comp.index.max()], [y_position + comp_idx * 0.25, y_position + comp_idx * 0.25], color=colors[comp_idx], linewidth=6, alpha=0.95, solid_capstyle='butt') # Аннотация - какие компании в test test_companies = ', '.join(sorted(test_groups)) ax.text(data.index.max(), y_position + 0.5, f'Test: {test_companies}', fontsize=9, va='center', ha='right', bbox=dict(boxstyle='round,pad=0.5', facecolor='yellow', edgecolor='orange', alpha=0.8, linewidth=2)) ax.set_yticks([i * 1.5 + 0.5 for i in range(len(group_splits))], [f"Фолд {j+1}" for j in range(len(group_splits))]) ax.set_title("B) Group Time Series Split ✓\n" + "На каждом фолде РАЗНЫЕ компании в test → проверка обобщения на различные группы\n" + "→ НЕТ пересечения групп между train/test", fontsize=13, fontweight='bold', pad=15) ax.grid(True, axis='x', alpha=0.3, linestyle='--') ax.set_xlabel("Дата", fontsize=12, fontweight='bold') ax.set_ylabel('', fontsize=11) ax.set_ylim(-0.3, len(group_splits) * 1.5) # Общая легенда для типов линий from matplotlib.patches import Patch legend_elements_types = [ plt.Line2D([0], [0], color='gray', linewidth=4, label='Train', alpha=0.6), plt.Line2D([0], [0], color='gray', linewidth=6, label='Test', alpha=0.95) ] ax.legend(handles=legend_elements_types, loc='upper left', fontsize=10, framealpha=0.9) plt.tight_layout() plt.show() # Статистика print("=" * 80) print("GROUP TIME SERIES SPLIT - АНАЛИЗ") print("=" * 80) print() print(f"Всего компаний: {len(companies)}") print(f"Компании: {', '.join(companies)}") print() for i, (train_idx, test_idx, train_groups, test_groups) in enumerate(group_splits): train_dates = data.iloc[train_idx].index test_dates = data.iloc[test_idx].index print(f"Фолд {i+1}:") print(f" Train groups: {', '.join(sorted(train_groups))} ({len(train_groups)} компаний)") print(f" Test groups: {', '.join(sorted(test_groups))} ({len(test_groups)} компании)") print(f" Train: {len(train_idx)} наблюдений ({train_dates.min().date()} - {train_dates.max().date()})") print(f" Test: {len(test_idx)} наблюдений ({test_dates.min().date()} - {test_dates.max().date()})") print() Рис. 6: Сравнение стандартного метода разделения выборок временных рядов TimeSeriesSplit с методом Group Time Series Split для прогнозирования рядов для 5 групп. Стандартный подход (A) разделяет данные только по времени, позволяя всем группам присутствовать в train и test одновременно, что создает риск утечки через межгрупповые корреляции. Group Time Series Split (B) гарантирует, что определенные группы используются только для тестирования на каждом фолде, обеспечивая честную оценку обобщающей способности модели на новые данные ================================================================================ GROUP TIME SERIES SPLIT - АНАЛИЗ ================================================================================ Всего компаний: 5 Компании: Company1, Company2, Company3, Company4, Company5 Фолд 1: Train groups: Company1, Company2, Company3 (3 компаний) Test groups: Company4, Company5 (2 компании) Train: 348 наблюдений (2023-01-01 - 2023-04-26) Test: 232 наблюдений (2023-04-27 - 2023-08-20) Фолд 2: Train groups: Company1, Company3, Company5 (3 компаний) Test groups: Company2, Company4 (2 компании) Train: 696 наблюдений (2023-01-01 - 2023-08-20) Test: 232 наблюдений (2023-08-21 - 2023-12-14) Фолд 3: Train groups: Company2, Company3, Company5 (3 компаний) Test groups: Company1, Company4 (2 компании) Train: 1044 наблюдений (2023-01-01 - 2023-12-14) Test: 232 наблюдений (2023-12-15 - 2024-04-08) Фолд 4: Train groups: Company2, Company3, Company5 (3 компаний) Test groups: Company1, Company4 (2 компании) Train: 1392 наблюдений (2023-01-01 - 2024-04-08) Test: 232 наблюдений (2024-04-09 - 2024-08-02) Фолд 5: Train groups: Company2, Company4, Company5 (3 компаний) Test groups: Company1, Company3 (2 компании) Train: 1740 наблюдений (2023-01-01 - 2024-08-02) Test: 232 наблюдений (2024-08-03 - 2024-11-26) Преимущества и недостатки метода Плюсы: Предотвращает групповую утечку данных в модели - гарантирует независимость между train и test на уровне групп; Тестирует обобщение - оценивает способность модели работать на новых, невиденных группах (активах, клиентах, регионах); Реалистичность - в продакшене часто нужно применять модель к новым сущностям, не участвовавшим в обучении; Снижает переобучение - модель не может использовать специфичные для группы паттерны для "подглядывания" в test. Минусы: Меньше данных - часть групп исключается, либо неравномерно попадает в выборки. Возможен дисбаланс в объемах train/test, что создает риски недообучения модели; Требует достаточно групп - нужно минимум 5-10 групп для разумного разделения; Вариативность - результаты могут зависеть от того, какие конкретно группы попали в test; Усложняет интерпретацию - нужно дополнительно анализировать различия между группами. Group Time Series Split активно используется в следующих областях: Портфельный менеджмент - обучение на одних акциях, тест на других для проверки устойчивости стратегии; Multi-asset trading - модели для торговли несколькими инструментами одновременно; Прогнозирование в ритейле - прогноз продаж для разных магазинов/продуктов; Рекомендательные системы - тестирование на новых пользователях или товарах; Медицина - обобщение моделей с одних пациентов/больниц на другие. Заключение В данной статье мы рассмотрели продвинутые методы кросс-валидации для временных рядов, которые выходят за рамки стандартного TimeSeriesSplit из scikit-learn. Каждый метод решает специфические проблемы, возникающие при работе со сложными финансовыми данными, высокочастотными рядами или задачами с групповой структурой. Правильный выбор метода разбиения выборок в рядах перед стартом машинного обучения напрямую влияет на надежность оценки модели и ее производительность в продакшене. Нестандартные методы требуют больше вычислительных ресурсов и усложняют код, однако обеспечивают более честную и реалистичную оценку качества прогнозных моделей на временных рядах. ### Деревья решений: алгоритм CART, критерии разбиения и практическое применение Деревья решений относятся к фундаментальным алгоритмам машинного обучения, которые находят применение в задачах классификации и регрессии. Их ключевое преимущество — интерпретируемость: модель представляет собой последовательность логических правил, понятных даже неспециалисту. По своей структуре дерево решений имитирует процесс принятия решений человеком, последовательно разбивая данные на все более однородные группы на основе наиболее значимых признаков. Процесс построения такого дерева напоминает игру в «20 вопросов», где каждый узел дерева — это вопрос об особенностях объекта (например, «Возраст больше 30?»), а ответы «да» или «нет» определяют путь к следующему узлу. Алгоритм начинает с корневого узла, который содержит все данные, и рекурсивно разделяет их, выбирая на каждом шаге тот признак, который наилучшим образом разделяет данные по целевому показателю. Этот процесс продолжается до тех пор, пока не будет выполнено условие остановки (например, достигнута максимальная глубина или в узле остались объекты только одного класса), после чего создается листовой узел, содержащий итоговый прогноз. В отличие от нейронных сетей, дерево решений позволяет понять, какие именно признаки и пороговые значения привели к конкретному предсказанию. Это делает алгоритм востребованным инструментом для первичного анализа данных и построения базовых (baseline) моделей, после которых уже пробуют более мощные. Алгоритм CART деревьев решений Алгоритм CART (Classification and Regression Trees), разработанный Брейманом в 1984 году, стал де-факто стандартом построения деревьев решений и лег в основу современных ансамблевых методов — Random Forest и Gradient Boosting. Алгоритм строит бинарное дерево решений через рекурсивное разбиение пространства признаков. На каждом шаге алгоритм выбирает признак и пороговое значение, которые оптимально делят данные на две группы. Процесс продолжается до выполнения критерия остановки: достижения заданной глубины, минимального числа объектов в узле или невозможности дальнейшего улучшения качества. Рекурсивное разбиение пространства признаков CART использует жадную стратегию: на каждом шаге выбирается локально оптимальное разбиение без учета будущих шагов. Для числового признака x алгоритм перебирает все уникальные значения и находит порог t, минимизирующий функцию потерь. Разбиение формирует два дочерних узла: левый содержит объекты с x ≤ t, правый — с x > t. Для категориальных признаков CART преобразует задачу к бинарному виду: перебирает все возможные разбиения категорий на две группы. Если категорий k, количество вариантов составляет 2^(k-1) - 1. Это приводит к комбинаторному взрыву при большом числе категорий, поэтому на практике категориальные признаки кодируют через техники энкодинга: one-hot encoding или target encoding. Процесс разбиения рекурсивно применяется к каждому дочернему узлу. Результат — иерархическая структура, где каждый внутренний узел содержит условие разбиения, а листовые узлы — финальные предсказания. Глубина дерева определяет сложность модели: мелкие деревья дают высокое смещение, глубокие — высокую дисперсию. Рис. 1: Визуализация работы алгоритма Decision Trees. Демонстрирует влияние глубины дерева на сложность разбиения пространства признаков: простое линейное разбиение при глубине 1, сбалансированное при глубине 3, и избыточно сложное без ограничений с признаками переобучения. Черные линии показывают границы решений, цветовые регионы — области Бинарная структура и критерии остановки CART всегда создает бинарные деревья, в отличие от алгоритмов ID3 и C4.5, допускающих множественные разбиения. Бинарная структура упрощает реализацию и снижает риск переобучения: каждое разбиение максимально информативно, так как использует весь датасет текущего узла. Критерии остановки предотвращают избыточный рост дерева: Максимальная глубина (max_depth) ограничивает число уровней от корня до листьев; Минимальное число объектов в узле (min_samples_split) запрещает разбиение малых подвыборок; Минимальное число объектов в листе (min_samples_leaf) предотвращает создание узлов с единичными наблюдениями; Минимальное улучшение критерия (min_impurity_decrease) останавливает разбиение при незначительном приросте качества. Комбинация этих параметров определяет компромисс между точностью и обобщающей способностью модели. Эмпирически max_depth в диапазоне 5-10 обеспечивает баланс для большинства задач, хотя разумеется оптимальные значения зависят от объема данных, поставленной задачи, требований к качеству прогнозов и числа признаков. Жадный алгоритм и его ограничения Жадная стратегия CART оптимизирует каждое разбиение независимо, что гарантирует полиномиальную сложность построения дерева: O(n × m × log n) где: n — число объектов; m — число признаков. Это позволяет обрабатывать датасеты размером до миллионов строк на стандартном оборудовании. Недостаток жадного подхода — локальные оптимумы. Разбиение, неоптимальное на текущем шаге, может привести к лучшему результату после нескольких последующих разбиений. CART не учитывает такие сценарии, что ограничивает качество модели на сложных зависимостях. Ансамблевые методы частично решают проблему через построение множества деревьев с различными локальными оптимумами. Другая особенность Decision trees — неустойчивость к малым изменениям данных. Изменение нескольких наблюдений может радикально изменить структуру дерева, особенно в верхних узлах. Техника Bootstrap aggregating и алгоритм Random Forest снижает эту чувствительность через усреднение предсказаний независимых деревьев. Критерии разбиения узлов Выбор оптимального разбиения требует формализации понятия "качества" узла. CART использует различные критерии в зависимости от типа задачи: меры неопределенности для классификации; функции потерь для регрессии. Критерий вычисляется для родительского и дочерних узлов, разбиение выбирается по максимальному приросту качества. Gini impurity для классификации Gini impurity измеряет вероятность ошибочной классификации случайно выбранного объекта, если его метку назначить согласно распределению классов в узле: G = 1 - Σ(p_i)² где: p_i — доля объектов класса i в узле; Σ — суммирование по всем классам; G ∈ [0, 0.5] для бинарной классификации. Критерий достигает минимума 0 когда все объекты в узле принадлежат одному классу (чистый узел), и максимума 0.5 при равномерном распределении классов. Для многоклассовой задачи максимум составляет (K-1)/K, где K — число классов. При оценке разбиения CART вычисляет взвешенную сумму Gini impurity дочерних узлов: G_split = (n_left/n) × G_left + (n_right/n) × G_right где: n_left и n_right — число объектов в левом и правом дочерних узлах; n — общее число объектов. Прирост качества (information gain) определяется как разность G родительского узла и G_split. Алгоритм выбирает разбиение с максимальным information gain. Gini impurity быстро вычисляется и имеет гладкую производную, что делает его предпочтительным критерием в большинстве реализаций. Альтернативный критерий — энтропия — дает схожие результаты, но требует вычисления логарифмов, что увеличивает время построения дерева на 15-20%. MSE и MAE для регрессии В задачах регрессии CART минимизирует разброс целевой переменной в узлах. Стандартный критерий — среднеквадратичная ошибка (MSE): MSE = (1/n) × Σ(y_i - ȳ)² где: y_i — значение целевой переменной для объекта i; ȳ — среднее значение целевой переменной в узле; n — число объектов в узле. Предсказание в листовом узле равно среднему значению целевой переменной объектов, попавших в этот узел. MSE штрафует большие отклонения сильнее малых из-за квадратичной функции потерь, что делает модель чувствительной к выбросам. Альтернатива — средняя абсолютная ошибка (MAE): MAE = (1/n) × Σ|y_i - ỹ| где ỹ — медиана целевой переменной в узле. MAE более устойчива к выбросам: экстремальные значения влияют на критерий линейно, не доминируя в процессе разбиения. Предсказание в листе равно медиане, что обеспечивает устойчивость к аномалиям в данных. Выбор между MSE и MAE зависит от распределения целевой переменной и бизнес-требований. Для данных с выбросами MAE предпочтительнее, для нормально распределенных переменных MSE обеспечивает лучшую точность. На практике MSE используется чаще из-за вычислительной эффективности: обновление среднего при добавлении объекта требует O(1), обновление медианы — O(log n). Выбор оптимального разбиения Для каждого признака алгоритм перебирает пороговые значения и вычисляет критерий качества. Вычислительная сложность этого этапа определяет скорость построения дерева. Оптимизация сводится к сортировке объектов по значению признака и последовательному вычислению критерия для каждого уникального значения. Пусть признак x принимает n_unique уникальных значений после сортировки; CART вычисляет критерий для n_unique - 1 возможных разбиений: между каждой парой соседних значений; При каждом сдвиге порога один объект перемещается из левого узла в правый, что позволяет обновлять статистики инкрементально без пересчета с нуля. Для классификации при сдвиге объекта класса k обновление Gini impurity требует: Вычитания p_k² из суммы левого узла; Добавления обновленной p_k² после уменьшения числа объектов; Аналогичного обновления правого узла. Такой подход снижает сложность вычисления критерия с O(n) до O(1) для каждого порога, общая сложность составляет O(n × log n) на сортировку плюс O(n) на перебор порогов. Для признаков с большим числом уникальных значений (например, непрерывные величины с высокой точностью) CART может использовать биннинг: разбиение диапазона значений на фиксированное число интервалов. Это снижает число проверяемых порогов с тысяч до десятков, ускоряя построение дерева в 10-50 раз с минимальной потерей качества. Контроль глубины и переобучение Деревья решений склонны к переобучению: без ограничений алгоритм продолжает разбиение до создания листьев с одним объектом. Такое дерево идеально классифицирует обучающую выборку, но имеет нулевую обобщающую способность. Контроль сложности модели реализуется через параметры, ограничивающие рост дерева, или через прунинг — удаление узлов после построения полного дерева. Параметры ограничения роста дерева Библиотека scikit-learn предоставляет набор гиперпараметров для контроля сложности дерева. Ключевые параметры: max_depth — максимальная глубина дерева. Ограничивает число последовательных разбиений от корня до листа. Значения 3-5 дают простые, легко интерпретируемые модели. Значения 10-15 обеспечивают высокую точность на сложных зависимостях. Unlimited depth приводит к переобучению на выборках размером более 1000 объектов. min_samples_split — минимальное число объектов для разбиения узла. Значение 2 (по умолчанию) разрешает разбиение любого узла с двумя и более объектами. Увеличение до 20-50 предотвращает создание узлов на малых подвыборках, снижая дисперсию модели. Эффективно при наличии шума в данных. min_samples_leaf — минимальное число объектов в листовом узле. Предотвращает создание листьев с единичными наблюдениями. Значения 5-10 обеспечивают статистическую значимость предсказаний в листьях. Критично для несбалансированных датасетов: без ограничения дерево создает множество листьев для редких классов. max_features — число признаков для рассмотрения при поиске лучшего разбиения. Значение None использует все признаки, sqrt(n_features) и log2(n_features) вводят случайность, снижая корреляцию между деревьями в ансамбле. Для отдельного дерева рекомендуется None, для Random Forest — sqrt или log2. min_impurity_decrease — минимальное улучшение критерия для выполнения разбиения. Абсолютное значение прироста качества, ниже которого разбиение не выполняется. Значения 0.0001-0.001 отсекают разбиения, незначительно улучшающие критерий. Эффективен на больших датасетах, где множество разбиений дают микроскопический прирост качества. Оптимальные значения параметров зависят от размера и характеристик датасета. Grid search с кросс-валидацией находит комбинацию параметров, максимизирующую качество на валидационной выборке. Для датасетов размером 10000+ объектов типичные значения: max_depth=10, min_samples_split=20, min_samples_leaf=5. Прунинг: пост-обработка дерева Прунинг строит полное дерево, затем удаляет узлы, не улучшающие качество на валидационной выборке. Метод Cost complexity pruning (minimal cost-complexity pruning) минимизирует функцию. Его формула: R_α(T) = R(T) + α × |T| где: R(T) — ошибка дерева T на обучающей выборке; |T| — число листовых узлов; α — параметр регуляризации. Параметр α контролирует компромисс между сложностью и точностью. При α=0 минимум достигается на полном дереве. С ростом α оптимальным становится более простое дерево. Последовательное увеличение α генерирует вложенную последовательность деревьев от полного до корня, из которой выбирается дерево с минимальной ошибкой на валидации. Этапы алгоритма прунинга: Построить полное дерево на обучающей выборке; Для каждого внутреннего узла вычислить α, при котором поддерево становится невыгодным; Найти узел с минимальным α и заменить его поддерево листом; Повторять шаг 3 до получения корня; Выбрать дерево из последовательности с минимальной ошибкой на валидации. Scikit-learn реализует cost complexity pruning через параметр ccp_alpha. Значения 0.001-0.01 дают умеренную обрезку, 0.1+ — агрессивную. Метод cost_complexity_pruning_path возвращает последовательность α и соответствующие показатели качества для выбора оптимального значения. Прунинг эффективнее предварительного ограничения параметров на небольших датасетах (менее 5000 объектов), в случаях когда кросс-валидация гиперпараметров вычислительно затратна. На больших выборках предварительное ограничение через max_depth и min_samples_split дает сопоставимое качество при меньших затратах времени. Валидация и выбор гиперпараметров K-fold кросс-валидация оценивает обобщающую способность модели. Датасет делится на K фолдов, модель обучается на K-1 фолдах и валидируется на оставшемся. Процесс повторяется K раз, финальная метрика — среднее по фолдам. Значения K=5 или K=10 обеспечивают баланс между вычислительными затратами и качеством оценки. Стратификация фолдов обычно применяется для несбалансированных данных: каждый фолд содержит пропорциональное представительство классов. Без стратификации отдельные фолды могут не содержать редкие классы, что искажает оценку качества. Scikit-learn автоматически применяет стратификацию в StratifiedKFold для задач классификации. Grid search перебирает комбинации гиперпараметров, обучая модель для каждой комбинации с кросс-валидацией. Для дерева решений типичная сетка включает: max_depth: [3, 5, 7, 10, 15, None] min_samples_split: [2, 10, 20, 50] min_samples_leaf: [1, 5, 10, 20] criterion: ['gini', 'entropy'] для классификации Полный перебор 6 × 4 × 4 × 2 = 192 комбинаций с 5-fold валидацией требует построения 960 деревьев. Для датасетов размером 100000+ объектов это занимает десятки минут. В таких случаях прибегают к Random search; данный алгоритм случайно выбирает подмножество комбинаций, снижая время поиска в 5-10 раз с минимальной потерей качества найденного оптимума. Интерпретируемость и важность признаков Основное преимущество деревьев решений — прозрачность логики принятия решений. Путь от корня до листа представляет собой набор условий if-then, понятных без знания математического аппарата. Это крайне важный аспект в регулируемых индустриях: кредитование, страхование, медицина требуют объяснения отказов и решений. Визуализация дерева решений Графическое представление дерева показывает структуру разбиений, распределение классов в узлах и пути принятия решений. Библиотека scikit-learn предоставляет функции export_graphviz и export_text для визуализации. Первая генерирует dot-файл для рендеринга через Graphviz, вторая — текстовое представление дерева. Узел дерева содержит: Условие разбиения (признак и порог); Значение критерия (Gini или MSE); Число объектов в узле; Распределение классов (для классификации) или среднее значение (для регрессии). Рис. 2: Структура дерева решений для задачи кредитного скоринга с двумя признаками: доход и возраст. Каждый узел отображает условие разбиения, значение Gini impurity, долю объектов от общей выборки и распределение классов. Цветовая насыщенность узлов отражает чистоту предсказаний: насыщенный синий соответствует высокой концентрации класса "Дефолт", насыщенный оранжевый — класса "Не дефолт". Корневой узел использует признак "Доход" как наиболее информативный для первого разбиения, что демонстрирует жадную стратегию алгоритма CART Интерпретация начинается с анализа верхних уровней дерева: признаки в корневых узлах имеют наибольшее влияние на предсказания. Частое появление признака на разных уровнях указывает на его значимость. Глубокие ветви с малым числом объектов сигнализируют о переобучении. Цветовое кодирование узлов упрощает анализ: интенсивность цвета отражает чистоту узла или концентрацию класса. Для бинарной классификации один класс кодируется оттенками синего, другой — оранжевого. Смешанные узлы имеют светлые оттенки, чистые — насыщенные. Это позволяет визуально выделить области уверенных предсказаний. Важность признаков (Feature importance) Встроенный в деревья алгоритм Feature importance количественно оценивает вклад каждого признака в качество модели. CART вычисляет важность как взвешенное уменьшение критерия разбиения. Формула расчета: importance(f) = Σ (n_t / n) × (impurity_t - (n_left/n_t) × impurity_left - (n_right/n_t) × impurity_right) где: суммирование по всем узлам t, где используется признак f; n_t — число объектов в узле t; n — общее число объектов; impurity — значение критерия (Gini, энтропия, MSE). Важности обычно нормализуются к сумме 1.0. Признаки, не использованные в дереве, получают нулевую важность. Высокая важность указывает, что признак участвует в разбиениях, сильно снижающих критерий неопределенности или ошибки. Метод feature_importances_ возвращает массив важностей, упорядоченный по исходному порядку признаков в датасете. Сортировка и визуализация топ-10 признаков выявляет ключевые факторы модели. Признаки с важностью менее 0.01-0.05 могут быть удалены без потери качества, что упрощает модель и снижает риск переобучения. Рис. 3: Визуализация важности признаков в дереве решений: левый график показывает вклад каждого признака (красным выделены малозначимые с важностью менее 0.05), правый график отображает накопленную важность для определения минимального набора признаков, обеспечивающих 90% информативности модели При этом следует учитывать, что важность признаков в одиночном дереве неустойчива: малые изменения данных могут перераспределить важность между коррелированными признаками. Для стабильной оценки в таких случаях используется усреднение важностей по множеству деревьев Random Forest или анализ permutation importance, оценивающий падение качества при случайном перемешивании значений признака. Практическое применение деревьев решений Деревья решений находят применение в задачах, где требуется не только предсказание, но и понимание механизма. К примеру, в бизнесе с помощью этого алгоритма часто проводят анализ оттока клиентов (churn prediction), который выявляет сегменты с высоким риском ухода. В таком анализе дерево показывает, какие комбинации признаков (низкая частота использования + отсутствие активности 30+ дней) наиболее важны. В кредитном скоринге дерево решений генерирует прозрачные правила одобрения: доход более 50000, стаж работы более 2 лет, отсутствие просрочек за 12 месяцев. Регуляторы требуют объяснения отказов заемщикам, дерево предоставляет конкретный путь к решению. Это невозможно с black-box моделями типа градиентного бустинга или нейросетей без дополнительных методов объяснения. Сегментация клиентов через дерево решений выделяет гомогенные группы по поведенческим паттернам. Листовые узлы представляют сегменты с характерными признаками: высокий LTV + частые покупки + использование премиум-функций. Маркетинговые кампании таргетируются на специфичные сегменты с персонализированными предложениями. Диагностика производственных процессов использует деревья для выявления причин дефектов. Признаки включают параметры оборудования, характеристики сырья, условия производства. Дерево показывает, какие комбинации параметров приводят к браку: температура выше 180°C + влажность менее 40% + скорость линии более 100 единиц/час. Это позволяет оптимизировать процесс без дорогостоящих экспериментов. Пример построения модели на Python Как правило, для построения деревьев используется библиотека Scikit-learn. Она уже включает в себя нужные классы DecisionTreeClassifier и DecisionTreeRegressor со всем необходимым. Ниже пример анализа оттока клиентов телекоммуникационной компании: задача классификации с целью выявить факторы, влияющие на решение клиента покинуть сервис. import pandas as pd import numpy as np from sklearn.model_selection import train_test_split, GridSearchCV, cross_val_score from sklearn.tree import DecisionTreeClassifier, plot_tree, export_text from sklearn.metrics import classification_report, confusion_matrix import matplotlib.pyplot as plt # Генерация синтетических данных телеком компании np.random.seed(42) n_samples = 2000 data = pd.DataFrame({ 'tenure_months': np.random.randint(1, 72, n_samples), 'monthly_charges': np.random.uniform(20, 120, n_samples), 'total_charges': np.random.uniform(100, 8000, n_samples), 'contract_type': np.random.choice(['month_to_month', 'one_year', 'two_year'], n_samples), 'payment_method': np.random.choice(['electronic', 'mailed_check', 'bank_transfer', 'credit_card'], n_samples), 'tech_support': np.random.choice([0, 1], n_samples), 'online_security': np.random.choice([0, 1], n_samples), 'num_services': np.random.randint(1, 8, n_samples), 'customer_service_calls': np.random.poisson(2, n_samples) }) # Генерация целевой переменной с логической зависимостью churn_probability = ( 0.15 * (data['tenure_months'] < 12) + 0.12 * (data['contract_type'] == 'month_to_month') + 0.10 * (data['customer_service_calls'] > 4) + 0.08 * (data['tech_support'] == 0) + 0.05 * (data['monthly_charges'] > 80) ) data['churn'] = (np.random.random(n_samples) < churn_probability).astype(int) # Кодирование категориальных признаков data_encoded = pd.get_dummies(data, columns=['contract_type', 'payment_method'], drop_first=True) # Разделение на признаки и целевую переменную X = data_encoded.drop('churn', axis=1) y = data_encoded['churn'] # Разделение на обучающую и тестовую выборки X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y) # Построение базовой модели base_tree = DecisionTreeClassifier(random_state=42) base_tree.fit(X_train, y_train) print("Базовая модель (без ограничений):") print(f"Глубина дерева: {base_tree.get_depth()}") print(f"Число листьев: {base_tree.get_n_leaves()}") print(f"Accuracy на обучающей выборке: {base_tree.score(X_train, y_train):.4f}") print(f"Accuracy на тестовой выборке: {base_tree.score(X_test, y_test):.4f}") # Подбор гиперпараметров через Grid Search param_grid = { 'max_depth': [3, 5, 7, 10], 'min_samples_split': [10, 20, 50], 'min_samples_leaf': [5, 10, 20], 'criterion': ['gini', 'entropy'] } grid_search = GridSearchCV( DecisionTreeClassifier(random_state=42), param_grid, cv=5, scoring='f1', n_jobs=-1 ) grid_search.fit(X_train, y_train) print("\nЛучшие параметры:", grid_search.best_params_) print(f"Лучший F1-score на кросс-валидации: {grid_search.best_score_:.4f}") # Обучение оптимальной модели optimal_tree = grid_search.best_estimator_ y_pred = optimal_tree.predict(X_test) print("\nОптимальная модель:") print(f"Глубина дерева: {optimal_tree.get_depth()}") print(f"Число листьев: {optimal_tree.get_n_leaves()}") print(f"Accuracy на тестовой выборке: {optimal_tree.score(X_test, y_test):.4f}") print("\nClassification Report:") print(classification_report(y_test, y_pred, target_names=['No Churn', 'Churn'])) # Анализ важности признаков feature_importance = pd.DataFrame({ 'feature': X.columns, 'importance': optimal_tree.feature_importances_ }).sort_values('importance', ascending=False) print("\nВажность признаков (топ-10):") print(feature_importance.head(10)) # Визуализация дерева fig, axes = plt.subplots(2, 2, figsize=(20, 16)) # График 1: Полное дерево (первые 3 уровня) plot_tree(optimal_tree, max_depth=3, feature_names=X.columns, class_names=['No Churn', 'Churn'], filled=True, ax=axes[0, 0], fontsize=9) axes[0, 0].set_title('Структура дерева (первые 3 уровня)', fontsize=12, weight='bold') # График 2: Важность признаков top_features = feature_importance.head(10) axes[0, 1].barh(range(len(top_features)), top_features['importance'], color='#2C3E50') axes[0, 1].set_yticks(range(len(top_features))) axes[0, 1].set_yticklabels(top_features['feature']) axes[0, 1].set_xlabel('Importance', fontsize=10) axes[0, 1].set_title('Важность признаков (топ-10)', fontsize=12, weight='bold') axes[0, 1].invert_yaxis() # График 3: Матрица ошибок cm = confusion_matrix(y_test, y_pred) im = axes[1, 0].imshow(cm, cmap='Blues') axes[1, 0].set_xticks([0, 1]) axes[1, 0].set_yticks([0, 1]) axes[1, 0].set_xticklabels(['No Churn', 'Churn']) axes[1, 0].set_yticklabels(['No Churn', 'Churn']) axes[1, 0].set_xlabel('Predicted', fontsize=10) axes[1, 0].set_ylabel('Actual', fontsize=10) axes[1, 0].set_title('Confusion Matrix', fontsize=12, weight='bold') for i in range(2): for j in range(2): text = axes[1, 0].text(j, i, cm[i, j], ha="center", va="center", color="black", fontsize=14) plt.colorbar(im, ax=axes[1, 0]) # График 4: Сравнение качества на разных глубинах depths = range(1, 16) train_scores = [] test_scores = [] for depth in depths: tree = DecisionTreeClassifier(max_depth=depth, random_state=42) tree.fit(X_train, y_train) train_scores.append(tree.score(X_train, y_train)) test_scores.append(tree.score(X_test, y_test)) axes[1, 1].plot(depths, train_scores, label='Train Accuracy', color='#2C3E50', linewidth=2) axes[1, 1].plot(depths, test_scores, label='Test Accuracy', color='#E74C3C', linewidth=2) axes[1, 1].axvline(x=optimal_tree.get_depth(), color='#27AE60', linestyle='--', linewidth=2, label=f'Optimal Depth ({optimal_tree.get_depth()})') axes[1, 1].set_xlabel('Max Depth', fontsize=10) axes[1, 1].set_ylabel('Accuracy', fontsize=10) axes[1, 1].set_title('Accuracy vs Tree Depth', fontsize=12, weight='bold') axes[1, 1].legend() axes[1, 1].grid(True, alpha=0.3) plt.tight_layout() plt.show() # Текстовое представление дерева tree_rules = export_text(optimal_tree, feature_names=list(X.columns), max_depth=3) print("\nПравила дерева (первые 3 уровня):") print(tree_rules) Базовая модель (без ограничений): Глубина дерева: 21 Число листьев: 233 Accuracy на обучающей выборке: 1.0000 Accuracy на тестовой выборке: 0.7920 Лучшие параметры: {'criterion': 'entropy', 'max_depth': 10, 'min_samples_leaf': 5, 'min_samples_split': 10} Лучший F1-score на кросс-валидации: 0.1642 Оптимальная модель: Глубина дерева: 10 Число листьев: 89 Accuracy на тестовой выборке: 0.8360 Рис. 4: Комплексный анализ дерева решений для задачи прогноза оттока клиентов. Панель A: структура дерева с визуализацией разбиений и распределения классов в узлах. Панель B: важность признаков, где monthly_charges и tenure_months доминируют. Панель C: матрица ошибок с преобладанием корректных предсказаний класса No Churn. Панель D: динамика точности в зависимости от глубины дерева, демонстрирующая переобучение после 8 уровней. Оптимальная глубина 10 обеспечивает баланс между точностью и обобщающей способностью Classification Report: precision recall f1-score support No Churn 0.88 0.94 0.91 432 Churn 0.33 0.21 0.25 68 accuracy 0.84 500 macro avg 0.61 0.57 0.58 500 weighted avg 0.81 0.84 0.82 500 Важность признаков (топ-10): feature importance 1 monthly_charges 0.297364 0 tenure_months 0.186262 2 total_charges 0.182591 6 customer_service_calls 0.089506 8 contract_type_two_year 0.063956 3 tech_support 0.059896 4 online_security 0.042064 5 num_services 0.039216 7 contract_type_one_year 0.030980 9 payment_method_credit_card 0.008166 Представленный выше код реализует полный пайплайн анализа оттока клиентов. Синтетические данные имитируют реальный датасет телеком компании с признаками: Ежемесячные платежи (monthly_charges); Срок обслуживания в месяцах (tenure_months); Общая сумма платежей (total_charges); Количество обращений в службу поддержки (customer_service_calls); Контракт на два года (признак) (contract_type_two_year); Техническая поддержка (подключена/не подключена) (tech_support); Онлайн-защита (подключена/не подключена) (online_security); Количество подключенных услуг (num_services); Контракт на один год (признак) (contract_type_one_year); Оплата кредитной картой (признак) (payment_method_credit_card). Целевая переменная генерируется с логической зависимостью от признаков: клиенты с коротким сроком контракта, месячным типом подписки и частыми обращениями в поддержку имеют повышенную вероятность оттока. Базовая модель без ограничений демонстрирует переобучение: высокая точность на обучающей выборке при существенно меньшей на тестовой. Это происходит довольно часто с деревьями решений. Для подбора оптимальных гиперпараметров используется Grid search, он находит оптимальную комбинацию через 5-fold кросс-валидацию с метрикой F1-score, сбалансированной для несбалансированных классов. Оптимальная модель показывает глубину 5-7 уровней с минимальным числом объектов в листьях 10-20. Матрица ошибок показывает распределение предсказаний: модель лучше идентифицирует клиентов без оттока (специфичность выше чувствительности), что типично для несбалансированных датасетов. Анализ важности признаков выявляет, что ежемесячные платежи (monthly_charges), срок обслуживания в месяцах (tenure_months) и общая сумма платежей (total_charges) доминируют в предсказаниях. Признаки с важностью менее 0.05 вносят минимальный вклад и могут быть удалены для упрощения модели. Визуализация первых уровней дерева показывает ключевые пороговые значения: клиенты с tenure_months < 12 и contract_type = month_to_month формируют сегмент высокого риска. Правила дерева (первые 3 уровня): |--- tenure_months <= 11.50 | |--- monthly_charges <= 62.88 | | |--- monthly_charges <= 45.98 | | | |--- num_services <= 3.50 | | | | |--- truncated branch of depth 2 | | | |--- num_services > 3.50 | | | | |--- truncated branch of depth 4 | | |--- monthly_charges > 45.98 | | | |--- contract_type_two_year <= 0.50 | | | | |--- class: 0 | | | |--- contract_type_two_year > 0.50 | | | | |--- truncated branch of depth 2 | |--- monthly_charges > 62.88 | | |--- tenure_months <= 7.50 | | | |--- monthly_charges <= 65.79 | | | | |--- class: 1 | | | |--- monthly_charges > 65.79 | | | | |--- truncated branch of depth 7 | | |--- tenure_months > 7.50 | | | |--- total_charges <= 5274.54 | | | | |--- truncated branch of depth 3 | | | |--- total_charges > 5274.54 | | | | |--- truncated branch of depth 4 |--- tenure_months > 11.50 | |--- contract_type_one_year <= 0.50 | | |--- contract_type_two_year <= 0.50 | | | |--- total_charges <= 640.92 | | | | |--- truncated branch of depth 4 | | | |--- total_charges > 640.92 | | | | |--- truncated branch of depth 7 | | |--- contract_type_two_year > 0.50 | | | |--- monthly_charges <= 79.03 | | | | |--- truncated branch of depth 7 | | | |--- monthly_charges > 79.03 | | | | |--- truncated branch of depth 7 | |--- contract_type_one_year > 0.50 | | |--- tech_support <= 0.50 | | | |--- total_charges <= 7866.02 | | | | |--- truncated branch of depth 7 | | | |--- total_charges > 7866.02 | | | | |--- class: 0 | | |--- tech_support > 0.50 | | | |--- customer_service_calls <= 4.50 | | | | |--- truncated branch of depth 5 | | | |--- customer_service_calls > 4.50 | | | | |--- truncated branch of depth 2 Заключение Деревья решений - классический алгоритм машинного обучения, который строит деревья через жадную оптимизацию критериев разбиения — Gini impurity для классификации и MSE для регрессии. Контроль сложности модели реализуется через параметры ограничения роста или прунинг, предотвращающий переобучение без потери репрезентативности паттернов в данных. Деревья решений обладают важным преимуществом — интерпретируемостью. Визуализированная структура дерева позволяет проследить логику принятия решения пошагово, что делает модель понятной для аналитиков и руководителей, отвечающих за внедрение результатов в бизнес-процессы. Однако сами по себе деревья часто склонны к переобучению, плюс чувствительны к шуму и небольшим изменениям данных, поэтому для повышения устойчивости и качества прогнозов на практике широко применяются ансамблевые методы, такие как Random Forest и Gradient Boosting, которые комбинируют множество деревьев и обеспечивают более высокую точность при сохранении объяснимости ключевых факторов. ### Бэктестинг: что это такое и как правильно его проводить Перед тем как запускать стратегию в реальную торговлю, ее много раз прогоняют на исторических данных. Бэктестинг — это тестирование стратегии на прошлых котировках, чтобы увидеть, как она вела бы себя в реальных рыночных условиях. Данный процесс позволяет заранее выявить слабые места стратегии и избежать ненужных потерь. Качественный бэктест требует понимания не только программирования, но и специфики финансовых рынков: от особенностей исполнения ордеров до статистических ловушек при анализе временных рядов. Результаты бэктестирования помогают принять решение о запуске стратегии в продакшен или о необходимости ее доработки. В то же время, даже небольшие ошибки в построении бэктеста могут сильно исказить картину эффективности стратегии. В этой статье мы подробно разберем наиболее распространенные ошибки и покажем, как их избежать. Что такое бэктестинг? Бэктестинг представляет собой симуляцию торговой стратегии на исторических рыночных данных. Система генерирует сигналы на покупку и продажу согласно заданным правилам, после чего рассчитывается гипотетическая доходность портфеля. Основная цель — получить статистику производительности стратегии в различных рыночных условиях без риска реальных средств. Процесс отличается от форвард-тестирования (paper trading), где стратегия работает в режиме реального времени на текущих данных без реального исполнения сделок: Бэктест использует уже известные исторические цены, что создает риск использования информации из будущего; Форвард-тест устраняет эту проблему, но требует длительного времени для накопления статистики и не позволяет быстро протестировать множество вариантов параметров. Корректный бэктест должен максимально точно воспроизводить условия реальной торговли. Он должен включать: моделирование проскальзывания, учет bid-ask спреда, ограничения ликвидности и задержек в получении данных и т. д. Без учета этих факторов доходность в тестах получается завышенной. И чем больше частота сделок, тем больше разница. Основные компоненты бэктеста Исторические данные Качество данных напрямую влияет на достоверность результатов. Минутные и тиковые данные содержат больше информации для внутридневных стратегий, но требуют значительных вычислительных ресурсов. Дневные бары подходят для позиционных стратегий с горизонтом удержания от нескольких дней. Источники данных различаются полнотой и точностью. Провайдеры уровня Bloomberg или Refinitiv предоставляют скорректированные цены с учетом сплитов и дивидендов, но стоят от нескольких тысяч долларов в год. Бесплатные альтернативы типа Yahoo Finance содержат ошибки в исторических данных и пропуски в котировках, особенно для бумаг с низкой ликвидностью. Ключевые проверки данных включают: Выявление пропусков во временных рядах; Наличие спайков, аномальных ценовых выбросов; Наличие баров с нулевыми или аномальными объемами; Ошибки в котировках открытия/закрытия свечей, неконсистентность между источниками данных; Технические сбои в исторических рядах. Даже один пропущенный день в стратегии может исказить расчет стандартного отклонения и привести к ложным сигналам. Для обнаружения выбросов часто используют z-score с порогом 4–5 стандартных отклонений, что позволяет выявлять технические ошибки в данных. Логика стратегии Торговые правила должны быть формализованы в виде математических условий без двусмысленности интерпретации. Расплывчатые критерии типа "купить при сильном росте объема" не подходят для автоматизации. Вместо этого используются конкретные пороги: "открыть длинную позицию, если объем превышает 20-дневную скользящую среднюю в 2 раза". Стратегия включает: Правила входа в позицию; Правила выхода по тейк-профиту или стоп-лоссу; Размер позиции; Размер реинвестирования прибыли. Каждый компонент влияет на итоговую доходность и риск-профиль. Фиксированный размер позиции, например 10% капитала на сделку, формирует один профиль просадок, тогда как волатильно-взвешенные позиции по формуле Келли меняют распределение прибыли и риски. Чтобы эти правила можно было эффективно применить к историческим данным и быстро протестировать, стратегию реализуют на C++ или Python с векторизацией через датафреймы pandas. Такой подход позволяет обрабатывать сигналы для всего набора данных одновременно, ускоряя вычисления в 50–100 раз по сравнению с циклом по строкам. При этом векторизация требует особого внимания к последовательным зависимостям между сделками и предотвращению «заглядывания в будущее» (look-ahead bias). Модель исполнения Упрощенная модель предполагает исполнение по ценам закрытия баров без задержек и проскальзывания. Такой подход завышает доходность, поскольку игнорирует реальность рыночной микроструктуры. На практике ордера исполняются с задержкой минимум один бар после генерации сигнала, а цена исполнения отличается от желаемой. Более реалистичная модель учитывает bid-ask спред через вычитание половины спреда из цены покупки и добавление к цене продажи. Для ликвидных акций спред составляет 0.01-0.05%, для менее торгуемых достигает 0.3-1%. Проскальзывание моделируется как процент от волатильности или фиксированная величина в базисных пунктах. Комиссии брокера варьируются от $0.001 до $0.005 за акцию для розничных трейдеров на американском рынке. Частота торговли определяет их влияние: стратегия с 200 сделками в год теряет 2-4% годовой доходности на комиссиях, при 2000 сделок потери достигают 20-40%. Институциональные трейдеры получают меньшие комиссии, но сталкиваются с impact cost — рыночным воздействием сделки, которое возникает при больших объемах и снижает эффективность стратегии. Метрики производительности Базовый набор метрик включает: Совокупную доходность; Годовую доходность; Максимальную просадку; Коэффициент Шарпа. Совокупная доходность показывает рост капитала за весь период тестирования, но не учитывает риск. Годовая доходность нормализует результат для сравнения стратегий на разных временных интервалах. Коэффициент Шарпа (Sharpe ratio) - ключевой показатель. В идеале он должен быть сильно больше 1. Он вычисляется как отношение средней избыточной доходности к стандартному отклонению доходности: SR = (R̄ - Rᶠ) / σ где: R̄ — средняя доходность стратегии за период; Rᶠ — безрисковая ставка (обычно доходность гособлигаций); σ — стандартное отклонение доходности. Коэффициент показывает доходность на единицу принятого риска. Значения выше 1.0 считаются хорошими, выше 2.0 — отличными. Однако Sharpe ratio чувствителен к выбросам и предполагает нормальное распределение доходностей, что часто не соответствует реальности финансовых рынков. При бэктестах всегда обращают внимание на колебания кривой доходности, особенно в периоды просадок. Максимальная просадка (maximum drawdown) измеряет наибольшее падение капитала от пика до минимума в процентах. Метрика критична для оценки психологической устойчивости трейдера и требований к капиталу. Просадка в 40% требует последующего роста на 67% для возврата к исходному уровню, что может занять годы. Это основные показатели эффективности стратегии. Кроме них, для бэктестов иногда используют дополнительные метрики: Коэффициент Сортино (Sortino ratio) — учитывает только негативную волатильность, игнорируя колебания вверх. Чем выше значение, тем лучше стратегия компенсирует риск отрицательных движений цены; Коэффициент Калмара (Calmar ratio) — отношение годовой доходности к максимальной просадке. Он позволяет оценить, насколько стратегия устойчива к сильным падениям капитала; Винрейт (Win rate) — процент прибыльных сделок. Важно помнить, что винрейт выше 50% не гарантирует прибыльность; ключевое значение имеет соотношение средней прибыли к среднему убытку. Типичные ошибки при проведении бэктестов Использование будущих значений или Look-ahead bias Смещение данных возникает, когда для генерации торгового сигнала используется информация, которая на самом деле еще не была доступна. Классический пример — расчет скользящих средних, уровней поддержки / сопротивления или других индикаторов с учетом будущих значений временного ряда. Такая ошибка приводит к нереалистично высокой доходности в бэктесте, которая не воспроизводится в реальной торговле. Распространенная ошибка — использование цены закрытия текущего бара для генерации сигнала и исполнения на этой же цене. Реальная система узнает цену закрытия только после завершения бара, поэтому исполнение возможно минимум на следующем баре. Сдвиг сигналов на один период назад относительно исполнения устраняет проблему. Другой источник look-ahead bias — применение функций типа shift() или rolling() без правильной индексации. Pandas по умолчанию включает текущее значение в расчет скользящего окна, что создает утечку данных из будущего. Корректная реализация требует явного исключения текущего наблюдения или использования параметра closed='left' в rolling(). Проверка на утечку данных из будущего включает ручной пошаговый анализ нескольких сделок с выводом доступных данных на момент генерации сигнала. Если стратегия использует данные, которые появятся только в будущих барах, бэктест содержит ошибку. Увы, но решений по автоматической детекции таких случаев нет, поэтому от специалиста требуется тщательная ревизия кода. Ошибка выживших или Survivorship bias Ошибка выживших в бэктестинге возникает при тестировании стратегии только на активах, которые существуют в настоящее время. Компании, обанкротившиеся или делистированные с биржи, исключаются из анализа, что искусственно завышает историческую доходность. Этот эффект особенно заметен для долгосрочных стратегий с горизонтом 10 и более лет. Индекс S&P 500 меняет состав компаний каждый год — добавляются растущие бизнесы, исключаются проблемные. Бэктест стратегии на текущем составе индекса за последние 20 лет тестирует только выживших победителей, игнорируя неудачников. Реальная стратегия в прошлом торговала бы смесью успешных и провальных компаний. Устранить такую ошибку можно за счет использования исторических датасетов с актуальным состоянием на каждую дату (point-in-time), которые показывают точный состав индексов и список торгуемых активов в прошлом. Поставщики данных, такие как Norgate Data или Sharadar, предоставляют такие наборы за дополнительную плату, тогда как бесплатные источники обычно содержат только текущие «выжившие» компании. Влияние survivorship bias на доходность составляет примерно 1–3% годовых для широких индексов и 5–10% для стратегий на акциях с малой капитализацией. Особенно чувствительны к этому стоимостные стратегии, так как они часто покупают проблемные компании, многие из которых впоследствии исключаются с биржи. Переобучение или переподгонка стратегии Переподгонка (overfitting) возникает при чрезмерной оптимизации параметров стратегии под исторические данные. Стратегия с множеством правил и условий может идеально работать на тестовом периоде, но полностью проваливаться на новых данных. Все потому, что модель запоминает случайный шум вместо устойчивых рыночных закономерностей. Типичный пример — перебор тысяч комбинаций параметров индикаторов в поиске максимальной доходности. Найденная оптимальная комбинация скорее всего отражает случайные совпадения в прошлом, а не предсказательную силу. Вероятность найти прибыльную комбинацию случайно растет с количеством попыток согласно множественному тестированию. Частые признаки переподгонки: Резкое падение производительности стратегии на данных вне выборки (out-of-sample) по сравнению с данными внутри выборки (in-sample); Слишком высокий Sharpe ratio (выше 3–4), не соответствующий реальному риску. Как правило, чем выше доходность, тем выше и риск; Идеальную кривую капитала без просадок; Чрезмерную сложность правил стратегии — стратегии с десятком условий и параметров чаще переподогнаны по сравнению с простыми логиками из 2–3 правил; Нестабильность сигналов при небольших изменениях входных данных; Зависимость результатов от редких экстремальных событий (outlier sensitivity). Эти признаки помогают выявлять стратегии, которые скорее запомнили случайный шум истории, чем выявили устойчивые рыночные закономерности. Методы борьбы с переподгонкой включают: Ограничение числа оптимизируемых параметров; Использование регуляризации; Проверку стратегии на нескольких независимых исторических периодах; Тестирование на разных рынках; Применение техники кросс-валидации с разными настройками по фолдам для оценки стабильности параметров; Упрощение логики стратегии и удаление незначимых сигналов для снижения сложности. В трейдинге действует принцип бритвы Оккама — простые стратегии обычно более устойчивы к изменениям рыночного режима и меньше подвержены случайному шуму. Игнорирование транзакционных издержек Транзакционные издержки включают комиссии брокера, bid-ask спред, проскальзывание и рыночное воздействие сделки (impact cost) для крупных ордеров. Начинающие трейдеры часто учитывают только комиссии, игнорируя остальные компоненты, что приводит к переоценке реальной прибыльности стратегии. Спред Bid-ask варьируется в течение дня и расширяется в периоды низкой ликвидности. Утренний спред сразу после открытия биржи в 2-3 раза шире дневного среднего. Стратегии, торгующие на открытии или закрытии сессии, несут повышенные издержки. Использование среднедневного спреда в бэктесте недооценивает реальные потери. Проскальзывание возрастает с размером ордера относительно среднедневного объема торгов. Ордер на 10% дневного объема сдвигает цену исполнения на 0.5-2% против трейдера в зависимости от ликвидности инструмента. Моделирование проскальзывания через квадратный корень от отношения размера ордера к объему дает приближенную оценку impact cost. Частота торговли усиливает влияние издержек. Стратегия с периодом удержания в 2-3 дня генерирует 80-120 сделок в год, теряя 1-2% на издержках при комиссии $0.002 за акцию. Увеличение частоты до 1-2 сделок в день дает 250-500 сделок в год и потери 5-15%. Высокочастотные подходы с тысячами сделок требуют стратегий с доходностями минимум 20-30% годовых для покрытия издержек. Тестирование на тренировочной и тестовой выборках Разделение исторических данных на тренировочный (in-sample) и тестовый (out-of-sample) периоды — базовое требование для объективной оценки стратегии: In-sample период используется для разработки логики и оптимизации параметров; Out-of-sample — для проверки устойчивости найденных закономерностей на данных, которые алгоритм еще не видел, что приближенно к реальности. Типичное соотношение составляет 80/20 или 70/30 в пользу тренировочного периода. Для временных рядов длиной 10 лет используется 7 лет для обучения и 3 года для валидации. Слишком короткий in-sample период не дает достаточной статистики для оптимизации, слишком длинный сокращает возможности проверки на свежих данных. Правильная временная последовательность данных крайне важна для финансовых временных рядов. Случайное перемешивание наблюдений разрушает автокорреляцию и может привести к утечке данных из будущего (look-ahead bias). Поэтому данные всегда разделяют последовательно: первая часть истории используется для обучения модели, а последующая — для тестирования. Информация из будущего никогда не должна попадать в тренировочный период. Выбор границы между периодами учитывает структурные изменения рынка. Разделение до и после финансового кризиса 2008-2009 создает два разных рыночных режима. Стратегия, оптимизированная на бычьем рынке 2009-2020, может провалиться в условиях повышенной волатильности. Включение разных рыночных фаз в оба периода дает более реалистичную оценку. Walk-forward анализ Walk-forward анализ расширяет концепцию разделения данных через множественные итерации оптимизации и тестирования. Метод моделирует непрерывное обновление параметров стратегии по мере поступления новых данных, что приближает бэктест к реальным условиям адаптивной торговли. Алгоритм работает следующим образом: Оптимизируем параметры на первом окне данных длиной N периодов; Применяем найденные параметры на следующих M периодах для генерации сделок; Сдвигаем окно вперед и повторяем процесс. Типичные значения — N=252 торговых дня (1 год) для оптимизации, M=63 дня (3 месяца) для тестирования. Результаты walk-forward теста показывают стабильность стратегии во времени. Если производительность значительно различается между окнами, стратегия чувствительна к выбору параметров и рыночному режиму. Стабильная доходность на всех окнах указывает на устойчивые закономерности. Допустимая вариация годовой доходности между окнами составляет ±30-50% от средней. Вычислительная сложность walk-forward анализа существенно выше простого разделения, поскольку требует повторной оптимизации на каждой итерации. Для стратегии с 5 параметрами и 10 значений каждого получается 100,000 комбинаций на одно окно. При 20 окнах общее число бэктестов достигает 2 миллионов, что требует часов расчетов даже на быстрых машинах. Кросс-валидация в трейдинге Кросс-валидация (cross-validation) адаптируется для временных рядов с учетом их специфики. Стандартный k-fold подход со случайным разделением нарушает временную последовательность и почти гарантирует заглядывание в будущее. Поэтому для временных рядов используют последовательную версию кросс-валидации с временными фолдами. Метод Time series cross-validation разделяет данные на K последовательных периодов: Первый фолд служит тренировочным, второй — тестовым; Затем первые два становятся тренировочными, третий — тестовым, и так далее. Метод дает K-1 независимых оценок производительности на разных временных интервалах. Метод Purged k-fold cross-validation усовершенствует базовый подход через удаление наблюдений вокруг границ между фолдами. Удаление предотвращает утечку информации через корреляцию соседних наблюдений во временных рядах. Типичный размер purged периода составляет 5-10 дней для дневных данных, что соответствует периоду автокорреляции большинства финансовых инструментов. Комбинаторная purged cross-validation генерирует все возможные комбинации тренировочных и тестовых фолдов, что дает больше независимых оценок по сравнению с последовательным подходом. Метод требует значительных вычислений: для 10 фолдов получается C(10,2) = 45 комбинаций. Каждая комбинация дает независимую оценку производительности, усреднение которых снижает вариацию итогового результата. Практическая реализация бэктеста на Python Мы рассмотрели ключевые аспекты теории бэктестинга, теперь давайте перейдем от теории к практике. Рассмотрим конкретную стратегию и ее тестирование. В хедж-фондах одной из наиболее популярных является стратегия Mean reversion или стратегия возврата к среднему. Она основана на предположении, что цены инструментов со временем возвращаются к своему среднему уровню после колебаний вверх или вниз. Для практической реализации стратегии часто используют пары акций, рассчитывая z-score спреда между их ценами. Это позволяет формализовать торговые сигналы и оценить, насколько отклонение цены от среднего уровня может служить сигналом для открытия позиции. Ниже представлен пример кода бэктеста, который демонстрирует процесс генерации сигналов, расчета позиций и оценки доходности стратегии. import yfinance as yf import pandas as pd import numpy as np import matplotlib.pyplot as plt from datetime import datetime # Загрузка данных для двух коррелированных акций tickers = ['SLB', 'HAL'] # Schlumberger и Halliburton start_date = '2021-01-01' end_date = '2024-12-31' data = yf.download(tickers, start=start_date, end=end_date)['Close'] # Проверка на MultiIndex и выравнивание if isinstance(data.columns, pd.MultiIndex): data = data.droplevel(0, axis=1) # Разделение на in-sample и out-of-sample split_date = '2024-01-01' data_train = data[data.index < split_date].copy() data_test = data[data.index >= split_date].copy() print(f"Train период: {data_train.index[0]} - {data_train.index[-1]}") print(f"Test период: {data_test.index[0]} - {data_test.index[-1]}") # Функция расчета спреда и z-score def calculate_spread_zscore(prices, lookback=20): """ Рассчитываем спред между двумя активами и его z-score """ ratio = prices.iloc[:, 0] / prices.iloc[:, 1] spread_mean = ratio.rolling(window=lookback).mean() spread_std = ratio.rolling(window=lookback).std() zscore = (ratio - spread_mean) / spread_std return ratio, zscore # Генерация торговых сигналов def generate_signals(zscore, entry_threshold=2.0, exit_threshold=0.5): """ Генерация сигналов на основе z-score: - Длинная позиция при z-score < -entry_threshold - Короткая позиция при z-score > entry_threshold - Закрытие при возврате к exit_threshold """ signals = pd.DataFrame(index=zscore.index) signals['zscore'] = zscore signals['position'] = 0 position = 0 positions_list = [] for z in signals['zscore']: if pd.isna(z): positions_list.append(position) continue if z < -entry_threshold and position == 0: position = 1 elif z > entry_threshold and position == 0: position = -1 elif z > -exit_threshold and position == 1: position = 0 elif z < exit_threshold and position == -1: position = 0 positions_list.append(position) signals['position'] = pd.Series(positions_list, index=signals.index) signals['position'] = signals['position'].shift(1).fillna(0) return signals # Расчет доходности стратегии def calculate_strategy_returns(prices, signals, transaction_cost=0.001): returns = prices.pct_change() spread_returns = returns.iloc[:, 0] - returns.iloc[:, 1] strategy_returns = signals['position'] * spread_returns position_changes = signals['position'].diff().abs() costs = position_changes * transaction_cost strategy_returns = strategy_returns - costs return strategy_returns # Расчет метрик производительности def calculate_metrics(returns): cumulative_return = (1 + returns).cumprod().iloc[-1] - 1 n_years = len(returns) / 252 annual_return = (1 + cumulative_return) ** (1 / n_years) - 1 annual_vol = returns.std() * np.sqrt(252) sharpe = annual_return / annual_vol if annual_vol > 0 else 0 cumulative = (1 + returns).cumprod() running_max = cumulative.expanding().max() max_drawdown = ((cumulative - running_max) / running_max).min() win_rate = (returns > 0).sum() / (returns != 0).sum() if (returns != 0).sum() > 0 else 0 return { 'Cumulative Return': f"{cumulative_return:.2%}", 'Annual Return': f"{annual_return:.2%}", 'Annual Volatility': f"{annual_vol:.2%}", 'Sharpe Ratio': f"{sharpe:.2f}", 'Max Drawdown': f"{max_drawdown:.2%}", 'Win Rate': f"{win_rate:.2%}", 'Total Trades': int((returns != 0).sum()) } # Бэктест на in-sample данных ratio_train, zscore_train = calculate_spread_zscore(data_train, lookback=20) signals_train = generate_signals(zscore_train, entry_threshold=2.0, exit_threshold=0.5) returns_train = calculate_strategy_returns(data_train, signals_train, transaction_cost=0.001) print("\nIN-SAMPLE МЕТРИКИ") metrics_train = calculate_metrics(returns_train) for key, value in metrics_train.items(): print(f"{key}: {value}") # Бэктест на out-of-sample данных ratio_test, zscore_test = calculate_spread_zscore(data_test, lookback=20) signals_test = generate_signals(zscore_test, entry_threshold=2.0, exit_threshold=0.5) returns_test = calculate_strategy_returns(data_test, signals_test, transaction_cost=0.001) print("\nOUT-OF-SAMPLE МЕТРИКИ") metrics_test = calculate_metrics(returns_test) for key, value in metrics_test.items(): print(f"{key}: {value}") # Визуализация fig, axes = plt.subplots(3, 1, figsize=(14, 10)) # График 1 ax1 = axes[0] ax1.plot(ratio_train.index, ratio_train, label='Train Period', color='#2C3E50', linewidth=1) ax1.plot(ratio_test.index, ratio_test, label='Test Period', color='#E74C3C', linewidth=1) ax1.axvline(x=pd.Timestamp(split_date), color='gray', linestyle='--', alpha=0.7) ax1.set_ylabel('Price Ratio (SLB/HAL)') ax1.set_title('Спред между Schlumberger и Halliburton ') ax1.legend() ax1.grid(alpha=0.3) # График 2 ax2 = axes[1] ax2.plot(zscore_train.index, zscore_train, label='Train Z-score', color='#2C3E50', linewidth=1) ax2.plot(zscore_test.index, zscore_test, label='Test Z-score', color='#E74C3C', linewidth=1) ax2.axhline(y=2.0, color='green', linestyle='--', alpha=0.5, label='Entry Threshold') ax2.axhline(y=-2.0, color='green', linestyle='--', alpha=0.5) ax2.axhline(y=0.5, color='orange', linestyle='--', alpha=0.5, label='Exit Threshold') ax2.axhline(y=-0.5, color='orange', linestyle='--', alpha=0.5) ax2.axvline(x=pd.Timestamp(split_date), color='gray', linestyle='--', alpha=0.7) ax2.set_ylabel('Z-score') ax2.set_title('Z-score и торговые сигналы') ax2.legend() ax2.grid(alpha=0.3) # График 3 ax3 = axes[2] cum_returns_train = (1 + returns_train).cumprod() cum_returns_test = (1 + returns_test).cumprod() cum_returns_test_adjusted = cum_returns_test * cum_returns_train.iloc[-1] ax3.plot(cum_returns_train.index, cum_returns_train, label='Train Period', color='#2C3E50', linewidth=1.5) ax3.plot(cum_returns_test.index, cum_returns_test_adjusted, label='Test Period', color='#E74C3C', linewidth=1.5) ax3.axvline(x=pd.Timestamp(split_date), color='gray', linestyle='--', alpha=0.7, label='Split Date') ax3.set_ylabel('Cumulative Returns') ax3.set_xlabel('Date') ax3.set_title('Накопленная доходность стратегии') ax3.legend() ax3.grid(alpha=0.3) plt.tight_layout() plt.show() # Таблица метрик comparison = pd.DataFrame({ 'In-Sample': metrics_train, 'Out-of-Sample': metrics_test }) print("\nСРАВНЕНИЕ МЕТРИК") print(comparison) Рис. 1: Бэктест стратегии Mean reversion на паре Schlumberger (SLB) и Halliburton (HAL). Верхняя панель показывает динамику спреда между акциями с границей разделения на тренировочный и тестовый периоды. Средняя панель отображает z-score спреда с пороговыми уровнями для входа (±2.0) и выхода (±0.5) из позиций. Нижняя панель демонстрирует кумулятивную доходность стратегии отдельно для каждого периода, нормализованную для визуального сравнения динамики Train период: 2021-01-04 00:00:00 - 2023-12-29 00:00:00 Test период: 2024-01-02 00:00:00 - 2024-12-30 00:00:00 IN-SAMPLE МЕТРИКИ Cumulative Return: 28.16% Annual Return: 8.66% Annual Volatility: 12.60% Sharpe Ratio: 0.69 Max Drawdown: -15.25% Win Rate: 50.00% Total Trades: 230 OUT-OF-SAMPLE МЕТРИКИ Cumulative Return: 13.39% Annual Return: 13.45% Annual Volatility: 13.06% Sharpe Ratio: 1.03 Max Drawdown: -8.77% Win Rate: 53.09% Total Trades: 81 СРАВНЕНИЕ МЕТРИК In-Sample Out-of-Sample Cumulative Return 28.16% 13.39% Annual Return 8.66% 13.45% Annual Volatility 12.60% 13.06% Sharpe Ratio 0.69 1.03 Max Drawdown -15.25% -8.77% Win Rate 50.00% 53.09% Total Trades 230 81 Представленный код реализует полный цикл бэктестирования стратегии Mean reversion на паре акций Schlumberger (SLB) и Halliburton (HAL). Выбор этих инструментов обусловлен их высокой корреляцией как конкурентов в одной индустрии и достаточной ликвидностью для минимизации издержек. Логика стратегии основана на z-score нормализованного спреда между ценами: Когда z-score опускается ниже -2, спред аномально низкий — открываем длинную позицию, ожидая возврата к среднему; При z-score выше +2 открываем короткую позицию; Выход происходит при возврате z-score к уровню ±0.5, что соответствует частичной реверсии к среднему. Разделение данных на периоды позволяет оценить устойчивость параметров. Параметры стратегии (lookback=20, entry=2.0, exit=0.5) оптимизируются только на тренировочном периоде и применяются к тестовому без изменений. Важное правило — сдвиг сигналов на один период через shift(1) предотвращает look-ahead bias. Транзакционные издержки в 0.1% на сделку учитывают комиссии и bid-ask спред. Для пары ликвидных акций эта оценка консервативна. Издержки вычитаются при каждом изменении позиции, что моделирует реальные расходы на вход и выход из сделок. Метрики производительности рассчитываются идентично для обоих периодов: Sharpe ratio показывает риск-скорректированную доходность; Max Drawdown — наихудший сценарий потерь; Win Rate — процент прибыльных дней торговли. Сравнение метрик между периодами выявляет переподгонку: значительное ухудшение на out-of-sample указывает на нестабильность стратегии. Количество сделок влияет на статистическую значимость результатов и чувствительность к издержкам. Стратегия с 30 сделками за год дает недостаточную статистику для надежных выводов — случайность играет большую роль. Минимум 100-200 сделок требуется для начальной уверенности в устойчивости результатов, 500+ сделок дают высокую статистическую мощность. Как сравнивать in-sample с out-of-sample Деградация производительности модели между периодами — нормальное явление. Здесь важен не столь факт ухудешния метрик, а его масштаб. Падение коэффициента Sharpe на 20–30% обычно считается допустимым и ожидаемым. Снижение более чем на 50% говорит либо о сильной переоптимизации параметров стратегии под тренировочный период, либо о существенном изменении рыночных условий. Если же прибыльность полностью исчезает на out-of-sample, это указывает на то, что результат на in-sample был случайным. Сравнивая периоды, особое внимание нужно уделять максимальной просадке. Она важнее абсолютной доходности. Если просадка на out-of-sample в 2-3 раза глубже, чем на тренировочном периоде, это признак того, что стратегия плохо контролирует риск в новых условиях. Если же масштабы просадок сопоставимы, можно говорить о стабильности риск-менеджмента. Также имеет значение структура сделок. Если количество сделок резко изменяется на тестовом периоде, стратегия чувствительна к рыночному режиму. Например, если в бычьем тренде она совершает 100 сделок в год, а в боковом — только 20, то ее работоспособность зависит от направленного движения рынка, и в нейтральных условиях эффективность резко падает. Наконец, полезно смотреть на корреляцию доходностей между периодами. Высокая корреляция (выше 0.6) ежемесячных доходностей говорит о том, что стратегия продолжает использовать тот же источник альфы. Низкая корреляция (ниже 0.3) может означать, что источник прибыли изменился или что результаты на тренировочном периоде были случайными. Заключение Бэктестинг является ключевым инструментом оценки работоспособности торговых стратегий до их использования на реальном рынке. Понимание принципов корректного тестирования — от чистоты данных и формализации логики до учета издержек и проверки на устойчивость — позволяет избежать ложной уверенности и значительно снижает риск финансовых потерь. Без тщательного бэктеста даже перспективная стратегия может оказаться нежизнеспособной из-за скрытых ошибок, которые становятся очевидными только в реальных условиях. Усвоение принципов, описанных в статье, дает практическое преимущество: умение отличать реальные торговые преимущества от статистического шума. Ключевые требования к качественному бэктесту: Использовать корректные исторические данные и проверять их на пропуски, выбросы и ошибки. Формализовать логику стратегии без двусмысленных условий и «ручных» интерпретаций. Учитывать транзакционные издержки (комиссии, спред, проскальзывание). Избегать look-ahead bias и утечек данных, сдвигая сигналы относительно исполнения. Сравнивать результаты in-sample и out-of-sample, оценивая устойчивость стратегии в разных условиях рынка. Эти принципы — основа для построения стратегий, которые не только хорошо выглядят в теории, но и способны приносить устойчивый результат в реальной торговле. ### Регуляризация: L1 (Lasso) vs L2 (Ridge). Борьба с переобучением, отбор признаков Переобучение остается одной из центральных проблем в машинном обучении. Модель запоминает шум в обучающей выборке вместо выявления истинных закономерностей, что приводит к деградации качества на новых данных. Регуляризация решает эту проблему через добавление штрафа на сложность модели в целевую функцию. Два основных подхода — L1 (Lasso) и L2 (Ridge) — различаются не только математически, но и по практическим эффектам: первый обнуляет незначимые веса и выполняет автоматический отбор признаков, второй равномерно сжимает все коэффициенты без обнуления. Понимание различий между L1 и L2 регуляризацией крайне важно для построения надежных моделей. Выбор типа регуляризации влияет на интерпретируемость результатов, вычислительную эффективность и способность модели работать с высокоразмерными данными. В контексте алгоритмического трейдинга правильная регуляризация определяет стабильность весов факторов и устойчивость стратегии к рыночным изменениям. Механизм регуляризации Регуляризация модифицирует целевую функцию добавлением штрафного слагаемого, которое ограничивает величину параметров модели. Базовая задача минимизации среднеквадратичной ошибки трансформируется в задачу с дополнительным членом, контролирующим сложность: L(w) = MSE(w) + λ · Penalty(w) где: L(w) — итоговая функция потерь; MSE(w) — среднеквадратичная ошибка модели с весами w; λ — гиперпараметр, контролирующий силу регуляризации; Penalty(w) — штрафная функция на веса. Параметр λ балансирует между точностью на обучающих данных и простотой модели. При λ = 0 регуляризация отсутствует, при больших λ модель упрощается за счет роста ошибки на обучающей выборке. Математическое представление L1-регуляризация (Lasso) использует сумму абсолютных значений весов в качестве штрафа: L₁(w) = MSE(w) + λ · Σ|wᵢ| где: wᵢ — вес i-го признака; Σ|wᵢ| — L1-норма вектора весов; λ — коэффициент регуляризации. L1-норма приводит к разреженным решениям: часть весов становится точно равной нулю. Это свойство превращает Lasso в инструмент автоматического отбора признаков. L2-регуляризация (Ridge) штрафует квадраты весов: L₂(w) = MSE(w) + λ · Σwᵢ² где: wᵢ² — квадрат веса i-го признака; Σwᵢ² — L2-норма (квадрат евклидовой нормы) вектора весов; λ — коэффициент регуляризации. L2-норма сжимает все веса равномерно, но никогда не обнуляет их полностью. Веса стремятся к нулю асимптотически, но остаются ненулевыми даже для нерелевантных признаков. Градиенты штрафных функций принципиально различаются. Для L1 градиент равен sign(wᵢ), для L2 — 2wᵢ. Константный градиент L1 создает равномерное давление на все веса независимо от их величины, линейный градиент L2 сильнее воздействует на большие веса и слабее на малые. L1 vs L2: ключевые различия Выбор между L1 и L2 регуляризацией определяется структурой данных и целями моделирования. Различия проявляются на уровне геометрии задачи оптимизации, характера получаемых решений и вычислительных свойств алгоритмов. Геометрическая интерпретация Задачу регуляризации можно представить как оптимизацию с ограничениями. Минимизация функции потерь происходит в области, заданной условием на норму весов: для L1 это условие Σ|wᵢ| ≤ t; для L2 — Σwᵢ² ≤ t², где t определяется через λ. В двумерном пространстве весов L1-ограничение образует ромб с вершинами на осях координат. Контуры функции потерь (эллипсы для квадратичной функции) касаются этого ромба с высокой вероятностью в вершине, где один из весов равен нулю. L2-ограничение формирует круг, контакт с которым происходит в произвольной точке окружности без предпочтения осям координат. Рис. 1: Геометрическая интерпретация регуляризаций: слева зеленый ромб L1 с эллиптическими контурами функции потерь, касающимися вершин и создающими разреженные решения; справа синий круг L2, касающийся эллипсов в произвольной точке, что дает ненулевые веса Эта геометрия объясняет разреженность L1-решений. Чем выше размерность пространства признаков, тем больше вершин у L1-многогранника и тем выше вероятность получить решение с нулевыми компонентами. В пространстве 100 признаков L1-регуляризация естественным образом находит подмножество из 10-20 значимых переменных. Влияние на веса модели L1-регуляризация создает бимодальное распределение весов: часть коэффициентов обнуляется, остальные принимают ненулевые значения. Переход происходит дискретно — при увеличении λ веса последовательно становятся нулевыми, начиная с наименее значимых признаков. Этот процесс называется путем регуляризации (regularization path) и позволяет ранжировать признаки по важности. L2-регуляризация равномерно уменьшает все веса пропорционально их начальной величине. Распределение остается унимодальным со сдвигом к нулю. Большие веса сжимаются сильнее малых в абсолютном выражении, но относительное сжатие одинаково. Результат — плавное уменьшение всех коэффициентов без качественного изменения структуры модели. Для коррелированных признаков поведение методов различается принципиально: L1 выбирает один признак из группы коррелированных переменных случайным образом, остальные обнуляет; L2 распределяет вес между коррелированными признаками примерно поровну. В задачах с мультиколлинеарностью L2 обеспечивает более стабильные оценки, L1 создает разреженное решение ценой повышенной вариативности отбора. Вычислительная сложность также различается: L2-регуляризация допускает аналитическое решение через модификацию нормального уравнения: w = (X^T X + λI)^(-1) X^T y; L1 требует итеративных методов оптимизации — покоординантный спуск (coordinate descent) или проксимальный градиентный спуск (proximal gradient descent). На практике разница в скорости несущественна для современных библиотек, но L2 гарантирует единственность решения, в то время как L1 может иметь множество оптимумов при коллинеарности. Отбор признаков через L1 L1-регуляризация выполняет автоматический отбор признаков (feature selection) в процессе обучения модели. Этот механизм особенно ценен при работе с высокоразмерными данными, где количество признаков сопоставимо или превышает число наблюдений. Механизм зануления весов Обнуление весов в L1 происходит из-за негладкости штрафной функции в нуле. Производная |w| не определена при w = 0, что создает субградиент — множество значений от -1 до +1. Алгоритм оптимизации может достичь минимума функции потерь при точно нулевом весе, если градиент MSE по этому параметру попадает в интервал [-λ, λ]. Геометрически это соответствует ситуации, когда градиент функции ошибок компенсируется субградиентом L1-нормы. Для признака с малым влиянием на целевую переменную такая компенсация достигается при нулевом весе. Ridge-регуляризация такой компенсации не допускает из-за гладкости квадратичной функции — градиент 2λw стремится к нулю вместе с весом, но никогда не достигает конечного значения при w = 0. Порядок обнуления весов при увеличении λ детерминирован величиной градиента функции потерь. Признаки с малым градиентом (слабая корреляция с целевой переменной) обнуляются первыми. Построение полного пути регуляризации для диапазона λ показывает последовательность включения признаков и позволяет выбрать оптимальное подмножество через кросс-валидацию. Интерпретируемость моделей Разреженные модели упрощают интерпретацию результатов. Вместо анализа сотен слабых факторов специалист работает с 10-15 ключевыми переменными. В контексте количественного анализа это означает понимание драйверов доходности без информационного шума. Интерпретируемость сегодня как никогда ценна в финансовой индустрии. Модели, используемые для принятия инвестиционных решений, должны обосновывать свои рекомендации. Модель с 200 признаками, где каждый вносит микроскопический вклад, объяснить невозможно. Lasso-регрессия с 12 значимыми факторами позволяет построить нарратив: доходность определяется momentum, value, качеством отчетности и ликвидностью. Разреженность также улучшает обобщающую способность при ограниченных данных. Правило большого пальца: количество наблюдений должно превышать число признаков минимум в 10 раз. При 500 наблюдениях модель с 50 признаками рискованна, с 15 — приемлема. L1-регуляризация автоматически подгоняет количество активных параметров под объем выборки через выбор λ. Снижение размерности через L1 ускоряет переобучение модели при поступлении новых данных. В production-системах алгоритмического трейдинга модели переобучаются ежедневно или еженедельно. Разреженная модель требует пересчета меньшего числа параметров и быстрее адаптируется к изменениям рыночного режима. Выбор типа регуляризации Решение об использовании L1 или L2 базируется на свойствах данных, целях моделирования и ограничениях продакшен-среды. Универсального правила не существует, но накопленный опыт квантитативного анализа формирует практические эвристики. Когда использовать L1 L1-регуляризация оптимальна при подозрении на избыточность признаков. Типичный сценарий — датасет с десятками технических индикаторов, фундаментальных мультипликаторов и макроэкономических переменных, где большинство дублирует информацию или не содержит предсказательной силы. Lasso автоматически отсекает шум и выделяет информативное подмножество. Высокая размерность относительно объема выборки — прямое показание для L1. При соотношении признаков к наблюдениям выше 0.1 (например, 50 признаков на 500 наблюдений) разреженность решения становится необходимостью. Без отбора признаков модель неизбежно переобучится, запомнив случайные корреляции в обучающей выборке. Требование интерпретируемости усиливает аргументацию в пользу L1. Если модель используется для генерации торговых идей или объяснения инвестиционных решений клиентам, список из 10 ключевых факторов предпочтительнее 100 слабых сигналов. Регуляторы финансовых рынков также ожидают обоснования алгоритмических стратегий через понятные факторы риска. Вычислительные ограничения в реальном времени могут склонить выбор к L1. Разреженная модель требует вычисления меньшего числа признаков при инференсе. Для высокочастотных стратегий экономия 50 микросекунд на расчете 30 вместо 100 индикаторов может быть значимой. Неопределенность в релевантности признаков также фактор выбора в пользу L1. На начальном этапе исследования неясно, какие из сотен потенциальных факторов действительно работают. Lasso проводит первичный скрининг, оставляя кандидатов для углубленного анализа. Последующее моделирование строится на отобранном подмножестве. Когда использовать L2 L2-регуляризация предпочтительна при наличии множества слабых, но релевантных сигналов. Если априори известно, что доходность определяется комбинацией десятков факторов без явных доминант, Ridge сохранит вклад каждого компонента. Обнуление части весов через L1 приведет к потере информации. Мультиколлинеарность признаков — классическое применение L2. Группа высококоррелированных переменных (например, close, high, low цены) несет одну информацию, но их совместное использование дестабилизирует оценки без регуляризации. L1 случайным образом выберет один признак из группы, L2 распределит вес между всеми, повысив устойчивость модели к вариациям в данных. Стабильность коэффициентов во времени - важный фактор для продакшен-систем. L2 создает плавно меняющиеся веса при переобучении модели на скользящем окне, L1 может радикально менять набор активных признаков. Для стратегий с ежедневным ребалансом портфеля стабильность весов снижает транзакционные издержки и уменьшает дрейф распределения рисков (risk attribution drift). Если предполагаются малые выборки с большим количеством информативных признаков тоже стоит рассмотреть L2. При 200 наблюдениях и 50 коррелированных технических индикаторов L1 оставит 5-7 признаков, потенциально упустив значимую информацию. L2 использует все 50 индикаторов со сжатыми весами, извлекая максимум из ограниченных данных. Отсутствие требований к интерпретируемости нивелирует главное преимущество L1. Если модель работает как black-box компонент торговой системы без необходимости объяснения решений, плотное решение L2 может обеспечить лучшее качество предсказаний. Настройка гиперпараметра Лямбда Выбор оптимального значения λ обычно осуществляется через кросс-валидацию. Диапазон кандидатов покрывает несколько порядков величины — от 10^(-4) до 10^2 для нормализованных признаков. Логарифмическая шкала обеспечивает равномерное исследование пространства параметров. K-fold кросс-валидация разбивает данные на K фолдов (обычно 5–10), последовательно обучая модель на K−1 фолдах и проверяя ее на оставшемся. Для каждого значения λ вычисляется средняя ошибка по всем фолдам. Оптимальное λ минимизирует валидационную ошибку, обеспечивая баланс между смещением (bias) и дисперсией (variance) модели. import numpy as np import yfinance as yf from sklearn.linear_model import LassoCV, RidgeCV from sklearn.preprocessing import StandardScaler from sklearn.model_selection import TimeSeriesSplit import matplotlib.pyplot as plt # Загрузка данных ticker = yf.Ticker("2330.TW") # TSMC data = ticker.history(period="5y", interval="1d") # Построение признаков: лаги доходности и технические индикаторы returns = data['Close'].pct_change() features = [] for lag in range(1, 21): features.append(returns.shift(lag)) # Скользящие средние разных периодов for window in [5, 10, 20, 50]: ma = data['Close'].rolling(window=window).mean() features.append((data['Close'] - ma) / ma) # Волатильность for window in [5, 10, 20]: vol = returns.rolling(window=window).std() features.append(vol) # Объем торгов volume_ma = data['Volume'].rolling(window=20).mean() features.append((data['Volume'] - volume_ma) / volume_ma) # Объединение в датафрейм import pandas as pd X = pd.concat(features, axis=1).dropna() y = returns.shift(-1).loc[X.index].dropna() X = X.loc[y.index] # Нормализация scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # Time series split для финансовых данных tscv = TimeSeriesSplit(n_splits=5) # Подбор параметров для Lasso alphas_lasso = np.logspace(-4, 1, 50) lasso_cv = LassoCV(alphas=alphas_lasso, cv=tscv, max_iter=10000) lasso_cv.fit(X_scaled, y) # Подбор параметров для Ridge alphas_ridge = np.logspace(-2, 3, 50) ridge_cv = RidgeCV(alphas=alphas_ridge, cv=tscv) ridge_cv.fit(X_scaled, y) # Визуализация зависимости ошибки от lambda fig, axes = plt.subplots(1, 2, figsize=(14, 5)) # Lasso mse_lasso = np.mean(lasso_cv.mse_path_, axis=1) axes[0].plot(alphas_lasso, mse_lasso, 'o-', color='#2C3E50', linewidth=2) axes[0].axvline(lasso_cv.alpha_, color='#E74C3C', linestyle='--', linewidth=2, label=f'Optimal λ = {lasso_cv.alpha_:.4f}') axes[0].set_xscale('log') axes[0].set_xlabel('Lambda (L1)', fontsize=11) axes[0].set_ylabel('Mean Squared Error', fontsize=11) axes[0].set_title('Lasso Regularization Path', fontsize=12, fontweight='bold') axes[0].legend() axes[0].grid(True, alpha=0.3) # Ridge # Для Ridge нужно вычислить ошибки вручную mse_ridge = [] for alpha in alphas_ridge: from sklearn.linear_model import Ridge ridge_temp = Ridge(alpha=alpha) scores = [] for train_idx, val_idx in tscv.split(X_scaled): ridge_temp.fit(X_scaled[train_idx], y.iloc[train_idx]) pred = ridge_temp.predict(X_scaled[val_idx]) mse = np.mean((y.iloc[val_idx] - pred) ** 2) scores.append(mse) mse_ridge.append(np.mean(scores)) axes[1].plot(alphas_ridge, mse_ridge, 'o-', color='#2C3E50', linewidth=2) axes[1].axvline(ridge_cv.alpha_, color='#E74C3C', linestyle='--', linewidth=2, label=f'Optimal λ = {ridge_cv.alpha_:.2f}') axes[1].set_xscale('log') axes[1].set_xlabel('Lambda (L2)', fontsize=11) axes[1].set_ylabel('Mean Squared Error', fontsize=11) axes[1].set_title('Ridge Regularization Path', fontsize=12, fontweight='bold') axes[1].legend() axes[1].grid(True, alpha=0.3) plt.tight_layout() plt.show() # Сравнение количества ненулевых коэффициентов n_nonzero_lasso = np.sum(lasso_cv.coef_ != 0) n_nonzero_ridge = np.sum(np.abs(ridge_cv.coef_) > 1e-5) print(f"\nLasso: {n_nonzero_lasso} активных признаков из {X.shape[1]}") print(f"Ridge: все {n_nonzero_ridge} признаков активны") print(f"\nОптимальные параметры:") print(f"Lasso λ = {lasso_cv.alpha_:.6f}") print(f"Ridge λ = {ridge_cv.alpha_:.2f}") Рис. 2: Зависимость ошибки валидации от силы регуляризации для Lasso и Ridge. Левый график показывает характерную резкую кривую для L1: при малых λ модель переобучается, при больших — недообучается. Правый график демонстрирует более плавную зависимость для L2 без резких переходов. Вертикальные линии отмечают оптимальные значения λ, минимизирующие валидационную ошибку Lasso: 0 активных признаков из 28 Ridge: все 28 признаков активны Оптимальные параметры: Lasso λ = 0.002121 Ridge λ = 1000.00 В примере выше Lasso занулил все признаки. На практике такое часто происходит из-за нескольких причин, связанных с масштабом данных и особенностями финансовых временных рядов: Основные причины: Целевая переменная (y) очень мала по масштабу. Мы использовали returns.shift(-1), т.е. дневную доходность. Обычно она порядка 0.001–0.02; Стандартизированные признаки (X_scaled) имеют среднее 0 и стандартное отклонение 1. Lasso с alpha ~ 0.002 для такого масштаба может быть слишком большим, и оптимизация зануляет все коэффициенты; L1 регуляризация чувствительна к масштабу признаков и целевой переменной. Lasso минимизирует ||y - Xw||^2 + alpha * ||w||_1. Если значения y очень маленькие, alpha даже ~0.002 может превышать «сигнал» в данных. Ridge с L2 менее чувствителен, поэтому  Ridge выбирает разумное большое alpha; Численные эффекты и размерность. Используем много лагов (20) + индикаторов (около 28 признаков). Для маленького y и маленького объема выборки (5 лет дневных данных — примерно 1250 точек) Lasso часто выбирает зануление всех коэффициентов при высоком alpha относительно масштаба ошибки. Результат показывает драматическое различие в разреженности: Lasso активирует 0 признаков, Ridge сохраняет все с ненулевыми весами. Оптимальные значения λ различаются на порядки из-за разной природы штрафных функций. Визуализация пути регуляризации демонстрирует компромисс между сложностью модели и качеством fit. При λ → 0 ошибка валидации растет из-за переобучения, при λ → ∞ — из-за недообучения. Оптимум находится в точке минимума U-образной кривой. Для Lasso эта кривая имеет более выраженную структуру с участками плато, соответствующими обнулению очередного признака. Выбор количества фолдов K влияет на баланс смещения / дисперсии процедуры валидации. Малые K (3-5) снижают вычислительные затраты, но увеличивают вариативность оценки ошибки. Большие K (10-20) дают более стабильную оценку ценой роста времени обучения. Для временных рядов K = 5-7 обеспечивает разумный баланс. Применение в алгоритмическом трейдинге Регуляризация играет ключевую роль в построении количественных торговых стратегий. Финансовые данные характеризуются высоким уровнем шума, нестационарностью и ограниченной историей наблюдений, что создает благоприятную среду для переобучения. Построение предиктивных моделей Факторные модели доходности составляют основу количественного инвестирования. Типичная постановка: предсказать избыточную доходность актива на основе его характеристик — мультипликаторов оценки, momentum индикаторов, качества отчетности, ликвидности. Количество потенциальных факторов исчисляется десятками или сотнями. L1-регуляризация идентифицирует подмножество значимых предикторов из расширенного набора кандидатов. Процесс начинается с конструирования широкой палитры факторов: фундаментальные мультипликаторы (P/E, P/B, EV/EBITDA, debt-to-equity), технические индикаторы различных таймфреймов, статистические характеристики ценовых рядов, альтернативные данные. Lasso-регрессия на истории автоматически отбирает 10-20 факторов с наибольшей предсказательной силой. Отобранные факторы формируют базис для ранжирования активов. Веса факторов определяют скоринговую функцию, которая присваивает каждому активу числовое значение — ожидаемую избыточную доходность. Портфель строится через long позиции в активах с высоким скором и short в активах с низким скором. Разреженность модели упрощает хеджирование факторных экспозиций и мониторинг риска. Регуляризация также применяется в моделях краткосрочного прогнозирования цен для внутридневной торговли. Микроструктурные признаки (спред bid-ask, глубина стакана, дисбаланс ордеров, объем на разных ценовых уровнях) и лаги доходностей различных таймфреймов создают высокоразмерное пространство. L1 выделяет критичные индикаторы, L2 стабилизирует оценки при мультиколлинеарности микроструктурных переменных. Стабильность весов во времени Нестационарность финансовых рынков требует периодического переобучения моделей. Факторные премии меняются в зависимости от рыночного режима: Фактор моментума движения цены (Momentum) эффективен на трендовых рынках; Фактор возврата к среднему (Mean Reversion) проявляется в боковых рынках; Фактор стоимости (Value) активируется после рыночных коррекций. Модель должна адаптироваться к текущим условиям через обновление весов. L2-регуляризация обеспечивает плавное изменение коэффициентов при переобучении на скользящем окне. Если окно сдвигается на один день, веса факторов меняются незначительно — на несколько процентов. Это свойство критично для стратегий с ежедневной или еженедельной ребалансировкой портфеля. Радикальное изменение весов приводит к высоким транзакционным издержкам и потенциальному влиянию на котировки (market impact) при ликвидации крупных позиций. L1-регуляризация создает дискретные изменения набора активных признаков. При сдвиге окна обучения фактор может резко включиться (вес изменяется с 0 до значимой величины) или выключиться. Для долгосрочных стратегий с редкой ребалансировкой это приемлемо. Для высокочастотных стратегий нестабильность набора признаков проблематична. Компромиссное решение — двухэтапный подход: Первый этап использует L1 для идентификации значимых факторов на длинной истории (3-5 лет); Второй этап применяет L2 для оценки весов отобранных факторов на коротком скользящем окне (6-12 месяцев). Набор факторов обновляется раз в квартал, веса — еженедельно. Такая схема сочетает преимущества отбора признаков и стабильности оценок. Мониторинг дрейфа факторов (factor drift) также требует регуляризации. Если вес фактора в необобщенной модели колеблется на ±50% между соседними периодами переобучения, это сигнализирует о нестабильности. L2-регуляризация демпфирует такие колебания, делая систему более устойчивой к выбросам и режимным сдвигам. Валидация на новых данных с регуляризацией показывает реалистичные ожидания от стратегии. Переобученная модель без регуляризации демонстрирует отличные метрики на истории, но проваливается в продакшене. Регуляризованная модель жертвует частью исторической доходности ради устойчивости на новых данных. Разница значений коэффициента Шарпа на исторических данных (in-sample) и на новых данных (out-of-sample) должна составлять 20–30%, а не 100–200%. Заключение L1 и L2 регуляризация представляют собой фундаментальные инструменты борьбы с переобучением, различающиеся механизмом действия и областями оптимального применения. Lasso обнуляет незначимые веса, создавая разреженные интерпретируемые модели с автоматическим отбором признаков — незаменимое свойство при работе с высокоразмерными данными и необходимостью объяснения решений; Ridge равномерно сжимает все коэффициенты, обеспечивая стабильность оценок при мультиколлинеарности и сохранение информации от множества слабых сигналов. Выбор типа регуляризации определяется структурой задачи. В количественном анализе комбинация методов — L1 для отбора факторов на длинной истории и L2 для адаптации весов на скользящем окне — обеспечивает баланс между точностью предсказаний и устойчивостью к рыночным изменениям. Корректная настройка λ через кросс-валидацию превращает регуляризацию из теоретического концепта в практический инструмент построения надежных ML-моделей. ### Матожидание в статистике и трейдинге Математическое ожидание определяет среднее значение случайной величины при бесконечном количестве наблюдений. В трейдинге этот показатель отвечает на вопрос: какую прибыль или убыток принесет стратегия в долгосрочной перспективе. Стратегия с положительным матожиданием доходности генерирует прибыль при достаточном количестве сделок, стратегия с отрицательным — приводит к убыткам независимо от краткосрочных результатов. Понимание матожидания позволяет отделить случайную удачу от систематического преимущества. Трейдер может выиграть серию сделок благодаря везению, но только положительное матожидание обеспечивает стабильную прибыльность на дистанции сотен и тысяч транзакций. Расчет матожидания для дискретных случайных величин Для дискретной случайной величины X с возможными значениями x₁, x₂, ..., xₙ и соответствующими вероятностями p₁, p₂, ..., pₙ математическое ожидание рассчитывается как: E[X] = x₁ · p₁ + x₂ · p₂ + ... + xₙ · pₙ где: E[X] — математическое ожидание случайной величины X; xᵢ — возможное значение случайной величины; pᵢ — вероятность появления значения xᵢ. Формула определяет взвешенное среднее всех возможных исходов, где веса соответствуют вероятностям. Пример торговой стратегии: вероятность прибыльной сделки 40% с доходностью +2%, вероятность убыточной сделки 60% с доходностью -1%. Математическое ожидание доходности: E[R] = 0.4 · 2% + 0.6 · (-1%) = 0.8% - 0.6% = 0.2%. Положительное значение указывает на прибыльность стратегии в долгосрочной перспективе. Математическое ожидание непрерывных распределений Для непрерывной случайной величины с функцией плотности вероятности f(x) математическое ожидание определяется интегралом: E[X] = ∫ x · f(x) dx где f(x) — функция плотности вероятности, а интегрирование проводится по всей области определения. В трейдинге непрерывные распределения моделируют доходности активов. Нормальное распределение часто используется как первое приближение, хотя реальные доходности демонстрируют толстые хвосты и асимметрию. Свойства математического ожидания Линейность — ключевое свойство для практических расчетов. Для случайных величин X и Y и констант a, b: E[aX + bY] = aE[X] + bE[Y] Это позволяет разбивать сложные стратегии на компоненты и анализировать каждый из них независимо. Математическое ожидание суммы равно сумме математических ожиданий независимо от корреляции между величинами. Для независимых случайных величин выполняется мультипликативное свойство: E[X · Y] = E[X] · E[Y] Это свойство применяется при расчете ожидаемой доходности стратегий с реинвестированием прибыли, хотя на практике доходности в разные периоды редко бывают строго независимыми. Расчет ожидаемой доходности сделки Базовая формула ожидаемой доходности учитывает вероятность успеха и соотношение прибыли к убытку: E[R] = P(win) · Avg(win) + P(loss) · Avg(loss) где: P(win) — вероятность прибыльной сделки (винрейт); Avg(win) — средняя прибыль в прибыльной сделке; P(loss) — вероятность убыточной сделки; Avg(loss) — средний убыток в убыточной сделке (отрицательное значение). Стратегия прибыльна, если E[R] > 0. При винрейте 45%, средней прибыли $150 и среднем убытке -$80: E[R] = 0.45 · 150 + 0.55 · (-80) = 67.5 - 44 = $23.5 на сделку. Альтернативная запись через Risk/Reward Ratio (RRR): E[R] = P(win) · RRR - P(loss) где RRR = Avg(win) / |Avg(loss)|. Эта форма удобна для быстрой оценки: при RRR = 2 и винрейте 40% получаем E[R] = 0.4 · 2 - 0.6 = 0.2 или 20% доходности на риск. Учет комиссий и проскальзывания Профессионалы трейдинга всегда учитывают в расчете матожидания транзакционные издержки: E[R_net] = E[R_gross] - 2 · commission - slippage где: E[R_gross] — доходность без учета издержек; commission — комиссия брокера за сделку (вход и выход); slippage — проскальзывание при исполнении ордера. Учет комиссий и проскальзывания особенно важен для высокочастотных стратегий. При комиссии 0.1% и проскальзывании 0.05% общие издержки составляют 0.3% на round-trip (вход + выход). Стратегия с валовой доходностью 0.4% на сделку имеет чистую доходность всего 0.1%. Ниже пример кода для расчета этих метрик. def calculate_expected_return(win_rate, avg_win, avg_loss, commission=0.001, slippage=0.0005): """ Расчет ожидаемой доходности с учетом издержек. Parameters: - win_rate: вероятность прибыльной сделки (0-1) - avg_win: средняя прибыль в % (положительное число) - avg_loss: средний убыток в % (отрицательное число) - commission: комиссия за сделку в долях - slippage: проскальзывание в долях """ loss_rate = 1 - win_rate gross_return = win_rate * avg_win + loss_rate * avg_loss transaction_costs = 2 * commission + slippage net_return = gross_return - transaction_costs return { 'gross_return': gross_return, 'transaction_costs': transaction_costs, 'net_return': net_return } # Пример использования result = calculate_expected_return( win_rate=0.52, avg_win=1.5, avg_loss=-1.2, commission=0.001, slippage=0.0005 ) print(f"Gross Return: {result['gross_return']:.4f}%") print(f"Transaction Costs: {result['transaction_costs']:.4f}%") print(f"Net Return: {result['net_return']:.4f}%") Gross Return: 0.2040% Transaction Costs: 0.0025% Net Return: 0.2015% Функция рассчитывает валовую и чистую доходность, явно разделяя влияние стратегии и издержек. Результат показывает, какую часть прибыли забирают комиссии. Положительное матожидание как основа прибыльности Положительное матожидание означает, что при достаточно большом числе независимых сделок средняя прибыль будет стремиться к положительному значению. Однако само по себе положительное матожидание не гарантирует прибыль в каждом отдельном периоде. Если дисперсия доходности высока или количество сделок недостаточно, стратегия может демонстрировать убыток из-за статистических флуктуаций. Минимально приемлемое матожидание зависит от волатильности результатов. Например, для стратегии со средним доходом на сделку μ = 0.3% и стандартным отклонением σ = 2% коэффициент Шарпа составит всего 0.15. Это слишком низкое значение для практического применения: стратегия имеет преимущество «в среднем», но шум почти полностью «съедает» этот эффект. На практике для алгоритмических стратегий целевым ориентиром считается Sharpe Ratio > 1, что предполагает μ ≥ σ (при прочих равных). Временной горизонт определяет, сможет ли стратегическое преимущество реализоваться. Если стратегия совершает 100 сделок в год, ожидаемая годовая доходность составит около 30% (100 × 0.3%). Однако суммарный риск также растет: стандартное отклонение годовой доходности будет σ × √100 = 2% × 10 = 20%. Таким образом, годовой результат распределен примерно как N(30%, 20%), что означает широкий диапазон возможных исходов — от значительной прибыли до отрицательной доходности в отдельные годы. На практике это означает, что положительное матожидание должно сопровождаться контролем дисперсии и достаточным количеством сделок, иначе статистическое преимущество просто не успеет проявиться. import numpy as np import matplotlib.pyplot as plt sigma = 0.02 # стандартное отклонение 2% одинаковое для всех mu_low = 0.003 # низкий Sharpe mu_mid = 0.01 # средний Sharpe mu_high = 0.02 # высокий Sharpe n_trades = 100 n_sims = 10000 # Моделирование годовой доходности returns_low = np.random.normal(mu_low, sigma, (n_sims, n_trades)).sum(axis=1) returns_mid = np.random.normal(mu_mid, sigma, (n_sims, n_trades)).sum(axis=1) returns_high = np.random.normal(mu_high, sigma, (n_sims, n_trades)).sum(axis=1) # Построение графика с тремя распределениями plt.figure(figsize=(10, 6)) plt.hist(returns_low, bins=60, alpha=0.4, label="Низкий Sharpe (μ = 0.3% на сделку)", density=True) plt.hist(returns_mid, bins=60, alpha=0.4, label="Средний Sharpe (μ = 1% на сделку)", density=True) plt.hist(returns_high, bins=60, alpha=0.4, label="Высокий Sharpe (μ = 2% на сделку)", density=True) plt.title("Сравнение распределений годовой доходности при разных Sharpe (σ = 2%)") plt.xlabel("Годовая доходность") plt.ylabel("Плотность распределения") plt.legend() plt.show() Рис. 1: Сравнение распределений годовой доходности при разных значениях коэффициента Шарпа. При одинаковой волатильности (σ) увеличение математического ожидания (μ) сдвигает распределение доходности вправо и уменьшает вероятность отрицательного результата. Таким образом, Sharpe Ratio отражает не только «положительность матожидания», но и стабильность реализации прибыли во времени Оценка математического ожидания торговых стратегий Expectancy — метрика, нормализующая матожидание относительно риска одной сделки: Expectancy = E[R] / |Avg(loss)| Значение показывает, сколько единиц риска приносит средняя сделка. Expectancy = 0.5 означает среднюю прибыль в половину среднего убытка. Положительная величина указывает на вероятность альфа-стратегии. Average P&L per trade — абсолютное значение ожидаемой прибыли на сделку без нормализации. Метрика полезна для сравнения стратегий с одинаковым размером позиции, но не учитывает масштаб риска. System Quality Number (SQN) оценивает качество стратегии через отношение среднего P&L к его стандартному отклонению: SQN = (E[R] / σ) · √n где: σ — стандартное отклонение доходности сделок; n — количество сделок. SQN > 3 указывает на качественную систему, SQN > 5 — на отличную. Метрика учитывает не только матожидание, но и стабильность результатов. Связь с винрейтом и Risk/Reward Ratio Показатели винрейта (winrate) и отношения риск/прибыль (RRR) совместно определяют математическое ожидание стратегии. Однако разные комбинации этих параметров могут иметь одинаковое матожидание при совершенно различных характеристиках кривой доходности — волатильности, глубине просадок и стабильности результатов. Стратегия с винрейтом 60% и отношением риск/прибыль RRR = 1 имеет математическое ожидание: E[R] = 0.6 · 1 − 0.4 · 1 = 0.2R. Стратегия с винрейтом 40% и RRR = 2 дает то же математическое ожидание: E[R] = 0.4 · 2 − 0.6 · 1 = 0.2R. Таким образом, высокий winrate в сочетании с низким RRR действительно создает визуально плавную кривую доходности (equity curve) — прибыль поступает часто и небольшими шагами. Однако такие стратегии подвержены редким, но глубоким просадкам, когда один убыток перекрывает результат десятков прибыльных сделок. Напротив, низкий winrate при высоком RRR приводит к частым небольшим убыткам и редким большим прибылям. Такая кривая выглядит более «рваной», но риск катастрофической просадки существенно ниже: размер убытка контролируем и ограничен. Психологически большинству трейдеров комфортнее первый вариант, однако именно он несет более высокий риск разрушения капитала при нарушении дисциплины или аномальном рыночном движении. Минимальный винрейт для прибыльности при заданном RRR: P(win)_min = 1 / (1 + RRR) При RRR = 2 минимальный винрейт составляет 33.3%. Стратегии с более низким винрейтом убыточны независимо от размера прибылей. Формула определяет точку безубыточности для любого соотношения риска к прибыли. Практические примеры расчета Анализ стратегии на исторических данных показывает распределение результатов сделок. Импортируем библиотеки и загружаем данные о сделках: import numpy as np import pandas as pd from scipy import stats # Данные о результатах сделок (в процентах) trades = pd.Series([ 1.2, -0.8, 2.1, -0.5, -0.9, 1.5, 0.7, -1.1, 3.2, -0.6, 1.8, -0.7, 0.9, -1.3, 2.5, 1.1, -0.4, -0.9, 1.6, -0.8, 2.3, -0.5, 1.4, -1.0, 0.8, 1.9, -0.6, -1.2, 2.0, -0.7 ]) def analyze_strategy_expectancy(trades): """Анализ математического ожидания торговой стратегии.""" wins = trades[trades > 0] losses = trades[trades < 0] win_rate = len(wins) / len(trades) avg_win = wins.mean() avg_loss = losses.mean() expectancy = win_rate * avg_win + (1 - win_rate) * avg_loss rrr = avg_win / abs(avg_loss) # Статистическая значимость t_stat, p_value = stats.ttest_1samp(trades, 0) # Доверительный интервал для матожидания conf_interval = stats.t.interval( 0.95, len(trades) - 1, loc=trades.mean(), scale=stats.sem(trades) ) return { 'expectancy': expectancy, 'win_rate': win_rate, 'avg_win': avg_win, 'avg_loss': avg_loss, 'rrr': rrr, 'std_dev': trades.std(), 't_statistic': t_stat, 'p_value': p_value, 'conf_interval_95': conf_interval, 'sqn': (expectancy / trades.std()) * np.sqrt(len(trades)) } results = analyze_strategy_expectancy(trades) print(f"Expectancy: {results['expectancy']:.4f}%") print(f"Win Rate: {results['win_rate']:.2%}") print(f"Risk/Reward Ratio: {results['rrr']:.2f}") print(f"SQN: {results['sqn']:.2f}") print(f"95% CI: [{results['conf_interval_95'][0]:.4f}%, {results['conf_interval_95'][1]:.4f}%]") print(f"P-value: {results['p_value']:.4f}") Expectancy: 0.4333% Win Rate: 50.00% Risk/Reward Ratio: 2.08 SQN: 1.75 95% CI: [-0.0732%, 0.9399%] P-value: 0.0908 Код вычисляет базовые метрики и проверяет статистическую значимость положительного матожидания. T-test определяет, можно ли отвергнуть гипотезу о нулевой доходности. Доверительный интервал показывает диапазон вероятных значений истинного матожидания. Интерпретация: Expectancy (матожидание): 0.43% — небольшое, но положительное; Win Rate (% прибыльных сделок): 50% — сбалансированная стратегия; RRR: 2.08 — стратегия с хорошим отношением прибыли к риску; SQN: 1.75 — считается средним качеством системы (по Дрекслеру: <1 — плохо, 1–2 — средне, >2 — хорошо); CI: [-0.073%, 0.9399%] — среднее может быть и отрицательным, статистически не сильно значимо (p=0.0908). Вывод: матожидание положительное и метрики в целом неплохие, однако статистическая значимость невысока, стратегия может быть прибыльной в долгосрочной перспективе, но для уверенности нужен больший объем данных. Применение матожидания в расчете рисков Критерий Келли Формула Келли определяет оптимальную долю капитала для размещения в одной сделке, максимизируя долгосрочный рост портфеля: f* = (p · b - q) / b где: f* — оптимальная доля капитала; p — вероятность выигрыша; q — вероятность проигрыша (1 - p); b — отношение выигрыша к проигрышу (RRR). При винрейте 55% и RRR = 1.5 получаем: f* = (0.55 · 1.5 - 0.45) / 1.5 = 0.25 или 25% капитала. Формула предполагает возможность дробных ставок и реинвестирования прибыли. Критерий Келли максимизирует медианный рост капитала, но приводит к высокой волатильности эквити. Практическое применение использует дробный Келли (обычно 0.25-0.5 от f*) для снижения просадок. Half-Kelly при тех же параметрах дает размер позиции 12.5% вместо 25%. def kelly_criterion(win_rate, rrr): """Расчет критерия Келли.""" p = win_rate q = 1 - win_rate b = rrr kelly_fraction = (p * b - q) / b # Проверка на положительное матожидание if kelly_fraction <= 0: return 0 return kelly_fraction def fractional_kelly(win_rate, rrr, fraction=0.5): """Дробный Келли для снижения волатильности.""" full_kelly = kelly_criterion(win_rate, rrr) return full_kelly * fraction # Примеры расчета strategies = [ {'name': 'High WR', 'win_rate': 0.65, 'rrr': 0.8}, {'name': 'Balanced', 'win_rate': 0.55, 'rrr': 1.5}, {'name': 'Low WR', 'win_rate': 0.40, 'rrr': 2.5} ] for strategy in strategies: full = kelly_criterion(strategy['win_rate'], strategy['rrr']) half = fractional_kelly(strategy['win_rate'], strategy['rrr'], 0.5) quarter = fractional_kelly(strategy['win_rate'], strategy['rrr'], 0.25) print(f"\n{strategy['name']}:") print(f" Full Kelly: {full:.2%}") print(f" Half Kelly: {half:.2%}") print(f" Quarter Kelly: {quarter:.2%}") High WR: Full Kelly: 21.25% Half Kelly: 10.63% Quarter Kelly: 5.31% Balanced: Full Kelly: 25.00% Half Kelly: 12.50% Quarter Kelly: 6.25% Low WR: Full Kelly: 16.00% Half Kelly: 8.00% Quarter Kelly: 4.00% Функции рассчитывают полный и дробный критерий Келли для разных стратегий. Результаты показывают зависимость оптимального размера позиции от характеристик системы. Оптимальный размер позиции Размер позиции влияет на реализацию матожидания через количество сделок и риск разорения. Слишком большие позиции увеличивают просадки и вероятность маржин-кола. Слишком малые не используют потенциал стратегии полностью. Показатель Fixed Fractional Position Sizing выделяет фиксированный процент капитала на сделку: Position Size = Capital · f где f — выбранная доля (обычно 1-5% для консервативных стратегий). При капитале $100,000 и f = 2% размер позиции составляет $2,000. Метод прост в реализации, но не учитывает изменение волатильности активов. Показатель Volatility-Based Position Sizing нормализует риск относительно волатильности: Position Size = (Capital · f) / (ATR · multiplier) где ATR (Average True Range) измеряет текущую волатильность. Подход выравнивает риск между активами с разной волатильностью и адаптируется к изменению рыночных условий. Учет дисперсии доходности Матожидание не учитывает разброс результатов. Две стратегии с одинаковым E[R] = 1% могут иметь σ = 0.5% и σ = 5%. Первая обеспечивает стабильный рост, вторая создает высокие просадки. Максимальная просадка (Maximum Drawdown) связана с дисперсией через коэффициент Шарпа. Эмпирическое правило: Maximum Drawdown ≈ σ · √(n) / 2 где n — количество периодов. При σ = 2% и 250 торговых дней ожидаемая просадка около 15.8%. Метрика Risk-Adjusted Return учитывает волатильность при оценке эффективности: Sharpe Ratio = (E[R] - Rf) / σ где Rf — безрисковая ставка. Для внутридневных стратегий Rf ≈ 0. Sharpe Ratio > 1 указывает на качественную стратегию, > 2 — на отличную. Метрика позволяет сравнивать стратегии с разным уровнем риска. Практический расчет матожидания на Python Бэктестинг стратегии требует точного расчета доходности сделок с учетом всех факторов. Загружаем исторические данные и применяем торговую логику: import pandas as pd import numpy as np def backtest_strategy(prices, entry_signal, exit_signal, initial_capital=100000, commission=0.001, slippage=0.0005): """ Бэктест стратегии с расчетом матожидания. Parameters: - prices: DataFrame с колонками ['open', 'high', 'low', 'close'] - entry_signal: Series с булевыми значениями для входа - exit_signal: Series с булевыми значениями для выхода - initial_capital: начальный капитал - commission: комиссия брокера (доля) - slippage: проскальзывание (доля) """ position = 0 entry_price = 0 trades = [] capital = initial_capital for i in range(len(prices)): # Вход в позицию if entry_signal.iloc[i] and position == 0: entry_price = prices['close'].iloc[i] * (1 + slippage) position = capital / entry_price capital = 0 # Выход из позиции elif exit_signal.iloc[i] and position > 0: exit_price = prices['close'].iloc[i] * (1 - slippage) # Расчет P&L с учетом комиссий gross_pnl = position * (exit_price - entry_price) transaction_costs = (position * entry_price * commission + position * exit_price * commission) net_pnl = gross_pnl - transaction_costs capital = position * exit_price - transaction_costs # Сохранение результата сделки trades.append({ 'entry_price': entry_price, 'exit_price': exit_price, 'pnl': net_pnl, 'return': net_pnl / (position * entry_price), 'gross_return': (exit_price - entry_price) / entry_price }) position = 0 trades_df = pd.DataFrame(trades) if len(trades_df) == 0: return None # Расчет метрик expectancy = trades_df['return'].mean() win_rate = (trades_df['return'] > 0).sum() / len(trades_df) avg_win = trades_df[trades_df['return'] > 0]['return'].mean() avg_loss = trades_df[trades_df['return'] < 0]['return'].mean() metrics = { 'total_trades': len(trades_df), 'expectancy': expectancy, 'win_rate': win_rate, 'avg_win': avg_win, 'avg_loss': avg_loss, 'rrr': avg_win / abs(avg_loss) if avg_loss != 0 else np.nan, 'total_return': (capital - initial_capital) / initial_capital, 'sharpe': expectancy / trades_df['return'].std() if trades_df['return'].std() > 0 else 0 } return metrics, trades_df # Пример генерации сигналов (простая стратегия на основе SMA) # В реальности здесь будет логика вашей стратегии np.random.seed(42) dates = pd.date_range('2023-01-01', periods=500, freq='D') prices = pd.DataFrame({ 'open': 100 + np.random.randn(500).cumsum(), 'high': 101 + np.random.randn(500).cumsum(), 'low': 99 + np.random.randn(500).cumsum(), 'close': 100 + np.random.randn(500).cumsum() }, index=dates) # Простые сигналы для демонстрации sma_short = prices['close'].rolling(20).mean() sma_long = prices['close'].rolling(50).mean() entry_signal = (sma_short > sma_long) & (sma_short.shift(1) <= sma_long.shift(1)) exit_signal = (sma_short < sma_long) & (sma_short.shift(1) >= sma_long.shift(1)) metrics, trades = backtest_strategy(prices, entry_signal, exit_signal) if metrics: print(f"Total Trades: {metrics['total_trades']}") print(f"Expectancy: {metrics['expectancy']:.4%}") print(f"Win Rate: {metrics['win_rate']:.2%}") print(f"Risk/Reward: {metrics['rrr']:.2f}") print(f"Sharpe Ratio: {metrics['sharpe']:.2f}") Total Trades: 3 Expectancy: 3.7057% Win Rate: 33.33% Risk/Reward: 5.13 Sharpe Ratio: 0.29 Функция моделирует исполнение сделок с учетом реалистичных условий: проскальзывание при входе и выходе, комиссии на обе транзакции. Результат включает детализацию каждой сделки и агрегированные метрики стратегии. Рассмотрим полученные показатели: Всего совершено 3 сделки; Матожидание (expectancy) составляет 3,71%, что означает, что в среднем каждая сделка приносит небольшую положительную доходность; Win Rate равен 33,33%, то есть одна из 3-х сделок оказалась прибыльной, а две — убыточными; Несмотря на низкий процент выигрышей, отношение риска к прибыли (RRR = 5,13) показывает, что прибыльная сделка в среднем значительно превышает по величине убытки от проигрышных сделок; Sharpe Ratio равен 0,29, что указывает на умеренную доходность стратегии относительно ее волатильности. В совокупности эти показатели говорят о том, что стратегия имеет положительное матожидание и потенциально прибыльна, но ее эффективность сильно зависит от размера выигрышных сделок и может требовать увеличения выборки для более стабильной оценки. Другими словами, низкий процент выигрышей компенсируется высоким профитом на успешных сделках, что важно учитывать при управлении капиталом. Оценка статистической значимости Даже если стратегия показывает положительное математическое ожидание на исторических данных, это не гарантирует прибыль в будущем. Чтобы понять, насколько надежен такой результат и не является ли он случайным, применяются статистические методы оценки значимости. Один из таких методов — t-тест для одной выборки. Он позволяет проверить нулевую гипотезу о том, что средняя доходность сделок равна нулю, то есть стратегия не имеет реального преимущества. Если результат теста показывает низкое значение p-value, это говорит о том, что положительное математическое ожидание статистически значимо и с высокой вероятностью отражает реальную способность стратегии приносить прибыль, а не случайное совпадение. При этом важно помнить, что результаты t-теста зависят от распределения доходностей и объема выборки. Малое количество сделок или сильные отклонения от нормального распределения могут снизить надежность выводов. Поэтому при оценке стратегии рекомендуется использовать не только p-value, но и доверительные интервалы для средних доходностей, а также визуальный анализ распределения прибыли и убытков. from scipy import stats def test_expectancy_significance(trades_returns, alpha=0.05): """ Проверка статистической значимости положительного матожидания. Parameters: - trades_returns: Series с доходностями сделок - alpha: уровень значимости (обычно 0.05) """ # T-test против нулевой гипотезы (матожидание = 0) t_stat, p_value = stats.ttest_1samp(trades_returns, 0) # Доверительный интервал conf_interval = stats.t.interval( 1 - alpha, len(trades_returns) - 1, loc=trades_returns.mean(), scale=stats.sem(trades_returns) ) # Проверка нормальности распределения _, normality_p = stats.shapiro(trades_returns) result = { 'mean_return': trades_returns.mean(), 't_statistic': t_stat, 'p_value': p_value, 'is_significant': p_value < alpha, 'conf_interval': conf_interval, 'normality_p': normality_p, 'is_normal': normality_p > 0.05, 'n_trades': len(trades_returns) } return result # Тестирование на данных из предыдущего примера if trades is not None and len(trades) > 0: significance = test_expectancy_significance(trades['return']) print(f"\nStatistical Significance Test:") print(f"Mean Return: {significance['mean_return']:.4%}") print(f"T-statistic: {significance['t_statistic']:.2f}") print(f"P-value: {significance['p_value']:.4f}") print(f"Significant at 5%: {significance['is_significant']}") print(f"95% CI: [{significance['conf_interval'][0]:.4%}, {significance['conf_interval'][1]:.4%}]") print(f"Normal distribution: {significance['is_normal']}") Statistical Significance Test: Mean Return: 3.7057% T-statistic: 0.51 P-value: 0.6605 Significant at 5%: False 95% CI: [-27.5318%, 34.9433%] Normal distribution: True Код оценивает вероятность того, что наблюдаемое матожидание получено случайно. P-value < 0.05 указывает на статистически значимый результат при уровне доверия 95%. Тест нормальности проверяет применимость t-test — при сильных отклонениях от нормального распределения потребуются непараметрические методы. Интерпретация: Cредняя доходность сделок составляет 3,71%, что хорошо; Однако значение t-статистики равно 0,51, а p-value — 0,6605, что значительно выше стандартного порога 0,05. Это означает, что положительное математическое ожидание на исторических данных не является статистически значимым, и с высокой вероятностью его можно объяснить случайными колебаниями; Доверительный интервал [-27,53%, 34,94%] дополнительно подчеркивает, что средний результат сделки может быть как отрицательным, так и положительным. При этом тест Шапиро показывает, что распределение доходностей не сильно отклоняется от нормального, что позволяет корректно применять t-тест. Тем не менее, низкая статистическая значимость указывает на необходимость увеличения выборки или дополнительного анализа стратегии перед практическим использованием, так как текущее количество сделок недостаточно для уверенной оценки ее эффективности. Bootstrap-анализ устойчивости Bootstrap-анализ позволяет оценить устойчивость ключевых показателей стратегии, таких как математическое ожидание, коэффициент выигрышей или соотношение риска к прибыли. Метод заключается в многократной случайной пересборке исходной выборки с возвращением (sampling with replacement), что создает множество новых «псевдовыборок». Для каждой из них рассчитываются метрики стратегии, после чего строится распределение этих показателей. Главное преимущество Bootstrap заключается в том, что он не требует предположений о конкретном распределении доходностей сделок, в отличие, например, от t-теста. Это особенно важно для финансовых данных, которые часто имеют несимметричное распределение, «тяжелые хвосты» и выбросы. def bootstrap_expectancy(trades_returns, n_bootstrap=10000, confidence=0.95): """ Bootstrap-анализ устойчивости матожидания. Parameters: - trades_returns: Series с доходностями сделок - n_bootstrap: количество bootstrap-итераций - confidence: уровень доверия для интервала """ bootstrap_means = [] bootstrap_sharpe = [] n_trades = len(trades_returns) for _ in range(n_bootstrap): # Случайная выборка с возвратом sample = np.random.choice(trades_returns, size=n_trades, replace=True) bootstrap_means.append(sample.mean()) if sample.std() > 0: bootstrap_sharpe.append(sample.mean() / sample.std()) # Расчет доверительных интервалов lower_percentile = (1 - confidence) / 2 upper_percentile = 1 - lower_percentile mean_ci = np.percentile(bootstrap_means, [lower_percentile * 100, upper_percentile * 100]) sharpe_ci = np.percentile(bootstrap_sharpe, [lower_percentile * 100, upper_percentile * 100]) result = { 'original_mean': trades_returns.mean(), 'bootstrap_mean': np.mean(bootstrap_means), 'mean_ci': mean_ci, 'mean_std': np.std(bootstrap_means), 'original_sharpe': trades_returns.mean() / trades_returns.std(), 'bootstrap_sharpe': np.mean(bootstrap_sharpe), 'sharpe_ci': sharpe_ci, 'probability_positive': np.sum(np.array(bootstrap_means) > 0) / n_bootstrap } return result # Bootstrap-анализ if trades is not None and len(trades) > 0: bootstrap_results = bootstrap_expectancy(trades['return']) print(f"\nBootstrap Analysis ({len(trades)} trades):") print(f"Original Mean: {bootstrap_results['original_mean']:.4%}") print(f"Bootstrap Mean: {bootstrap_results['bootstrap_mean']:.4%}") print(f"95% CI for Mean: [{bootstrap_results['mean_ci'][0]:.4%}, {bootstrap_results['mean_ci'][1]:.4%}]") print(f"Probability of Positive Expectancy: {bootstrap_results['probability_positive']:.2%}") print(f"Bootstrap Sharpe: {bootstrap_results['bootstrap_sharpe']:.2f}") print(f"95% CI for Sharpe: [{bootstrap_results['sharpe_ci'][0]:.2f}, {bootstrap_results['sharpe_ci'][1]:.2f}]") Bootstrap Analysis (3 trades): Original Mean: 3.7057% Bootstrap Mean: 3.7528% 95% CI for Mean: [-4.0642%, 18.2137%] Probability of Positive Expectancy: 70.32% Bootstrap Sharpe: -1.39 95% CI for Sharpe: [-7.65, 1.11] Интерпретация результатов: Средняя доходность стратегии составляет 3,71%, среднее по Bootstrap-псевдовыборкам — 3,75%, что подтверждает положительное матожидание; Доверительный интервал [-4,06%, 18,21%] широкий, включающий отрицательные значения, что указывает на высокую вариативность результатов при малом числе сделок; Вероятность положительного матожидания в случайной выборке — 70,3%, то есть положительная доходность более вероятна, но не гарантирована; Bootstrap-Sharpe равен -1,39 с доверительным интервалом [-7,65, 1,11], что отражает сильные колебания доходности относительно риска. Вывод: стратегия имеет положительное математическое ожидание, но при небольшой выборке ее надежность ограничена; для стабильной оценки необходима большая история сделок. Бутстрап создает тысячи альтернативных версий истории торговли путем случайного отбора сделок с повторениями. Результаты такого анализа позволяют оценить доверительные интервалы для показателей стратегии, понять их разброс и чувствительность к отдельным сделкам: Если интервалы узкие и положительные, можно с большей уверенностью говорить о стабильной положительной доходности; Если же распределение широкое или включает отрицательные значения, это сигнализирует о высокой вариативности стратегии и потенциальных рисках для капитала. Ограничения и распространенные ошибки Смещение выборки (Sample bias) и Переобучение (Overfitting) Малое количество сделок приводит к нестабильной оценке матожидания. Например, при 20 сделках стандартная ошибка среднего составляет σ/√20 ≈ 0,22σ. Для σ = 2% это дает погрешность ±0,44%. В результате доверительный интервал для истинного матожидания может включать ноль, даже если наблюдаемое значение положительное. Минимальное количество сделок для надежной оценки зависит от желаемой точности. Для погрешности ±0,2% при σ = 2% требуется n = (2% / 0,2%)² = 100 сделок. Стратегии с редкими сигналами могут накапливать достаточную статистику только годами. Переобучение возникает при чрезмерной оптимизации параметров на исторических данных. Например, стратегия с 15 настраиваемыми параметрами и тестированием на 500 барах имеет высокий риск подгонки под шум. В этом случае матожидание на обучающих данных завышается, а на тестовых данных — резко снижается. Анализ скользящего тестирования (walk-forward) помогает снизить риск переобучения через последовательное тестирование на непересекающихся периодах. Оптимизация параметров проводится на первых 60% данных, валидация — на следующих 20%, а финальный тест — на последних 20%. Деградация метрик между периодами служит индикатором переобучения. Нестационарность рынков Финансовые рынки изменяются со временем. Матожидание стратегии, которая работала пять лет назад, может не соответствовать текущей ситуации. Изменения ликвидности, волатильности и корреляций между активами могут нарушать статистические свойства, на которых была построена стратегия. Выявление рыночных режимов (regime detection) позволяет определять структурные сдвиги в поведении рынка. С помощью скрытых моделей Маркова (Hidden Markov Models) и других методов периоды классифицируются по уровню волатильности, направлению тренда и корреляциям между активами. Стратегия может либо адаптировать свои параметры, либо временно отключаться в неблагоприятных рыночных режимах. Анализ скользящего окна (rolling window analysis) отслеживает динамику ключевых метрик во времени. Расчет математического ожидания, коэффициента выигрышей или соотношения риска к прибыли на скользящих окнах по 100–200 сделок позволяет выявлять тренды в эффективности стратегии. Устойчивое снижение коэффициента выигрышей (Win Rate) или соотношения прибыли к риску (RRR) сигнализирует о деградации торгового преимущества и служит сигналом для пересмотра стратегии. Ниже пример кода Python для расчета матожидания на скользящем окне: def rolling_expectancy(trades_df, window=100): """Расчет матожидания на скользящем окне.""" rolling_mean = trades_df['return'].rolling(window=window).mean() rolling_std = trades_df['return'].rolling(window=window).std() rolling_sharpe = rolling_mean / rolling_std rolling_winrate = trades_df['return'].rolling(window=window).apply( lambda x: (x > 0).sum() / len(x) ) results = pd.DataFrame({ 'expectancy': rolling_mean, 'volatility': rolling_std, 'sharpe': rolling_sharpe, 'winrate': rolling_winrate }) return results # Анализ деградации edge if trades is not None and len(trades) >= 100: rolling_metrics = rolling_expectancy(trades, window=50) print("\nRolling Metrics (last 5 windows):") print(rolling_metrics.tail()) # Проверка тренда в матожидании recent_expectancy = rolling_metrics['expectancy'].tail(10).mean() early_expectancy = rolling_metrics['expectancy'].head(10).mean() print(f"\nEarly Expectancy: {early_expectancy:.4%}") print(f"Recent Expectancy: {recent_expectancy:.4%}") print(f"Change: {(recent_expectancy - early_expectancy):.4%}") Мониторинг скользящих метрик выявляет деградацию стратегии до появления критических убытков. Снижение матожидания на 50% от исторического уровня — сигнал для остановки торговли и пересмотра подхода. Зависимость результатов от периода тестирования Выбор периода бэктестов существенно влияет на оценку матожидания стратегии. Например, тестирование торговли Bitcoin только в периоды роста (бычий рынок) завышает показатели прибыльности long-only стратегий. Включение кризисных периодов или резких спадов, наоборот, может значительно ухудшить метрики и показать реальные риски стратегии. Репрезентативная выборка должна включать разные рыночные условия: тренды, флэт, высокую и низкую волатильность, кризисы. Минимальный период тестирования — 3-5 лет для дневных стратегий, охватывающий хотя бы один полный рыночный цикл. Тестирование на отложенных данных (out-of-sample) крайне важно для проверки надежности стратегии. Оно заключается в проверке работы стратегии на данных, которые не использовались при ее оптимизации. Например, стратегия, оптимизированная на период 2019–2022 гг, должна подтвердить свои результаты на данных 2022–2025 годов. Существенное расхождение ключевых метрик, таких как математическое ожидание, коэффициент выигрышей или соотношение прибыли к риску, сигнализирует либо о переобучении, либо о смене рыночного режима. Примеры из практики: Стратегии на малой ликвидности альткоинов 2019–2020 показывали хорошие результаты на исторических данных, но при росте ликвидности и появлении крупных маркет-мейкеров эффективность резко снижалась; Алгоритмические стратегии на ETF с низкой волатильностью в 2017–2018 годах теряли прибыль при увеличении волатильности и сезонных корреляциях в 2019–2020 годах, несмотря на стабильные метрики на обучающей выборке. Метод Монте-Карло позволяет оценить устойчивость стратегии к случайным вариациям порядка сделок. Суть метода — многократная случайная перестановка сделок или их доходностей и расчет ключевых метрик на каждой новой выборке. Это дает распределение возможных исходов и показывает, как может меняться кривая капитала при том же наборе сделок, но другом порядке их исполнения. Такой анализ помогает оценить риск просадок и вероятность сохранения положительного математического ожидания. import numpy as np def monte_carlo_simulation(trades_returns, n_simulations=1000): """Monte Carlo симуляция альтернативных equity curves с расчетом доходности и просадок.""" trades_returns = np.array(trades_returns) n_trades = len(trades_returns) final_returns = np.zeros(n_simulations) max_drawdowns = np.zeros(n_simulations) for i in range(n_simulations): # Случайная перестановка сделок shuffled_trades = np.random.choice(trades_returns, size=n_trades, replace=False) # Расчет equity curve equity = np.cumprod(1 + shuffled_trades) final_returns[i] = equity[-1] - 1 # Расчет максимальной просадки running_max = np.maximum.accumulate(equity) drawdown = (equity - running_max) / running_max max_drawdowns[i] = drawdown.min() results = { 'mean_return': final_returns.mean(), 'return_std': final_returns.std(), 'return_percentiles': np.percentile(final_returns, [5, 25, 50, 75, 95]), 'mean_max_dd': max_drawdowns.mean(), 'worst_dd_percentile': np.percentile(max_drawdowns, 5) } return results, final_returns, max_drawdowns # Пример вызова if trades is not None and len(trades) > 0: mc_results, returns_dist, dd_dist = monte_carlo_simulation(trades['return']) print(f"\nMonte Carlo Simulation (1000 runs):") print(f"Mean Return: {mc_results['mean_return']:.2%}") print(f"Return Std: {mc_results['return_std']:.2%}") print(f"Return Percentiles [5%, 25%, 50%, 75%, 95%]:") print(f" {[f'{p:.2%}' for p in mc_results['return_percentiles']]}") print(f"Mean Max Drawdown: {mc_results['mean_max_dd']:.2%}") print(f"5th Percentile Max DD: {mc_results['worst_dd_percentile']:.2%}") Monte Carlo Simulation (1000 runs): Mean Return: 9.97% Return Std: 0.00% Return Percentiles [5%, 25%, 50%, 75%, 95%]: ['9.97%', '9.97%', '9.97%', '9.97%', '9.97%'] Mean Max Drawdown: -4.60% 5th Percentile Max DD: -6.97% Симуляция показывает вероятностное распределение возможных результатов. 5-й перцентиль доходности указывает на худший ожидаемый сценарий с вероятностью 95%. Если этот сценарий неприемлем, стратегия требует корректировки размера позиций или дополнительной диверсификации. Использование комбинации тестирования на отложенных данных и симуляций Монте-Карло позволяет понять, насколько стратегия устойчива, выявить риск переобучения и подготовиться к различным рыночным сценариям. Заключение Математическое ожидание отражает долгосрочную прибыльность торговой стратегии. Положительное матожидание — необходимое, но недостаточное условие успеха. Его практическая реализация зависит от дисперсии доходности, числа сделок и устойчивости торговой стратегии во времени. Например, стратегия с E[R] = 0,3% и низкой волатильностью может быть предпочтительнее стратегии с E[R] = 0,5% и высокими просадками, если оценивать доходность с учетом риска (risk-adjusted returns). Для точной оценки матожидания требуется достаточная статистика, учет транзакционных издержек и проверка на нескольких периодах. Методы Bootstrap и Monte Carlo дополняют классические статистические тесты, показывая, насколько устойчивы ключевые метрики к вариациям в порядке сделок. ### Закон больших чисел в портфельной теории Закон больших чисел — фундаментальный результат теории вероятностей, который объясняет механику диверсификации в инвестиционных портфелях. Понимание его слабой и сильной формулировок позволяет оценить границы применимости диверсификации и избежать типичных ошибок при построении портфелей. Слабый закон больших чисел Слабый закон утверждает, что среднее арифметическое независимых одинаково распределенных случайных величин сходится по вероятности к математическому ожиданию: P(|X̄ₙ - μ| > ε) → 0 при n → ∞ где: X̄ₙ — выборочное среднее n наблюдений; μ — математическое ожидание; ε — произвольное положительное число; P — вероятность события. Закон гарантирует, что при увеличении числа наблюдений вероятность большого отклонения выборочного среднего от истинного среднего стремится к нулю. Для инвестиций это означает: чем больше независимых активов в портфеле, тем ближе фактическая доходность к ожидаемой. Сильный закон больших чисел Сильный закон формулирует более сильное утверждение о сходимости выборочного среднего: P(lim(n→∞) X̄ₙ = μ) = 1 Разница между двумя законами следующая: Слабый закон больших чисел утверждает, что выборочное среднее сходится к математическому ожиданию по вероятности. Это означает, что вероятность заметного отклонения уменьшается при увеличении объема выборки; Сильный закон больших чисел утверждает почти наверную сходимость: с вероятностью 1 последовательность выборочных средних в пределе совпадает с истинным математическим ожиданием. Почти наверная сходимость означает, что если мы будем увеличивать размер выборки бесконечно, то со 100%-ной вероятностью (за исключением событий, вероятность которых равна 0) выборочное среднее действительно приблизится и «прилипнет» к математическому ожиданию. То есть возможно, что существует какой-то исключительный случай, когда среднее не сойдется, но вероятность этого случая настолько мала, что фактически равна нулю. Для портфельного управления сильный закон важнее: он гарантирует, что при достаточно длительном периоде инвестирования и правильной диверсификации доходность портфеля приблизится к ожидаемой. Слабый закон дает лишь вероятностную оценку на каждом шаге. Математическое обноснование Портфель из n активов с весами wᵢ и доходностями rᵢ имеет доходность: rₚ = Σ wᵢ × rᵢ где: rₚ — доходность портфеля; wᵢ — вес i-го актива в портфеле; rᵢ — доходность i-го актива; n — число активов. Если активы независимы и имеют одинаковое распределение доходностей, то дисперсия равновзвешенного портфеля: σₚ² = σ²/n где: σₚ² — дисперсия портфеля; σ² — дисперсия отдельного актива; n — число активов в портфеле. Волатильность портфеля снижается пропорционально квадратному корню из числа активов. Это прямое следствие закона больших чисел: увеличение n уменьшает разброс средней доходности вокруг ожидаемого значения. Условия применимости Закон больших чисел требует выполнения двух условий: Независимость наблюдений. Доходности активов не должны систематически двигаться вместе. В реальности финансовые активы коррелируют — особенно в периоды кризисов. Корреляция снижает эффективность диверсификации. Идентичность распределений. Активы должны иметь одинаковое распределение доходностей. На практике акции разных компаний имеют разные профили риск-доходность. Это требование менее важно в портфельной оптимизации: закон работает и при некотором разбросе параметров распределений. Ограничения в реальных условиях Финансовые рынки нарушают оба условия применимости закона больших чисел. Корреляции между активами положительны и нестабильны во времени. В спокойные периоды корреляции низкие (0.2-0.4 для акций разных секторов), в кризисы возрастают до 0.7-0.9. Этот эффект называется сломом корреляции или correlation breakdown. Распределения доходностей нестационарны: параметры меняются со временем. Волатильность акций технологических компаний в 2020-2021 отличалась от волатильности в 2022-2023. Математическое ожидание доходности также нестабильно. Следствие для практики: диверсификация снижает риск, но не устраняет его полностью. Существует систематический риск, который невозможно диверсифицировать. Диверсификация как практическое применение Систематический и несистематический риск Общий риск актива разделяется на две компоненты: σ² = σ²ₛᵧₛₜ + σ²ᵤₙₛᵧₛₜ где: σ² — общая дисперсия доходности; σ²ₛᵧₛₜ — систематический риск (рыночный); σ²ᵤₙₛᵧₛₜ — несистематический риск (специфичный для актива). Диверсификация устраняет только несистематический риск. Систематический риск присущ всему рынку и сохраняется в любом портфеле. Закон больших чисел работает именно для несистематической компоненты: при усреднении по многим активам их специфические риски взаимно погашаются. Оптимальное число активов По результатам множества исследований 90-95% выгоды от диверсификации достигается при 15-20 активах в портфеле. Дальнейшее увеличение числа позиций дает минимальный прирост к снижению риска, но увеличивает транзакционные издержки и сложность управления. График зависимости волатильности портфеля от числа активов имеет характерную форму: резкое падение до 15-20 позиций, затем выход на плато. Асимптота соответствует уровню систематического риска рынка. Рис. 1: Эффект диверсификации биржевого портфеля и уровня систематического риска Для конкретных рынков оптимум различается: US Large Cap: 20-25 акций; Emerging Markets: 30-40 акций (если разные страны); Криптовалюты: 10-15 активов (из-за экстремально высоких корреляций). Цифры актуальны для периодов нормальной волатильности. Важно не забывать, что в кризисы эффективность диверсификации падает. Наивная диверсификация Простейший подход — равновесный портфель (1/n для каждого актива). Исследования демонстрируют, что наивная диверсификация часто превосходит сложные методы оптимизации из-за ошибок в оценке параметров. Преимущества равновесного портфеля: Не требует оценки ожидаемых доходностей (наименее надежный параметр); Устойчив к ошибкам в оценке ковариационной матрицы; Минимальные транзакционные издержки при ребалансировке; Автоматическая ребалансировка: продаем выросшие активы, покупаем упавшие. Недостатки: Игнорирует различия в профилях риск-доходность; Не учитывает корреляции между активами; Может включать активы с отрицательным ожидаемым доходом. Для большинства частных инвесторов равновесный портфель из 20-30 активов — оптимальный выбор. Представленная ниже симуляция с помошью Python демонстрирует эффект диверсификации через закон больших чисел: import numpy as np import pandas as pd import matplotlib.pyplot as plt np.random.seed(42) # Параметры симуляции n_simulations = 1000 n_periods = 252 # торговых дней portfolio_sizes = [1, 5, 10, 20, 50, 100] # Параметры активов mu = 0.0005 # дневная доходность 0.05% sigma = 0.02 # дневная волатильность 2% results = [] for n_assets in portfolio_sizes: portfolio_returns = [] for sim in range(n_simulations): # Генерация доходностей активов asset_returns = np.random.normal(mu, sigma, (n_periods, n_assets)) # Равновесный портфель weights = np.ones(n_assets) / n_assets portfolio_return = asset_returns @ weights # Годовая доходность и волатильность annual_return = np.mean(portfolio_return) * 252 annual_vol = np.std(portfolio_return) * np.sqrt(252) portfolio_returns.append({ 'n_assets': n_assets, 'return': annual_return, 'volatility': annual_vol }) results.extend(portfolio_returns) df = pd.DataFrame(results) # Средние метрики по размеру портфеля summary = df.groupby('n_assets').agg({ 'return': 'mean', 'volatility': ['mean', 'std'] }).round(4) print(summary) # Визуализация fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) # График волатильности vol_mean = df.groupby('n_assets')['volatility'].mean() vol_std = df.groupby('n_assets')['volatility'].std() ax1.plot(vol_mean.index, vol_mean.values, 'o-', linewidth=2, markersize=8) ax1.fill_between(vol_mean.index, vol_mean - vol_std, vol_mean + vol_std, alpha=0.3) ax1.axhline(y=sigma * np.sqrt(252), color='r', linestyle='--', label='Теоретический предел') ax1.set_xlabel('Число активов в портфеле') ax1.set_ylabel('Годовая волатильность') ax1.set_title('Снижение риска через диверсификацию') ax1.legend() ax1.grid(True, alpha=0.3) # Распределение волатильности for n in [1, 10, 50]: subset = df[df['n_assets'] == n]['volatility'] ax2.hist(subset, bins=30, alpha=0.5, label=f'{n} активов') ax2.set_xlabel('Годовая волатильность') ax2.set_ylabel('Частота') ax2.set_title('Распределение волатильности портфелей') ax2.legend() ax2.grid(True, alpha=0.3) plt.tight_layout() plt.savefig('diversification_effect.png', dpi=300, bbox_inches='tight') plt.show() # Относительное снижение риска risk_reduction = (1 - vol_mean / vol_mean.iloc[0]) * 100 print("\nСнижение риска относительно портфеля из 1 актива:") print(risk_reduction.round(2)) Рис. 2: Эффект диверсификации портфеля. Изображение демонстрирует снижение годовой волатильности равновзвешенного портфеля по мере увеличения числа включенных в него активов. Гистограммы распределений волатильности показывают уменьшение разброса результатов при росте размера портфеля, что отражает снижение влияния несистематического риска return volatility mean mean std n_assets 1 0.1264 0.3166 0.0137 5 0.1168 0.1416 0.0064 10 0.1284 0.0998 0.0044 20 0.1270 0.0707 0.0032 50 0.1261 0.0448 0.0020 100 0.1249 0.0316 0.0014 Снижение риска относительно портфеля из 1 актива: n_assets 1 0.00 5 55.29 10 68.49 20 77.67 50 85.86 100 90.01 Код генерирует 1000 симуляций портфелей разного размера, вычисляет годовые метрики и визуализирует эффект диверсификации. Каждая симуляция создает временной ряд доходностей для заданного числа активов, формирует равновесный портфель и рассчитывает итоговые характеристики. Результаты показывают, что при таком подходе происходят: Снижение волатильности. Портфель из 20 активов имеет волатильность примерно в 4.5 раза ниже, чем отдельный актив. Это соответствует теоретической оценке √20 ≈ 4.47; Уменьшение разброса результатов. Стандартное отклонение волатильности между симуляциями падает с ростом числа активов. Для портфеля из 1 актива разброс высокий, для 100 активов — минимальный; Закон убывающей отдачи. Переход от 1 к 10 активам снижает риск на 68%, от 10 к 20 — на дополнительные 12%, от 20 к 50 — лишь на 8%. График распределения волатильности наглядно демонстрирует закон больших чисел: с ростом n распределение сужается и концентрируется вокруг теоретического среднего. Когда диверсификация эффективна Диверсификация работает оптимально при низких корреляциях между активами. Портфель из акций различных секторов (технологии, здравоохранение, энергетика, финансы) имеет корреляции 0.2-0.4 в нормальных условиях. Добавление некоррелированных классов активов усиливает эффект: акции + облигации; акции + товарные фьючерсы; акции + альтернативные стратегии. Временной горизонт также влияет на эффективность. Короткие периоды (дни, недели) содержат больше шума, длинные периоды (месяцы, годы) — больше сигнала. Закон больших чисел действует не только по числу активов, но и по времени. Корреляционные ловушки Следует всегда помнить, что статические оценки корреляций бывают весьма обманчивы. Активы могут иметь низкую корреляцию 95% времени, но в критические моменты двигаться синхронно. Кризис 2008 года продемонстрировал это: диверсифицированные портфели показали потери, близкие к рыночным. Подходы к учету динамических корреляций: Скользящие корреляции. Расчет корреляций на скользящем окне (например, 60–120 дней) позволяет отслеживать изменения режимов взаимосвязи активов во времени; Модели смены режимов (regime switching). Определяются периоды низкой и высокой корреляции, и для каждого режима используются разные веса или разные правила формирования портфеля; Стресс-тестирование. Оценка поведения портфеля в экстремальных условиях, когда корреляции резко возрастают. Для защиты от correlation breakdown в портфель добавляют активы, которые в стресс-периоды ведут себя противоположно рынку: долгосрочные государственные облигации, золото, инструменты на волатильность (например, индекс VIX). Альтернативы наивной диверсификации Когда оценки параметров достаточно надежны, оптимизированные портфели могут превосходить равновзвешенный: Портфель минимальной волатильности (Minimum Variance Portfolio). Минимизирует общий риск без учета ожидаемых доходностей; требует только ковариационной матрицы, которая оценивается значительно точнее, чем математическое ожидание доходности. Паритет риска (Risk Parity). Выравнивает вклад каждого актива в совокупный портфельный риск; на практике оказывается более устойчивым, чем классическая оптимизация Марковица. Максимальная диверсификация (Maximum Diversification). Максимизирует отношение взвешенной волатильности отдельных активов к волатильности портфеля; использует эффект «аномалии низкого риска» (low-risk anomaly). Иерархический паритет риска (Hierarchical Risk Parity). Применяет методы кластеризации активов для построения более робастных весов; позволяет снизить чувствительность к ошибкам в оценке корреляций. Все методы превосходят равновесный портфель при достаточном объеме данных и стабильности параметров. В условиях неопределенности и высоких транзакционных издержек наивная диверсификация остается конкурентной. Ребалансировка и издержки Закон больших чисел предполагает фиксированные веса активов. В реальности цены меняются, веса дрейфуют. Ребалансировка восстанавливает целевые пропорции, однако платой за это становится генерация множества издержек: Комиссии брокера (для акций 0.01-0.05% за сделку); Спреды bid-ask (0.01-0.1% для ликвидных акций); Проскальзывание (больше для крупных ордеров); Налоги на прирост капитала (зависит от юрисдикции). Оптимальная частота ребалансировки — компромисс между поддержанием целевого профиля риска и минимизацией издержек. Для портфелей акций типична квартальная или полугодовая ребалансировка. Альтернатива — ребалансировка по порогам: корректировка веса при отклонении от цели более чем на 5-10%. Заключение Закон больших чисел объясняет механизм снижения несистематического риска при увеличении числа активов в портфеле. Однако природа современных финансовых рынков такова, что его действие не является безусловным. В периоды рыночных стрессов корреляции между активами имеют свойство резко возрастать, что приводит к синхронному движению цен и снижает эффект диверсификации. Таким образом, полагаться исключительно на простое расширение портфеля недостаточно. Для эффективного управления риском необходимо учитывать динамическую структуру корреляций и различать систематический и несистематический компоненты риска. Практически это означает применение методов, адаптирующих состав и веса активов к рыночным режимам: моделей смены состояний, паритета риска, иерархических и факторных подходов. Такие стратегии позволяют не только снижать уровень волатильности портфеля, но и поддерживать устойчивость к «корреляционным обвалам» в кризисных ситуациях. Иными словами, диверсификация остается фундаментальным инструментом управления риском, однако ее реальная эффективность достигается лишь при учете изменчивой природы рынков и грамотном выборе методов оптимизации. ### Обнаружение зон концентрации ликвидности на кластерных графиках Зоны концентрации ликвидности — это ценовые уровни, на которых скапливается значительный объем лимитных ордеров. Эти зоны формируются, когда множество участников рынка размещают заявки в узком ценовом диапазоне, создавая барьеры для движения цены. Обнаружение таких зон позволяет прогнозировать уровни поддержки и сопротивления на основе реального распределения объемов, а не графических паттернов. Кластерные графики отображают объемы торгов с детализацией до каждого ценового уровня внутри бара. В отличие от стандартных OHLCV данных, кластеры показывают, на каких именно ценах происходила торговля и с каким объемом. Это дает представление о микроструктуре рынка: где покупатели агрессивно покупали по ask, а где продавцы сбрасывали позиции по bid. В этой статье мы рассмотрим 3 метода обнаружения зон концентрации: Скользящее окно: метод использует простую эвристику — ищет диапазоны, где сосредоточено более 50% объема бара; Метод точки контроля и зоны стоимости (Point of Control with Value Area): индустриальный стандарт анализа профилей объемов; Ядерная оценка плотности распределения (Kernel Density Estimation): метод применяет статистическое сглаживание для выявления пиков плотности распределения объемов. Каждый подход имеет свои преимущества в зависимости от задачи: скорость работы, точность определения границ зоны или статистическая корректность. Реализация на Python Для анализа требуются детализированные данные о распределении объема по ценовым уровням внутри каждого бара. Биржи предоставляют доступ к этим данным через API в формате снимков стакана (order book snapshots) или последовательных записей сделок (trade-by-trade). В этой статье для демонстрации методов я буду использовать симуляцию кластерной структуры на основе стандартных OHLCV данных. Загрузка 5-минутных данных Bitcoin выполняется через yfinance. После получения OHLCV создается синтетическое распределение объема по ценам внутри каждого бара. Для 30% баров генерируются зоны концентрации, где 55-65% объема сосредоточено в диапазоне 25-30% от общего ценового размаха свечи. Остальные бары имеют равномерное распределение объема. import pandas as pd import numpy as np import yfinance as yf from datetime import datetime, timedelta import matplotlib.pyplot as plt from matplotlib.patches import Rectangle from scipy.stats import gaussian_kde from scipy.signal import find_peaks import warnings warnings.filterwarnings('ignore') def load_bitcoin_data(days_back=1): """ Загружает 5-минутные данные Bitcoin и создает кластерную структуру Returns: DataFrame с MultiIndex (timestamp, price) и колонками: - buyers: объем агрессивных покупок - sellers: объем агрессивных продаж - quantity: общий объем """ print(f"Загрузка Bitcoin данных за {days_back} дней...") end_date = datetime.now() start_date = end_date - timedelta(days=days_back) # Загрузка данных btc = yf.download('BTC-USD', start=start_date, end=end_date, interval='5m', progress=False) if btc.empty: raise ValueError("Не удалось загрузить данные") # Убираем MultiIndex из колонок если он есть if isinstance(btc.columns, pd.MultiIndex): btc.columns = btc.columns.get_level_values(0) print(f"Загружено {len(btc)} баров") # Создаем кластерную структуру cluster_data = simulate_cluster_data(btc) return cluster_data def simulate_cluster_data(ohlcv_df): """ Симулирует кластерные данные из OHLCV Создает распределение объема по ценовым уровням """ data = [] bars_processed = 0 bars_skipped = 0 for timestamp, row in ohlcv_df.iterrows(): try: high = float(row['High']) low = float(row['Low']) volume = float(row['Volume']) except (KeyError, TypeError, ValueError): bars_skipped += 1 continue # Пропускаем бары с нулевым объемом или диапазоном if pd.isna(high) or pd.isna(low) or pd.isna(volume): bars_skipped += 1 continue if volume == 0 or high == low: bars_skipped += 1 continue # Определяем количество ценовых уровней n_ticks = max(30, int(volume / 1000000)) n_ticks = min(n_ticks, 200) # 30% баров имеют зоны концентрации has_concentration = np.random.random() < 0.3 if has_concentration: # Зона концентрации: 25-30% диапазона concentration_center = np.random.uniform(low, high) concentration_range = (high - low) * np.random.uniform(0.25, 0.30) concentration_low = max(low, concentration_center - concentration_range / 2) concentration_high = min(high, concentration_center + concentration_range / 2) # 55-65% объема в зоне n_concentrated = int(n_ticks * np.random.uniform(0.55, 0.65)) n_random = n_ticks - n_concentrated concentrated_prices = np.random.uniform( concentration_low, concentration_high, n_concentrated ) random_prices = np.random.uniform(low, high, n_random) prices = np.concatenate([concentrated_prices, random_prices]) # Агрессивность в зоне: преобладание покупок или продаж buy_bias = np.random.choice([0.65, 0.35]) else: # Равномерное распределение prices = np.random.uniform(low, high, n_ticks) buy_bias = 0.5 prices = np.round(prices, 2) # Генерируем объемы для каждого уровня for price in prices: tick_volume = np.random.uniform(0.001, 0.01) is_buy = np.random.random() < buy_bias data.append({ 'timestamp': timestamp, 'price': price, 'buyers': tick_volume if is_buy else 0, 'sellers': tick_volume if not is_buy else 0, 'quantity': tick_volume }) bars_processed += 1 print(f"Обработано: {bars_processed}, пропущено: {bars_skipped}") if len(data) == 0: raise ValueError("Нет данных для анализа") # Создаем датафрейм с группировкой df = pd.DataFrame(data) df = df.groupby(['timestamp', 'price']).agg({ 'buyers': 'sum', 'sellers': 'sum', 'quantity': 'sum' }) print(f"Создано {len(df)} ценовых уровней в {df.index.get_level_values(0).nunique()} барах") return df Функция load_bitcoin_data() загружает данные и обрабатывает MultiIndex в колонках, который yfinance иногда создает. Функция simulate_cluster_data() преобразует OHLCV в кластерную структуру: для каждого бара генерируется распределение объема по ценовым уровням с возможной концентрацией в узком диапазоне. Результат — датафрейм с двухуровневым индексом (timestamp, price) и тремя колонками: buyers (агрессивные покупки), sellers (агрессивные продажи), quantity (общий объем). Эта структура аналогична реальным кластерным данным, где каждая строка представляет один ценовой уровень в конкретном баре. # Загружаем данные data = load_bitcoin_data(days_back=1) # Визуализируем один бар timestamps = sorted(data.index.get_level_values(0).unique()) first_bar = data.loc[timestamps[0]] fig, ax = plt.subplots(figsize=(10, 8)) # Volume profile ax.barh(first_bar.index, first_bar['quantity'], alpha=0.4, color='gray', label='Total Volume') ax.barh(first_bar.index, first_bar['buyers'], alpha=0.6, color='green', label='Buyers') ax.barh(first_bar.index, first_bar['sellers'], alpha=0.6, color='red', label='Sellers') ax.set_ylabel('Price', fontsize=12) ax.set_xlabel('Volume', fontsize=12) ax.set_title(f'Volume Profile: {timestamps[0]}', fontsize=14) ax.legend() ax.grid(True, alpha=0.3) plt.tight_layout() plt.show() print(f"\nСтатистика бара:") print(f"High: {first_bar.index.max():.2f}") print(f"Low: {first_bar.index.min():.2f}") print(f"Range: {first_bar.index.max() - first_bar.index.min():.2f}") print(f"Total Volume: {first_bar['quantity'].sum():.4f} BTC") print(f"Buyers: {first_bar['buyers'].sum():.4f} BTC") print(f"Sellers: {first_bar['sellers'].sum():.4f} BTC") Загрузка Bitcoin данных за 1 дней... Загружено 285 баров Обработано: 93, пропущено: 192 Создано 15150 ценовых уровней в 93 барах Статистика бара: High: 101743.79 Low: 101629.98 Range: 113.81 Total Volume: 0.4366 BTC Buyers: 0.2020 BTC Sellers: 0.2346 BTC Рис. 1: Объемный профиль для одного 5-минутного бара Bitcoin. Горизонтальная гистограмма показывает распределение объема по ценовым уровням. Зеленым выделены агрессивные покупки (market orders на ask), красным — агрессивные продажи (market orders на bid), серым — общий объем Метод 1: Скользящее окно Метод скользящего окна последовательно проверяет все участки ценового диапазона бара. Окно фиксированного размера (25-30% от диапазона High-Low) перемещается от минимума к максимуму, на каждом шаге подсчитывается доля объема внутри окна от общего объема бара. Если концентрация превышает порог (50%), участок фиксируется как зона концентрации ликвидности. Дополнительно применяется фильтр по объему: анализируются только бары, где общий объем превышает скользящую среднюю с периодом 12 (1 час на 5-минутках). Такой подход исключает малоликвидные периоды, где концентрация объема может быть случайной. Для каждой найденной зоны рассчитывается buy pressure — доля агрессивных покупок от суммы покупок и продаж в зоне. Значение выше 60% указывает на преобладание покупателей, ниже 40% — продавцов. def find_zones_sliding_window(data, window_pct=0.30, volume_threshold=0.50, volume_ma_period=12): """ Поиск зон концентрации методом скользящего окна Parameters: data: DataFrame с MultiIndex (timestamp, price) window_pct: размер окна в % от диапазона (0.25-0.30) volume_threshold: минимальная концентрация объема (0.50 = 50%) volume_ma_period: период MA для фильтрации баров Returns: DataFrame с найденными зонами """ results = [] # Рассчитываем MA объема по барам bar_volumes = data.groupby(level=0)['quantity'].sum() volume_ma = bar_volumes.rolling( window=volume_ma_period, min_periods=1 ).mean() timestamps = sorted(data.index.get_level_values(0).unique()) bars_analyzed = 0 bars_filtered = 0 for idx, timestamp in enumerate(timestamps): # Данные текущего бара bar_data = data.loc[timestamp].copy() # Фильтр по объему current_volume = bar_volumes.iloc[idx] current_ma = volume_ma.iloc[idx] if current_volume < current_ma: bars_filtered += 1 continue high = bar_data.index.max() low = bar_data.index.min() price_range = high - low if price_range == 0: bars_filtered += 1 continue window_size = price_range * window_pct total_volume = bar_data['quantity'].sum() # Проверяем зоны с шагом test_prices = np.linspace(low, high, num=20) for price in test_prices: window_high = price + window_size / 2 window_low = price - window_size / 2 # Отбираем данные в окне zone_mask = (bar_data.index >= window_low) & (bar_data.index <= window_high) zone_data = bar_data[zone_mask] if len(zone_data) == 0: continue zone_volume = zone_data['quantity'].sum() concentration = zone_volume / total_volume # Проверка порога if concentration >= volume_threshold: buyers_vol = zone_data['buyers'].sum() sellers_vol = zone_data['sellers'].sum() total_dir = buyers_vol + sellers_vol results.append({ 'timestamp': timestamp, 'method': 'Sliding Window', 'price_center': price, 'zone_low': window_low, 'zone_high': window_high, 'concentration': concentration, 'buy_pressure': buyers_vol / total_dir if total_dir > 0 else 0.5, 'zone_volume': zone_volume }) bars_analyzed += 1 print(f" Проанализировано: {bars_analyzed}, отфильтровано: {bars_filtered}") return pd.DataFrame(results) # Применяем метод zones_sw = find_zones_sliding_window( data, window_pct=0.30, volume_threshold=0.50, volume_ma_period=12 ) print(f"\nНайдено зон: {len(zones_sw)}") if len(zones_sw) > 0: print(f"Средняя концентрация: {zones_sw['concentration'].mean():.1%}") print(f"Средний buy pressure: {zones_sw['buy_pressure'].mean():.1%}") Проанализировано: 59, отфильтровано: 34 Найдено зон: 59 Средняя концентрация: 62.9% Средний buy pressure: 47.9% Функция принимает три параметра: Размер окна относительно диапазона бара; Порог концентрации объема; Период скользящей средней для фильтрации. Внутренний цикл проходит по всем барам, для каждого создается 20 тестовых позиций окна от минимума до максимума. На каждой позиции подсчитывается доля объема в окне. Результаты сохраняются в датафрейм со всеми характеристиками зон. # Визуализация найденных зон для одного бара bar_timestamp = zones_sw['timestamp'].iloc[0] if len(zones_sw) > 0 else timestamps[0] bar_data = data.loc[bar_timestamp] bar_zones = zones_sw[zones_sw['timestamp'] == bar_timestamp] fig, ax = plt.subplots(figsize=(12, 8)) # Volume profile ax.barh(bar_data.index, bar_data['quantity'], alpha=0.3, color='gray', label='Volume') # Зоны концентрации max_vol = bar_data['quantity'].max() for _, zone in bar_zones.iterrows(): color = 'green' if zone['buy_pressure'] > 0.6 else ('red' if zone['buy_pressure'] < 0.4 else 'yellow') rect = Rectangle( (0, zone['zone_low']), max_vol * 1.1, zone['zone_high'] - zone['zone_low'], alpha=0.3, color=color, edgecolor='black', linewidth=2 ) ax.add_patch(rect) # Подпись label_text = f"{zone['concentration']:.0%}\n" label_text += 'BUY' if zone['buy_pressure'] > 0.6 else ('SELL' if zone['buy_pressure'] < 0.4 else 'NEU') ax.text( max_vol * 0.5, zone['price_center'], label_text, ha='center', va='center', fontsize=10, fontweight='bold', bbox=dict(boxstyle='round', facecolor='white', alpha=0.8) ) ax.set_ylabel('Price', fontsize=12) ax.set_xlabel('Volume', fontsize=12) ax.set_title(f'Sliding Window Method: {bar_timestamp}', fontsize=14) ax.grid(True, alpha=0.3) plt.tight_layout() plt.show() Рис. 2: Результаты метода скользящего окна. Цветные области показывают найденные зоны концентрации. Зеленый цвет указывает на преобладание агрессивных покупок (buy pressure > 60%), красный — продаж (< 40%). Процент внутри зоны показывает долю объема бара, сосредоточенную в данном участке Метод простой в реализации и интуитивно понятный. Параметры легко настраиваются под разные инструменты и таймфреймы. Однако возможны ложные срабатывания: если объем распределен относительно равномерно, но случайно в одном участке концентрация немного выше, окно это зафиксирует как зону. Также метод чувствителен к выбору размера окна — слишком маленькое пропустит широкие зоны, слишком большое объединит несколько узких. Метод 2: Точки контроля и зоны стоимости (PoC with Value Area) Point of Control (POC) — ценовой уровень с максимальным объемом в баре. Это точка наибольшей активности участников, где цена провела больше всего времени и где произошло наибольшее количество сделок. POC выступает магнитом для цены: при возврате к этому уровню часто возникает консолидация или отскок. Value Area (VA) — диапазон цен, содержащий 70% совокупного объема бара. Рассчитывается от POC: Сначала добавляются ближайшие ценовые уровни с максимальным объемом, пока кумулятивный объем не достигнет 70%; Value Area High (VAH) и Value Area Low (VAL) — границы этого диапазона; Зона за пределами VA считается низколиквидной, цена проходит ее быстрее. Метод широко используется профессиональными трейдерами на фьючерсных рынках. CME Group публикует POC и VA для своих контрактов. Подход не требует настройки параметров, кроме процента объема для VA (стандартно 70%, иногда 68% или 75%). def find_poc_value_area(data, value_area_pct=0.70): """ Расчет Point of Control и Value Area Parameters: data: DataFrame с MultiIndex (timestamp, price) value_area_pct: доля объема в Value Area (0.70 = 70%) Returns: DataFrame с POC и границами VA для каждого бара """ results = [] timestamps = sorted(data.index.get_level_values(0).unique()) for timestamp in timestamps: bar_data = data.loc[timestamp].copy() if len(bar_data) == 0: continue # Point of Control - уровень с максимальным объемом poc_price = bar_data['quantity'].idxmax() # Value Area - сортируем по объему sorted_by_volume = bar_data.sort_values('quantity', ascending=False) total_volume = bar_data['quantity'].sum() target_volume = total_volume * value_area_pct # Накапливаем объем от максимального sorted_by_volume['cumvol'] = sorted_by_volume['quantity'].cumsum() va_prices = sorted_by_volume[sorted_by_volume['cumvol'] <= target_volume].index if len(va_prices) == 0: va_prices = [poc_price] va_high = va_prices.max() va_low = va_prices.min() va_volume = sorted_by_volume[sorted_by_volume['cumvol'] <= target_volume]['quantity'].sum() # Buy pressure в Value Area va_data = bar_data[(bar_data.index >= va_low) & (bar_data.index <= va_high)] buyers_vol = va_data['buyers'].sum() sellers_vol = va_data['sellers'].sum() total_dir = buyers_vol + sellers_vol results.append({ 'timestamp': timestamp, 'method': 'POC/Value Area', 'price_center': poc_price, 'zone_low': va_low, 'zone_high': va_high, 'concentration': va_volume / total_volume, 'buy_pressure': buyers_vol / total_dir if total_dir > 0 else 0.5, 'zone_volume': va_volume }) return pd.DataFrame(results) # Применяем метод zones_poc = find_poc_value_area(data, value_area_pct=0.70) print(f"\nPOC/Value Area анализ:") print(f"Обработано баров: {len(zones_poc)}") if len(zones_poc) > 0: avg_va_width = (zones_poc['zone_high'] - zones_poc['zone_low']).mean() print(f"Средняя ширина VA: {avg_va_width:.2f}") print(f"Средний buy pressure: {zones_poc['buy_pressure'].mean():.1%}") POC/Value Area анализ: Обработано баров: 93 Средняя ширина VA: 101.42 Средний buy pressure: 49.6% Функция находит POC через idxmax() на колонке объема. Для VA данные сортируются по убыванию объема, затем кумулятивно суммируются до достижения целевой доли. Ценовые уровни, вошедшие в эту сумму, определяют границы VA. Дополнительно рассчитывается агрессивность покупателей и продавцов внутри зоны. Метод детерминированный — всегда дает один и тот же результат для одних данных. Не требует подбора параметров, кроме процента VA. Однако POC может быть нерепрезентативным в барах с несколькими пиками объема: метод выберет только один максимум, проигнорировав другие значимые зоны. Также следует учитывать, что Value Area может быть фрагментированной, если объем распределен неравномерно с разрывами. # Визуализация POC и Value Area bar_poc = zones_poc.iloc[0] if len(zones_poc) > 0 else None bar_data = data.loc[bar_poc['timestamp']] if bar_poc is not None else data.loc[timestamps[0]] fig, ax = plt.subplots(figsize=(12, 8)) # Volume profile ax.barh(bar_data.index, bar_data['quantity'], alpha=0.3, color='gray', label='Volume') if bar_poc is not None: # POC линия ax.axhline( bar_poc['price_center'], color='blue', linewidth=3, label='POC', linestyle='-' ) # Value Area зона ax.axhspan( bar_poc['zone_low'], bar_poc['zone_high'], alpha=0.2, color='purple', label=f'Value Area ({bar_poc["concentration"]:.0%})' ) # Подписи границ ax.text( bar_data['quantity'].max() * 0.7, bar_poc['zone_high'], f"VAH: {bar_poc['zone_high']:.2f}", fontsize=10, bbox=dict(boxstyle='round', facecolor='white', alpha=0.8) ) ax.text( bar_data['quantity'].max() * 0.7, bar_poc['zone_low'], f"VAL: {bar_poc['zone_low']:.2f}", fontsize=10, bbox=dict(boxstyle='round', facecolor='white', alpha=0.8) ) ax.set_ylabel('Price', fontsize=12) ax.set_xlabel('Volume', fontsize=12) ax.legend() ax.grid(True, alpha=0.3) plt.tight_layout() plt.show() Рис. 3: Точки контроля и зоны стоимости на объемном профиле BTC-USD. Синяя горизонтальная линия отмечает POC — уровень с максимальным объемом. Фиолетовая зона показывает Value Area, содержащую 70% объема бара. VAH (Value Area High) и VAL (Value Area Low) отмечают границы зоны. Цена за пределами VA (выше VAH или ниже VAL) считается низколиквидной областью, где движение происходит быстрее. В данном примере POC смещен к верхней границе, что указывает на активность покупателей Метод точки контроля и зоны стоимости используется для размещения лимитных ордеров на откат, VA — для определения нормального торгового диапазона. Пробой за границы VA с объемом сигнализирует о потенциальном трендовом движении. Возврат в VA после пробоя часто приводит к консолидации. Метод 3: Ядерная оценка плотности распределения (Kernel Density Estimation) Kernel Density Estimation (KDE) строит непрерывную оценку плотности распределения объема по ценовым уровням. В отличие от гистограммы, где объем разбит на дискретные бины, KDE создает гладкую функцию плотности. Это позволяет точнее определить центры концентрации и их границы, особенно когда объем распределен с шумом или имеет несколько близко расположенных пиков. Метод использует взвешенный KDE: Каждый ценовой уровень повторяется пропорционально объему на нем. Гауссово ядро сглаживает распределение; После построения функции плотности находятся локальные максимумы (пики) — они соответствуют зонам концентрации; Ширина каждой зоны определяется на уровне половины высоты пика (Full Width at Half Maximum, FWHM). KDE широко применяется в статистическом анализе временных рядов и распознавании паттернов. В контексте микроструктуры рынка метод позволяет выделить уровни, где концентрируются лимитные ордера, без жесткой привязки к фиксированным размерам окон или процентным порогам. def find_zones_kde(data, n_points=100, peak_threshold_percentile=75): """ Поиск зон концентрации через Kernel Density Estimation Parameters: data: DataFrame с MultiIndex (timestamp, price) n_points: количество точек для интерполяции функции плотности peak_threshold_percentile: перцентиль для отсечения незначимых пиков Returns: DataFrame с зонами концентрации """ results = [] timestamps = sorted(data.index.get_level_values(0).unique()) for timestamp in timestamps: bar_data = data.loc[timestamp].copy() if len(bar_data) < 3: continue prices = bar_data.index.values volumes = bar_data['quantity'].values # Взвешенное KDE: повторяем цены пропорционально объему weighted_prices = np.repeat(prices, (volumes * 100).astype(int)) if len(weighted_prices) < 2: continue try: # Строим KDE kde = gaussian_kde(weighted_prices) price_grid = np.linspace(prices.min(), prices.max(), n_points) density = kde(price_grid) # Нормализация для сопоставимости density_norm = (density - density.min()) / (density.max() - density.min() + 1e-10) # Находим пики threshold = np.percentile(density_norm, peak_threshold_percentile) peaks, _ = find_peaks( density_norm, height=threshold, distance=int(n_points * 0.05) ) # Обрабатываем каждый пик for peak_idx in peaks: peak_price = price_grid[peak_idx] peak_density = density_norm[peak_idx] # Ширина на половине высоты (FWHM) half_height = peak_density / 2 left_idx = peak_idx while left_idx > 0 and density_norm[left_idx] > half_height: left_idx -= 1 right_idx = peak_idx while right_idx < len(density_norm) - 1 and density_norm[right_idx] > half_height: right_idx += 1 peak_low = price_grid[left_idx] peak_high = price_grid[right_idx] # Buy pressure в зоне пика zone_data = bar_data[ (bar_data.index >= peak_low) & (bar_data.index <= peak_high) ] buyers_vol = zone_data['buyers'].sum() sellers_vol = zone_data['sellers'].sum() total_dir = buyers_vol + sellers_vol zone_volume = zone_data['quantity'].sum() results.append({ 'timestamp': timestamp, 'method': 'KDE', 'price_center': peak_price, 'zone_low': peak_low, 'zone_high': peak_high, 'concentration': peak_density, # нормализованная плотность 'buy_pressure': buyers_vol / total_dir if total_dir > 0 else 0.5, 'zone_volume': zone_volume }) except: continue return pd.DataFrame(results) # Применяем метод zones_kde = find_zones_kde( data, n_points=100, peak_threshold_percentile=75 ) print(f"\nKDE анализ:") print(f"Найдено пиков: {len(zones_kde)}") if len(zones_kde) > 0: print(f"Средняя плотность пиков: {zones_kde['concentration'].mean():.3f}") print(f"Средняя ширина зон: {(zones_kde['zone_high'] - zones_kde['zone_low']).mean():.2f}") KDE анализ: Найдено пиков: 43 Средняя плотность пиков: 0.961 Средняя ширина зон: 23.89 Функция создает взвешенный массив цен там, где каждый уровень повторяется пропорционально объему. Scipy gaussian_kde() строит функцию плотности на этом массиве. Функция интерполируется на равномерную сетку из 100 точек. Scipy find_peaks() находит локальные максимумы выше заданного перцентиля (75%). Для каждого пика определяются границы на уровне половины высоты. Метод дает наиболее статистически корректную оценку зон концентрации. Гладкая функция плотности устраняет шум дискретных данных. Автоматическое определение границ зон через FWHM адаптируется к форме распределения. Однако KDE требует достаточного количества точек данных — на барах с малым числом ценовых уровней результат ненадежен. Выбор параметра bandwidth ядра влияет на степень сглаживания, gaussian_kde подбирает его автоматически по правилу Скотта. # Визуализация KDE и найденных пиков bar_timestamp = zones_kde['timestamp'].iloc[0] if len(zones_kde) > 0 else timestamps[0] bar_data = data.loc[bar_timestamp] bar_kde_zones = zones_kde[zones_kde['timestamp'] == bar_timestamp] # Пересчитываем KDE для визуализации prices = bar_data.index.values volumes = bar_data['quantity'].values weighted_prices = np.repeat(prices, (volumes * 100).astype(int)) if len(weighted_prices) >= 2: kde = gaussian_kde(weighted_prices) price_grid = np.linspace(prices.min(), prices.max(), 100) density = kde(price_grid) density_norm = (density - density.min()) / (density.max() - density.min() + 1e-10) fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 8)) # График 1: Volume profile с зонами ax1.barh(bar_data.index, bar_data['quantity'], alpha=0.3, color='gray', label='Volume') for _, zone in bar_kde_zones.iterrows(): rect = Rectangle( (0, zone['zone_low']), bar_data['quantity'].max() * 1.1, zone['zone_high'] - zone['zone_low'], alpha=0.3, color='orange', edgecolor='black', linewidth=2 ) ax1.add_patch(rect) ax1.text( bar_data['quantity'].max() * 0.5, zone['price_center'], f"Peak\n{zone['concentration']:.2f}", ha='center', va='center', fontsize=10, fontweight='bold', bbox=dict(boxstyle='round', facecolor='white', alpha=0.8) ) ax1.set_ylabel('Price', fontsize=12) ax1.set_xlabel('Volume', fontsize=12) ax1.set_title('KDE Zones on Volume Profile', fontsize=14) ax1.grid(True, alpha=0.3) # График 2: KDE плотность ax2.plot(density_norm, price_grid, linewidth=2, color='purple', label='KDE Density') ax2.fill_betweenx(price_grid, 0, density_norm, alpha=0.3, color='purple') # Отмечаем пики for _, zone in bar_kde_zones.iterrows(): ax2.axhline(zone['price_center'], color='red', linestyle='--', linewidth=2, alpha=0.7, label='Peak' if _ == bar_kde_zones.index[0] else '') ax2.axhspan(zone['zone_low'], zone['zone_high'], alpha=0.2, color='orange') ax2.set_ylabel('Price', fontsize=12) ax2.set_xlabel('Normalized Density', fontsize=12) ax2.set_title('KDE Density Function', fontsize=14) ax2.legend() ax2.grid(True, alpha=0.3) plt.suptitle(f'KDE Analysis: {bar_timestamp}', fontsize=16, fontweight='bold') plt.tight_layout() plt.show() Рис. 4: Результаты KDE анализа. Левая панель показывает профиль объема Bitcoin с выделенными зонами концентрации, найденными через пики плотности. Правая панель отображает саму функцию плотности (фиолетовая кривая) и отмеченные пики (красные пунктирные линии). Оранжевые зоны соответствуют ширине пиков на половине высоты. Гладкая форма функции устраняет случайные колебания объема на отдельных уровнях KDE подходит для баров с достаточной детализацией ценовых уровней. Метод автоматически определяет количество зон концентрации без предварительного задания параметров типа размера окна. Недостаток — вычислительная сложность выше, чем у скользящего окна или POC, что критично при анализе большого объема исторических данных. Сравнение методов Три рассмотренных метода решают одну задачу разными подходами: Скользящее окно — параметрический метод с явным контролем размера зоны и порога концентрации; POC/Value Area — индустриальный стандарт, детерминированный и не требующий настройки; KDE — статистически продвинутый подход с автоматическим обнаружением произвольного количества зон. Выбор метода зависит от задачи: Для быстрой оценки значимых уровней в рамках дневной торговли подходит POC/Value Area — стабильный результат без настройки; Для поиска конкретных зон входа с учетом агрессивности участников предпочтительнее скользящее окно — параметры можно адаптировать под волатильность инструмента; Для детального анализа микроструктуры и обнаружения множественных уровней концентрации оптимален KDE — статистически корректный подход с автоматическим определением количества зон. По скорости работы наиболее быстрый метод - скользящее окно. Что касаемо точности: KDE дает наименьшее количество ложных срабатываний на зашумленных данных благодаря сглаживанию. Скользящее окно чувствительно к параметрам — неправильный выбор размера окна приводит к пропуску зон или их избыточному дроблению. Также следует учитывать, что POC/VA всегда находит ровно одну зону, что может быть недостаточно для баров с несколькими отдельными кластерами активности. Заключение Зоны концентрации ликвидности — ключевой элемент микроструктурного анализа рынков. Обнаружение этих зон позволяет прогнозировать поведение цены на уровнях с высокой плотностью лимитных ордеров. Алгоритмические стратегии используют эти зоны для размещения ордеров, расчета уровней стоп-лосс и тейк-профит, оценки вероятности пробоя или отскока. Институциональные участники применяют Value Area для определения справедливого диапазона цен, розничные трейдеры — для поиска точек входа на откатах к POC. В высокочастотной торговле смещение ключевых ценовых зон отслеживается в режиме реального времени. Размеры позиций динамически корректируются в зависимости от изменений в книге ордеров. Комбинация нескольких методов повышает надежность сигналов: если несколько подходов указывают на один и тот же уровень, значит в текущий момент он действительно является наиболее значимым. ### Байесовская статистика и вывод в анализе рынков Байесовская статистика предоставляет инструменты для решения ключевых проблем количественного анализа: учет режимных сдвигов, калибровка моделей на ограниченных выборках, оптимизация гиперпараметров стратегий и управление риском переобучения. Байесовский подход рассматривает вероятность как степень уверенности в гипотезе, а не как частоту события в бесконечной серии испытаний. Параметры модели становятся случайными величинами с распределениями, которые обновляются по мере поступления новых данных. Это позволяет естественным образом интегрировать предыдущие знания о рынке, количественно оценивать неопределенность прогнозов и адаптировать модели к изменяющимся условиям. Теорема Байеса и обновление убеждений Теорема Байеса связывает априорные убеждения о параметрах модели с апостериорными распределениями после наблюдения данных: P(θ|D) = P(D|θ) × P(θ) / P(D) где: θ — параметры модели (например, коэффициенты регрессии, волатильность); D — наблюдаемые данные (котировки, объемы, фундаментальные показатели); P(θ|D) — апостериорное распределение параметров после наблюдения данных; P(D|θ) — функция правдоподобия (вероятность данных при фиксированных параметрах); P(θ) — априорное распределение параметров до наблюдения данных; P(D) — маргинальное правдоподобие данных (нормализующая константа). Теорема формализует процесс обновления убеждений: Априорное распределение P(θ) представляет начальные знания о параметрах; Функция правдоподобия P(D|θ) количественно оценивает согласованность данных с различными значениями параметров; Апостериорное распределение P(θ|D) синтезирует обе компоненты в обновленное представление о параметрах. Маргинальное правдоподобие P(D) вычисляется как интеграл по всем возможным значениям параметров: P(D) = ∫ P(D|θ) × P(θ) dθ Эта величина служит нормализующей константой, обеспечивающей корректность апостериорного распределения как плотности вероятности. В задачах сравнения моделей маргинальное правдоподобие выступает мерой качества модели: оно автоматически штрафует излишнюю сложность через интегрирование по всему параметрическому пространству. Априорные и апостериорные распределения Априорное распределение P(θ) кодирует начальные убеждения о параметрах до анализа конкретных данных. В контексте рынков априорные убеждения могут основываться на экономической теории, результатах предыдущих исследований или экспертных оценках. Например, для волатильности актива разумно выбрать априорное распределение с положительной поддержкой и тяжелым правым хвостом, отражающим редкие периоды турбулентности. Рис. 1: Априорное распределение, функция правдоподобия, апостериорное распределение Выбор априорного распределения балансирует между информативностью и гибкостью. Слабо информативные приоры (широкие распределения с высокой дисперсией) минимально влияют на апостериорные выводы и позволяют данным доминировать в обновлении убеждений. Информативные приоры концентрируют вероятностную массу в регионах, которые считаются более правдоподобными на основе предыдущих знаний. Это особенно ценно при работе с малыми выборками, где данных недостаточно для надежной оценки всех параметров. Апостериорное распределение P(θ|D) представляет обновленные убеждения после включения информации из данных. Форма апостериорного распределения определяется взаимодействием априорных убеждений и функции правдоподобия: При большом объеме данных влияние приора снижается, и апостериорное распределение концентрируется вокруг значений параметров, максимизирующих правдоподобие; При малых выборках приор играет регуляризующую роль, предотвращая экстремальные оценки параметров. Последовательное применение теоремы Байеса позволяет непрерывно обновлять убеждения по мере поступления новых наблюдений. Апостериорное распределение после обработки первой порции данных становится априорным для следующей порции: P(θ|D₁, D₂) = P(D₂|θ) × P(θ|D₁) / P(D₂) Этот механизм естественным образом реализует адаптивное обучение в нестационарной среде финансовых рынков. Функция правдоподобия Функция правдоподобия P(D|θ) определяет вероятностную модель генерации наблюдаемых данных при заданных параметрах. Выбор правдоподобия должен отражать статистические свойства рыночных данных: гетероскедастичность, тяжелые хвосты распределения доходностей, кластеризацию волатильности. Для доходностей активов стандартное нормальное распределение часто неадекватно из-за избыточного эксцесса реальных данных. Распределение Стьюдента с настраиваемым числом степеней свободы ν лучше описывает тяжелые хвосты: P(r|μ, σ, ν) = Γ((ν+1)/2) / (Γ(ν/2) × √(νπ) × σ) × [1 + (r-μ)²/(ν×σ²)]^(-(ν+1)/2) где: r — наблюдаемая доходность; μ — параметр сдвига (ожидаемая доходность); σ — параметр масштаба (волатильность); ν — число степеней свободы (контролирует толщину хвостов); Γ — гамма-функция. При ν → ∞ распределение Стьюдента сходится к нормальному, при малых ν хвосты становятся тяжелее, адекватно описывая экстремальные движения рынка. Оценка параметра ν из данных позволяет модели самостоятельно определять степень отклонения от нормальности. Рис. 2: Визуализация работы функции правдоподобия в различных сценариях Для моделирования кластеризации волатильности функция правдоподобия может включать условную гетероскедастичность. В простейшем случае параметр масштаба σₜ становится функцией предыдущих наблюдений: σₜ² = α₀ + α₁ × rₜ₋₁² + β₁ × σₜ₋₁² Эта спецификация соответствует модели GARCH(1,1), где текущая волатильность зависит от предыдущего шока и предыдущей волатильности. Байесовский подход к оценке GARCH позволяет получить распределения всех параметров (α₀, α₁, β₁) и количественно оценить неопределенность прогнозов волатильности. Функция правдоподобия напрямую определяет чувствительность апостериорных выводов к различным типам данных. Модели с тяжелыми хвостами менее чувствительны к выбросам, что важно для робастности в периоды рыночных кризисов. Неправильный выбор правдоподобия приводит к смещенным оценкам параметров и недооценке риска. Байесовская интерпретация параметров моделей Частотные методы возвращают точечные оценки параметров — одно число для каждого параметра модели. Стандартные ошибки и доверительные интервалы добавляются как вторичная информация, но основной результат остается детерминированным. Это создает ложное ощущение определенности: коэффициент β = 0.45 воспринимается как установленный факт, хотя истинное значение может существенно отличаться. Байесовский подход явно представляет параметры как случайные величины с распределениями. Вместо единственного числа β = 0.45 получаем распределение P(β|D), которое полностью описывает неопределенность в значении параметра. Апостериорное распределение может быть: Унимодальным и концентрированным (высокая уверенность); Мультимодальным (несколько правдоподобных значений); Широким и диффузным (высокая неопределенность). Полное апостериорное распределение позволяет отвечать на вероятностные вопросы напрямую: Какова вероятность, что параметр β больше нуля? Интегрирование апостериорной плотности от 0 до ∞ дает точный ответ; Какова вероятность, что β лежит в интервале [0.3, 0.6]? Интеграл по этому интервалу. Частотные методы не позволяют делать такие вероятностные утверждения о параметрах — параметр либо фиксирован (хотя неизвестен), либо вероятностная интерпретация требует гипотетических повторений эксперимента. Количественное представление неопределенности крайне важно для управления рисками: Стратегия с параметрами, оцененными на малой выборке, имеет широкие апостериорные распределения, что сигнализирует о низкой надежности оценок; Модель с узкими распределениями параметров более надежна для продакшена. Явное моделирование неопределенности позволяет калибровать размер позиций и риск-лимиты адекватно уверенности в параметрах. Байесовские интервалы vs Частотные интервалы Частотный доверительный интервал (confidence interval) интерпретируется следующим образом: Если повторить процедуру построения интервала бесконечно много раз на разных выборках из того же распределения, то 95% построенных интервалов будут содержать истинное значение параметра. Конкретный интервал, построенный на имеющейся выборке, либо содержит истинное значение, либо нет — вероятность здесь относится к процедуре построения, а не к параметру. Рис. 3: Байесовский интервал и частотный доверительный интервал Байесовский интервал (credible interval) имеет прямую вероятностную интерпретацию: 95% апостериорной вероятностной массы параметра лежит внутри интервала. Утверждение "с вероятностью 95% параметр β находится между 0.3 и 0.6" математически корректно в байесовской парадигме. Это соответствует интуитивному пониманию интервала и упрощает принятие решений. Центральный байесовский интервал (equal-tailed interval) размещает равные доли вероятности в обоих хвостах апостериорного распределения. 95% центральный интервал отсекает по 2.5% вероятности с каждого конца. Альтернативный подход — интервал высшей плотности (highest density interval, HDI), который включает все значения параметра с апостериорной плотностью выше определенного порога. HDI гарантирует минимальную ширину интервала для заданной вероятности покрытия и лучше подходит для асимметричных распределений. Для мультимодальных апостериорных распределений байесовские интервалы могут быть разрывными — несколько несвязных регионов параметрического пространства содержат высокую вероятностную массу. Это адекватно отражает ситуацию, когда данные поддерживают несколько альтернативных значений параметра. Частотные доверительные интервалы всегда непрерывны и не могут представить такую неопределенность. Предиктивные распределения Апостериорное распределение параметров P(θ|D) описывает неопределенность относительно параметров модели после наблюдения данных. Для прогнозирования будущих наблюдений требуется предиктивное распределение P(y*|D), которое маргинализует неопределенность параметров: P(y*|D) = ∫ P(y*|θ) × P(θ|D) dθ где: y* — будущее наблюдение (например, завтрашняя доходность); P(y*|θ) — модель генерации данных при известных параметрах; P(θ|D) — апостериорное распределение параметров; Интегрирование по θ — усреднение по всем правдоподобным значениям параметров. Предиктивное распределение автоматически включает два источника неопределенности: Неопределенность параметров (epistemic uncertainty); Стохастичность самих данных (aleatoric uncertainty). Модель с точно известными параметрами все равно дает неопределенные прогнозы из-за случайности рыночных движений. Неопределенность параметров добавляет дополнительную вариативность к прогнозам. Рис. 4: Влияние размера выборки на ширину предиктивного распределения. Оценка VaR из предиктивного распределения Ширина предиктивного распределения отражает общую неопределенность прогноза. При малых выборках апостериорные распределения параметров широкие, что приводит к широким предиктивным распределениям. По мере накопления данных апостериорные распределения сужаются, и ширина предиктивных распределений уменьшается, приближаясь к уровню, определяемому только стохастичностью данных. Предиктивные распределения позволяют количественно оценивать риск экстремальных сценариев. Вероятность того, что завтрашняя доходность превысит определенный порог убытка, вычисляется как интеграл предиктивного распределения по соответствующей области. Это прямой способ расчета Value-at-Risk (VaR) и Expected Shortfall (ES), учитывающий неопределенность параметров модели. Апостериорные предиктивные проверки (posterior predictive checks) используют предиктивные распределения для валидации модели: Генерируются синтетические данные из предиктивного распределения и сравниваются с реальными наблюдениями; Систематические расхождения между синтетическими и реальными данными указывают на неадекватность модели — например, модель может недооценивать частоту экстремальных движений или не улавливать кластеризацию волатильности. Последовательное обновление в условиях нестационарности Онлайн обучение Байеса Финансовые рынки нестационарны: параметры моделей меняются со временем из-за структурных сдвигов, изменений режимов монетарной политики, технологических инноваций и макроэкономических шоков. В таких условиях статичные модели, обученные на исторических данных, быстро устаревают. Онлайн обучение Байеса адаптирует параметры модели по мере поступления новых наблюдений. Последовательное применение теоремы Байеса обновляет апостериорное распределение при каждом новом наблюдении. Текущее апостериорное распределение становится априорным для следующего шага: P(θ|D₁, ..., Dₜ) = P(Dₜ|θ) × P(θ|D₁, ..., Dₜ₋₁) / P(Dₜ) Эта процедура не требует хранения всех исторических данных — достаточно поддерживать текущее апостериорное распределение. При поступлении нового наблюдения Dₜ распределение обновляется путем умножения на правдоподобие P(Dₜ|θ) и ренормализации. Вычислительная сложность каждого обновления не зависит от числа предыдущих наблюдений. Рис. 5: Визуализация онлайн обучения Байеса: сходимость к истинному значению, снижение неопределенности, эволюция апостериорного распределения, экспоненциальное забывание Онлайн обучение особенно эффективно с сопряженными априорными распределениями. Сопряженность означает, что априорное и апостериорное распределения принадлежат одному семейству распределений. Для нормального правдоподобия с известной дисперсией сопряженным приором для среднего является нормальное распределение. После обновления по новым данным апостериорное распределение остается нормальным с обновленными параметрами. Например, для оценки ожидаемой доходности актива при известной волатильности σ: Априорное распределение: μ ~ N(μ₀, σ₀²) Правдоподобие одного наблюдения r: r ~ N(μ, σ²) Апостериорное распределение: μ ~ N(μ₁, σ₁²) Параметры апостериорного распределения: μ₁ = (σ² × μ₀ + σ₀² × r) / (σ² + σ₀²) σ₁² = (σ² × σ₀²) / (σ² + σ₀²) Апостериорное среднее μ₁ представляет взвешенное среднее априорного значения μ₀ и нового наблюдения r, где веса обратно пропорциональны дисперсиям. Апостериорная дисперсия σ₁² всегда меньше априорной σ₀², отражая уменьшение неопределенности после наблюдения данных. Эти формулы позволяют обновлять распределение параметра аналитически без численных методов. Экспоненциальное забывание старых данных В нестационарной среде старые наблюдения менее релевантны для текущего состояния рынка. Равномерное взвешивание всех исторических данных приводит к инерционности модели — она медленно реагирует на структурные сдвиги. Экспоненциальное забывание (exponential forgetting) снижает влияние старых данных на текущие оценки параметров. Дисконтирующий фактор λ ∈ (0, 1] контролирует скорость забывания. Правдоподобие наблюдения с лагом k дисконтируется как: P(Dₜ₋ₖ|θ)^λᵏ При λ = 1 все наблюдения имеют равный вес (стандартное байесовское обучение); При λ < 1 влияние наблюдения экспоненциально убывает с увеличением лага. Например, при λ = 0.99 наблюдение месячной давности (k ≈ 21 торговый день) имеет вес 0.99²¹ ≈ 0.81 относительно сегодняшнего наблюдения. Апостериорное распределение с экспоненциальным забыванием рассчитывается по формуле: P(θ|D₁, ..., Dₜ) ∝ [∏ᵢ₌₁ᵗ P(Dᵢ|θ)^λᵗ⁻ⁱ] × P(θ) Где произведение правдоподобий взвешивается по лагу от текущего момента t. Эффективный размер выборки (effective sample size) при экспоненциальном забывании составляет приблизительно: 1/(1-λ) При λ = 0.99 эффективный размер ≈ 100 наблюдений, независимо от полной длины истории. Это означает, что модель адаптируется на основе последних ≈ 100 торговых дней. Выбор λ балансирует между адаптивностью и стабильностью: Малые значения λ (например, 0.95) обеспечивают быструю адаптацию к структурным сдвигам, но повышают чувствительность к шуму и выбросам; Большие значения λ (например, 0.995) дают более стабильные оценки, но медленнее реагируют на изменения режимов. Оптимальное значение λ зависит от характеристик конкретного рынка и временного масштаба анализа. При онлайн обучении часто используется метод экспоненциального забывания. При поступлении нового наблюдения Dₜ₊₁ все предыдущие веса умножаются на λ, а правдоподобие нового наблюдения получает вес 1. Этот подход хорош тем, что не нужно пересчитывать всю историю — достаточно масштабировать текущее апостериорное распределение и обновить его по новому наблюдению. Адаптация к структурным сдвигам рынка Структурные сдвиги (regime changes) представляют резкие изменения статистических свойств рынка: переход от низковолатильного к высоковолатильному режиму, изменение корреляционной структуры активов, смена трендового движения на боковое. Модели, не учитывающие возможность сдвигов, дают смещенные прогнозы после изменения режима. Байесовский подход к моделированию структурных сдвигов вводит скрытую переменную состояния sₜ, которая определяет текущий режим. Параметры модели зависят от состояния: θₜ = θ(sₜ). Апостериорное распределение включает как параметры θ, так и последовательность состояний s₁, ..., sₜ: P(θ, s₁:ₜ|D₁:ₜ) ∝ P(D₁:ₜ|θ, s₁:ₜ) × P(s₁:ₜ) × P(θ) Модель Маркова для переходов между состояниями определяет динамику режимов. Вероятность перехода из состояния i в состояние j задается матрицей переходов: P(sₜ₊₁ = j | sₜ = i) = πᵢⱼ Байесовский вывод одновременно оценивает параметры модели для каждого режима θ(sᵢ), вероятности переходов πᵢⱼ и наиболее вероятную последовательность состояний. Скрытые Марковские модели (Hidden Markov Models, HMM) и их расширения формализуют эту структуру. Альтернативный подход использует изменяющиеся во времени параметры с байесовским сглаживанием. Параметр θₜ эволюционирует случайным образом: θₜ₊₁ = θₜ + εₜ Где εₜ — шум процесса с малой дисперсией. Такая спецификация позволяет параметрам плавно дрейфовать, адаптируясь к постепенным изменениям рынка. Фильтр Калмана и его нелинейные обобщения (Extended Kalman Filter, Unscented Kalman Filter) обеспечивают эффективное онлайн обновление распределений θₜ. Рис. 6: Адаптация к структурным сдвигам и их обнаружение Обнаружение структурных сдвигов при онлайн обучении требует мониторинга предиктивной производительности модели. Байесовский фактор (Bayes factor) сравнивает правдоподобие данных под текущей моделью с альтернативной гипотезой структурного сдвига: BF = P(D₁:ₜ | M₀) / P(D₁:ₜ | M₁) где: M₀ — гипотеза стабильных параметров; M₁ — гипотеза сдвига в момент τ < t. Значения BF << 1 указывают на сильное свидетельство в пользу структурного сдвига. Систематическое ухудшение предиктивного правдоподобия P(Dₜ|D₁:ₜ₋₁) сигнализирует о несоответствии модели текущим данным и необходимости переоценки параметров. Иерархические байесовские модели При анализе портфеля активов возникает дилемма: оценивать параметры каждого актива независимо или объединить все данные в одну модель? Независимая оценка (no pooling) игнорирует потенциальное сходство между активами и приводит к переобучению на малых выборках. Полное объединение (complete pooling) предполагает идентичность всех активов, что нереалистично. Частичное объединение информации (partial pooling) Иерархические байесовские модели реализуют частичное объединение (partial pooling): параметры отдельных активов моделируются как выборки из общего распределения, которое само оценивается из данных. Структура модели включает два уровня: Уровень активов: параметры θᵢ для i-го актива; Гиперуровень: параметры распределения θᵢ. Например, ожидаемые доходности активов μᵢ моделируются как: μᵢ ~ N(μ_pop, σ_pop²) — ожидаемая доходность i-го актива; μ_pop ~ N(μ₀, τ²) — среднее по популяции активов; σ_pop ~ HalfNormal(s) — разброс доходностей между активами. Апостериорный вывод оценивает как индивидуальные параметры μᵢ, так и популяционные параметры μ_pop и σ_pop. Ключевое свойство: оценка μᵢ для конкретного актива зависит не только от данных этого актива, но и от данных всех остальных активов через общее распределение. Степень объединения информации определяется апостериорной оценкой σ_pop: Если σ_pop большая, активы сильно различаются, и индивидуальные оценки μᵢ близки к оценкам без объединения; Если σ_pop малая, активы однородны, и оценки μᵢ сильно притягиваются к общему среднему μ_pop. Модель автоматически калибрует степень заимствования информации между активами на основе данных. Частичное объединение особенно эффективно для активов с малым числом наблюдений. Оценка параметров редко торгуемого актива заимствует силу (borrows strength) от данных других активов, что снижает дисперсию оценки. Для ликвидных активов с длинной историей влияние общего распределения минимально — модель полагается преимущественно на индивидуальные данные. Моделирование кластерной структуры активов Финансовые активы не являются однородной популяцией: акции группируются по секторам, странам, стилям инвестирования. Иерархические модели могут кодировать многоуровневую структуру данных, где активы вложены в группы, а группы — в более крупные категории. Многоуровневая иерархия для акций внутри секторов: Параметры акций: θᵢⱼ ~ N(μⱼ, σ_within²) — i-я акция в j-м секторе; Параметры секторов: μⱼ ~ N(μ_pop, σ_between²) — среднее по j-му сектору; Популяционные параметры: μ_pop, σ_within, σ_between. Эта структура разделяет вариативность параметров на внутригрупповую (σ_within) и межгрупповую (σ_between) компоненты: Если σ_between >> σ_within, основная вариативность между акциями объясняется принадлежностью к сектору; Если σ_within >> σ_between, акции внутри секторов столь же разнообразны, как и между секторами, и группировка по секторам малоинформативна. Рис. 7: Моделирование кластерной структуры активов Иерархические модели автоматически оценивают релевантность группирующей структуры: Если апостериорное распределение σ_between концентрируется около нуля, данные не поддерживают гипотезу значимых различий между секторами; Если σ_between существенно отличается от нуля, секторная структура информативна для прогнозирования параметров отдельных акций. Расширение на более глубокие иерархии (акции → сектора → страны → регионы) позволяет моделировать сложные зависимости. Каждый уровень иерархии вносит свой вклад в общую вариативность параметров. Байесовский подход естественным образом оценивает значимость каждого уровня через апостериорные распределения дисперсий на соответствующих уровнях. Кластерная структура активов влияет на диверсификацию портфеля. Модель, игнорирующая группировку активов по секторам, недооценивает корреляции внутри секторов и переоценивает эффект диверсификации. Иерархические байесовские модели корректно учитывают зависимости, индуцированные общей принадлежностью к группе. Shrinkage-эффект и защита от переобучения Shrinkage (сжатие, усадка) — фундаментальное свойство иерархических байесовских моделей: экстремальные оценки параметров притягиваются к среднему популяционному значению. Актив с аномально высокой исторической доходностью получает апостериорную оценку ниже наблюдаемой, а актив с низкой доходностью — оценку выше наблюдаемой. Это защищает от переоценки перформанса на основе шума. Степень shrinkage зависит от надежности индивидуальных оценок. Параметры, оцененные на малых выборках, сжимаются сильнее, чем параметры с длинной историей наблюдений. Формально, апостериорная оценка параметра θᵢ представляет взвешенное среднее индивидуальной оценки θ̂ᵢ и популяционного среднего μ_pop: E[θᵢ|D] ≈ w × θ̂ᵢ + (1-w) × μ_pop Где вес w зависит от относительной точности оценок: При большом числе наблюдений для i-го актива w близок к 1, и shrinkage минимален; При малом числе наблюдений w близок к 0, и оценка сильно притягивается к среднему популяционному значению. Рис. 8: Визуализация Shrinkage-эффектов Shrinkage-эффект играет ключевую роль при построении портфелей, основанных на оптимизации средней доходности и ковариационной матрицы. Классические выборочные оценки средних и ковариаций часто содержат значительный шум, что приводит к экстремальным значениям весов активов в оптимальном портфеле. Использование иерархических байесовских моделей позволяет автоматически регуляризовать параметры, тем самым стабилизируя структуру портфеля. Связь между shrinkage и регуляризацией проявляется через априорные распределения. Информативные приоры, концентрирующие вероятностную массу около определенного значения, индуцируют shrinkage апостериорных оценок к этому значению. L2-регуляризация в частотном подходе эквивалентна нормальному приору в байесовском подходе, L1-регуляризация — приору Лапласа. Эмпирические байесовские методы оценивают гиперпараметры популяционного распределения из данных, а затем используют эти оценки для shrinkage индивидуальных параметров. Это промежуточный подход между полностью байесовским выводом (с априорами на гиперпараметры) и частотными методами. Эмпирический Байес вычислительно эффективнее полного байесовского вывода, но недооценивает неопределенность гиперпараметров. Байесовская оптимизация и выбор стратегий Функции приобретения: исследование vs эксплуатация Байесовская оптимизация предназначена для поиска глобального максимума целевой функции f(x), вычисление которой может быть дорогостоящим или аналитически невыполнимым. В задаче трейдинга вектор параметров x соответствует гиперпараметрам стратегии — таким, как размеры окон, пороги генерации сигналов и коэффициенты риск-менеджмента, а целевая функция f(x) отражает метрику ее эффективности, например коэффициент Шарпа или доходность, скорректированную на риск. Байесовская оптимизация строит вероятностную модель целевой функции на основе уже вычисленных точек и использует эту модель для выбора следующей точки для оценки. Гауссовский процесс (Gaussian Process, GP) обеспечивает гибкую непараметрическую модель, которая возвращает не только предсказание f(x), но и неопределенность предсказания. Рис. 9: Визуализация Байесовской оптимизации: начальная GP модель, функция приобретения, сходимость После k вычислений функции в точках x₁, ..., xₖ с результатами y₁, ..., yₖ гауссовский процесс предоставляет апостериорное распределение f(x) в любой новой точке x: f(x) | {xᵢ, yᵢ}ᵢ₌₁ᵏ ~ N(μₖ(x), σₖ²(x)) где: μₖ(x) — апостериорное среднее (предсказание значения функции); σₖ²(x) — апостериорная дисперсия (неопределенность предсказания). В точках, где уже проводились вычисления, дисперсия близка к нулю. Вдали от наблюдений дисперсия растет, отражая неопределенность. Выбор следующей точки для оценки xₖ₊₁ балансирует между исследованием (exploration) и эксплуатацией (exploitation): Эксплуатация выбирает точки с высоким предсказанным значением μₖ(x), чтобы улучшить текущий найденный максимум; Исследование выбирает точки с высокой неопределенностью σₖ(x), чтобы уточнить модель и найти потенциально лучшие регионы. Функции приобретения (acquisition functions) формализуют этот компромисс. Ожидаемое улучшение (EI) и верхняя граница доверительного интервала Ожидаемое улучшение (Expected Improvement, EI) количественно оценивает потенциальную пользу от оценки функции в точке x. Пусть f⁺ = max{y₁, ..., yₖ} — лучшее найденное значение. Улучшение в точке x определяется как: I(x) = max(0, f(x) - f⁺) Поскольку f(x) неизвестно, вычисляется ожидаемое улучшение относительно апостериорного распределения: EI(x) = E[max(0, f(x) - f⁺)] = σₖ(x) × [Z × Φ(Z) + φ(Z)] где: Z = (μₖ(x) - f⁺) / σₖ(x) — стандартизованное улучшение; Φ — функция распределения стандартного нормального распределения; φ — плотность стандартного нормального распределения. EI автоматически балансирует исследование и эксплуатацию: Точки с высоким μₖ(x) имеют положительный вклад через член Φ(Z) (эксплуатация); Точки с высоким σₖ(x) имеют положительный вклад через множитель σₖ(x) (исследование); Оптимизация выбирает точку с максимальным EI(x) для следующего вычисления. Верхняя граница доверительного интервала (Upper Confidence Bound, UCB) предлагает альтернативный критерий: UCB(x) = μₖ(x) + κ × σₖ(x) Параметр κ контролирует баланс между исследованием и эксплуатацией: При κ = 0 критерий сводится к чистой эксплуатации (выбирается точка с максимальным μₖ(x)); При больших κ акцент смещается на исследование регионов с высокой неопределенностью. Типичные значения κ ∈ [1, 3]. Рис. 10: Механика функций приобретения EI и UCB UCB имеет теоретические гарантии: при соответствующем выборе κ(k), растущем с числом итераций, UCB гарантирует сходимость к глобальному оптимуму. EI не имеет таких формальных гарантий, но часто демонстрирует лучшую эмпирическую производительность. Выбор между EI и UCB зависит от специфики задачи и вычислительных ограничений. Вероятность улучшения (Probability of Improvement, PI) представляет еще один критерий: PI(x) = P(f(x) > f⁺) = Φ((μₖ(x) - f⁺) / σₖ(x)) PI максимизирует вероятность найти точку лучше текущего максимума, но не учитывает величину потенциального улучшения. Это приводит к излишне консервативному поведению — алгоритм выбирает точки с высокой вероятностью небольшого улучшения вместо точек с малой вероятностью значительного улучшения. Применение для поиска оптимальных параметров Байесовская оптимизация эффективна для настройки гиперпараметров торговых стратегий, где каждая оценка целевой функции требует полного бэктестинга на исторических данных: Grid search вычисляет производительность на равномерной сетке значений параметров, игнорируя информацию из предыдущих вычислений; Random search выбирает точки случайно, что лучше grid search для высокоразмерных пространств, но все еще неэффективно. Байесовская оптимизация адаптивно концентрирует вычислительные ресурсы в перспективных регионах параметрического пространства. После начальной фазы случайной выборки модель гауссовского процесса идентифицирует регионы с высокими значениями целевой функции и направляет поиск туда. Количество требуемых вычислений для достижения хорошего решения обычно на порядок меньше, чем для grid или random search. Типичный процесс байесовской оптимизации стратегии включает: Определение диапазонов гиперпараметров (размеры окон, пороги входа/выхода); Выбор метрики оптимизации (коэффициент Шарпа, Sortino ratio, максимальная просадка); Начальная случайная выборка 5-10 точек для инициализации гауссовского процесса; Итеративный цикл: оптимизация функции приобретения → бэктестинг → обновление GP; Остановка после достижения бюджета вычислений или сходимости. Ключевые преимущества байесовской оптимизации в этом контексте: Эффективность при дорогих оценках функции (каждый бэктест требует минут/часов); Автоматический баланс между локальным и глобальным поиском; Количественная оценка неопределенности найденного оптимума; Робастность к шумным оценкам целевой функции (учет observation noise в GP). Важное ограничение: байесовская оптимизация масштабируется плохо на пространства высокой размерности (>20 параметров). Проклятие размерности влияет на гауссовские процессы — объем данных, необходимый для адекватной аппроксимации функции, растет экспоненциально с размерностью. Для стратегий с большим числом гиперпараметров требуется предварительная редукция размерности или использование специализированных ядер GP. Априорные распределения: выбор и влияние Выбор априорного распределения — критический этап байесовского анализа, влияющий на апостериорные выводы. Неинформативные (слабо информативные) приоры минимально ограничивают параметры и позволяют данным доминировать в формировании апостериорного распределения. Информативные приоры кодируют существенные предыдущие знания и направляют вывод к правдоподобным значениям параметров. Информативные vs неинформативные приоры Униформное распределение на ограниченном интервале представляет простейший неинформативный приор: все значения параметра в интервале равновероятны. Для параметра масштаба (волатильность, стандартное отклонение) униформный приор неадекватен — он подразумевает одинаковую априорную вероятность для σ = 0.01 и σ = 100, что нереалистично. Логарифмически униформный приор (Jeffreys prior) корректирует эту проблему: p(σ) ∝ 1/σ Этот приор инвариантен относительно параметризации: переход к log(σ) не изменяет форму приора, что желательно для параметров масштаба. Слабо информативные приоры занимают промежуточное положение: они исключают экстремальные значения параметров, но не концентрируют узко вероятностную массу. Например, для коэффициента регрессии β нормальный приор N(0, 10) допускает широкий диапазон значений, но экспоненциально подавляет |β| > 20. Это соответствует мягкой регуляризации без жестких ограничений. Информативные приоры включают конкретные знания о параметрах: Для ожидаемой годовой доходности акции разумный информативный приор: N(0.08, 0.05²), отражающий историческую премию за риск около 8% с умеренной неопределенностью; Для корреляции между акциями одного сектора информативный приор может концентрировать массу в интервале [0.3, 0.7], если известно, что акции умеренно коррелированы. Рис. 11: Типы априорных распределений и влияние приоров Влияние приора на апостериорные выводы зависит от объема данных: При больших выборках (сотни наблюдений) правдоподобие доминирует, и апостериорное распределение слабо зависит от выбора приора; При малых выборках (десятки наблюдений) приор существенно влияет на апостериорные оценки. Это не недостаток байесовского подхода, а его особенность: при недостатке данных предыдущие знания легитимно влияют на выводы. Регуляризация через априорные убеждения Априорные распределения выполняют роль регуляризации, предотвращая переобучение модели на ограниченных данных. Концентрация априорной массы около нуля для параметров модели эквивалентна штрафу за сложность модели — параметры отклоняются от нуля только если данные предоставляют достаточное свидетельство. Нормальный приор на коэффициенты регрессии: β ~ N(0, τ²) соответствует L2-регуляризации (ridge regression). Параметр τ контролирует силу регуляризации: малые τ сильно притягивают коэффициенты к нулю, большие τ ослабляют регуляризацию. Байесовская интерпретация позволяет оценить τ из данных через иерархическую модель, избегая кросс-валидации. Приор Лапласа на коэффициенты: p(β) ∝ exp(-λ|β|) соответствует L1-регуляризации (lasso). Пиковая форма распределения Лапласа в нуле индуцирует разреженность: многие апостериорные оценки коэффициентов концентрируются точно в нуле. Это автоматически выполняет отбор признаков, идентифицируя нерелевантные переменные. Приор Хорсшоу (Horseshoe prior) представляет продвинутую регуляризацию для разреженных моделей: βⱼ ~ N(0, λⱼ² × τ²) λⱼ ~ Cauchy⁺(0, 1) Локальные параметры λⱼ позволяют некоторым коэффициентам быть большими (если данные это позволяют), в то время как другие сжимаются к нулю. Глобальный параметр τ контролирует общую степень разреженности. Приор Хорсшоу адаптивнее лассо: он не сжимает сильные сигналы так агрессивно. Регуляризация через приоры особенно ценна для высокоразмерных моделей, где число параметров сопоставимо или превышает число наблюдений. Без регуляризации максимальное правдоподобие дает переобученные оценки с нулевой предсказательной способностью. Априорные распределения стабилизируют оценки, явно кодируя предпочтение к более простым моделям. Субъективность и надежность выводов Субъективность априорных распределений критикуется как недостаток байесовского подхода: разные аналитики могут выбрать разные приоры и получить разные выводы. Эта критика частично справедлива, но применима и к частотным методам: выбор архитектуры модели, преобразования переменных, критериев отбора — все это субъективные решения, влияющие на результаты. Байесовский подход делает субъективность явной и контролируемой: Априорные распределения документируют предположения аналитика, что улучшает воспроизводимость и позволяет критиковать конкретные допущения. Анализ чувствительности оценивает робастность выводов к выбору приора. Повторение анализа с несколькими альтернативными приорами (оптимистичный, пессимистичный, нейтральный) показывает, насколько апостериорные выводы зависят от априорных допущений. Если все разумные приоры приводят к качественно схожим выводам, результаты надежны. Если выводы сильно зависят от приора, это сигнализирует о недостатке данных или фундаментальной неопределенности. Объективные байесовские методы пытаются минимизировать субъективность через автоматический выбор приоров на основе формальных критериев. Референтные приоры (reference priors) максимизируют ожидаемую дивергенцию Кульбака-Лейблера между приором и апостериорным распределением, формализуя идею "максимального обучения от данных". Максимально энтропийные приоры (maximum entropy priors) выбираются при заданных ограничениях на моменты, минимизируя необоснованные допущения. На практике баланс между субъективными информативными приорами и объективными слабо информативными приорами зависит от контекста: Для задач с богатыми предыдущими знаниями (например, волатильность индексов) информативные приоры повышают качество выводов на малых выборках; Для исследовательских задач с минимальными предыдущими знаниями слабо информативные приоры предпочтительны, чтобы избежать навязывания необоснованных ограничений. Вычислительные методы байесовского вывода Markov Chain Monte Carlo (MCMC) Аналитическое вычисление апостериорных распределений возможно только для узкого класса моделей с сопряженными приорами. Для реалистичных моделей финансовых рынков апостериорные распределения не имеют аналитической формы и требуют численных методов. Markov Chain Monte Carlo (MCMC) генерирует выборки из апостериорного распределения, которые используются для аппроксимации интересующих величин. MCMC конструирует цепь Маркова, стационарное распределение которой совпадает с целевым апостериорным распределением P(θ|D). Генерация длинной последовательности θ⁽¹⁾, θ⁽²⁾, ..., θ⁽ᴺ⁾ из этой цепи дает выборку из апостериорного распределения (после отбрасывания начальной burn-in фазы). Эмпирическое распределение выборки аппроксимирует истинное апостериорное распределение. Алгоритм Метрополиса-Гастингса представляет базовый MCMC-метод. На каждой итерации предлагается новое значение параметров θ* из proposal distribution q(θ*|θ⁽ᵗ⁾). Предложение принимается с вероятностью: α = min(1, [P(θ*|D) × q(θ⁽ᵗ⁾|θ*)] / [P(θ⁽ᵗ⁾|D) × q(θ*|θ⁽ᵗ⁾)]) Если предложение принимается, θ⁽ᵗ⁺¹⁾ = θ*, иначе θ⁽ᵗ⁺¹⁾ = θ⁽ᵗ⁾. Критическое свойство: для вычисления α не требуется знать нормализующую константу P(D), так как она сокращается в отношении апостериорных вероятностей. Гиббс-сэмплирование упрощает процедуру для многомерных параметров. Параметры разбиваются на блоки, и каждый блок обновляется условно на текущих значениях остальных блоков. Если условные распределения P(θᵢ|θ₋ᵢ, D) имеют стандартную форму, сэмплирование из них эффективно. Гиббс-сэмплирование — частный случай Метрополиса-Гастингса с вероятностью принятия α = 1. Hamiltonian Monte Carlo (HMC) использует градиентную информацию для эффективного исследования параметрического пространства. Метод моделирует физическую систему, где параметры соответствуют положению частицы, а отрицательный логарифм апостериорной плотности — потенциальной энергии. Вспомогательные импульсные переменные вводятся для генерации траекторий в расширенном пространстве. HMC значительно эффективнее случайного блуждания для высокоразмерных моделей. No-U-Turn Sampler (NUTS) автоматически настраивает параметры HMC, устраняя необходимость ручной настройки длины траектории и размера шага. NUTS останавливает симуляцию траектории, когда она начинает разворачиваться назад, балансируя между исследованием и вычислительными затратами. Современные библиотеки вероятностного программирования (PyMC, Stan) используют NUTS как сэмплер по умолчанию. Variational Inference как альтернатива Variational Inference (VI) аппроксимирует апостериорное распределение P(θ|D) более простым параметрическим распределением: Q(θ; φ) оптимизируя параметры φ для минимизации расхождения между Q и P. Вместо генерации выборок VI решает оптимизационную задачу, что обычно быстрее MCMC. Дивергенция Кульбака-Лейблера (KL divergence) количественно оценивает различие между распределениями: KL(Q || P) = ∫ Q(θ; φ) × log[Q(θ; φ) / P(θ|D)] dθ Минимизация KL(Q || P) по параметрам φ приводит аппроксимацию Q ближе к целевому апостериорному распределению P. Прямая минимизация затруднена, так как требуется вычисления нормализующей константы P(D). Вместо этого максимизируется Evidence Lower Bound (ELBO): ELBO(φ) = ∫ Q(θ; φ) × log[P(D, θ) / Q(θ; φ)] dθ ELBO (Evidence Lower Bound) — это нижняя граница для логарифма маргинального правдоподобия logP(D). Максимизируя ELBO, мы фактически минимизируем дивергенцию Кульбака–Лейблера между аппроксимирующим распределением Q и истинным апостериорным распределением P, и при этом нам не нужно явно вычислять нормирующую константу P(D). Для вычисления градиентов ELBO используются два основных подхода: Reparameterization trick (перепараметризация); Score function estimators (оценка функции отклика). Mean-field variational inference предполагает факторизацию аппроксимирующего распределения: Q(θ; φ) = ∏ⱼ Qⱼ(θⱼ; φⱼ) Каждый параметр θⱼ моделируется независимо. Эта упрощающая гипотеза делает оптимизацию эффективной, но игнорирует апостериорные корреляции между параметрами. Для задач, где корреляции параметров критичны, mean-field VI дает смещенные приближения. Рис. 12: Визуализация методов Variational Inference В structured variational inference используется более гибкий класс аппроксимирующих распределений, способный частично сохранять зависимости между параметрами, в отличие от классического вариационного вывода, где часто предполагается их независимость. Нормализующие потоки (normalizing flows) позволяют получать сложные распределения, последовательно преобразуя простое базовое распределение через набор обратимых нелинейных функций. Это делает возможным приближение даже сильно коррелированных и мультимодальных апостериорных распределений. Ключевое преимущество VI — масштабируемость. Стохастический градиентный спуск позволяет оптимизировать ELBO на мини-батчах данных, что повышает скорость моделирования для больших датасетов. MCMC требует обработки всех данных на каждой итерации, что непрактично для миллионов наблюдений. Недостаток VI — потенциальная смещенность аппроксимации и недооценка неопределенности. Диагностика сходимости и качества аппроксимации Валидация результатов MCMC требует проверки сходимости цепи к стационарному распределению. Визуальный анализ trace plots (траекторий параметров θ(t) во времени) позволяет выявить типичные проблемы: застревание в локальных модах, наличие трендов и слабое смешивание. Цепь, корректно исследующая апостериорное распределение, должна демонстрировать стационарное поведение без выраженных тенденций и автокорреляции. Рис. 13: Диагностика сходимости MCMC R-hat статистика (Gelman-Rubin diagnostic) сравнивает вариативность внутри цепей с вариативностью между цепями: Запускаются несколько независимых цепей из разных начальных точек; Если цепи сошлись к одному распределению, вариативность между цепями должна быть сопоставима с вариативностью внутри цепей. R-hat близкая к 1 (обычно < 1.01) указывает на сходимость, R-hat > 1.1 сигнализирует о проблемах. Effective Sample Size (ESS) оценивает число эффективно независимых выборок, учитывая автокорреляцию в цепи. Последовательные выборки θ⁽ᵗ⁾ и θ⁽ᵗ⁺¹⁾ коррелированы из-за марковской природы процесса. ESS корректирует номинальное число итераций на степень автокорреляции: ESS = N / (1 + 2 × Σₖ₌₁^∞ ρₖ) где: N — длина цепи; ρₖ — автокорреляция на лаге k. Высокая автокорреляция снижает ESS, указывая на неэффективное смешивание. Практическое правило: ESS > 1000 для каждого параметра обеспечивает надежные оценки квантилей и моментов апостериорного распределения. Для Variational Inference качество аппроксимации оценивается через ELBO. Разность между истинным log P(D) и ELBO равна KL(Q || P). Максимизация ELBO минимизирует эту разность, но остаточное расхождение неизбежно из-за ограничений семейства Q. Мониторинг ELBO во время оптимизации показывает, сходится ли процедура. Рекомендую также сравнивать симулированные данные из апостериорного предиктивного распределения с реальными наблюдениями. Систематические расхождения (например, модель недооценивает хвосты распределения доходностей) указывают на неадекватность модели или аппроксимации. Эта диагностика применима как к MCMC, так и к VI. Парето-сглаженная важностная выборка (Pareto Smoothed Importance Sampling, PSIS) оценивает качество аппроксимации через анализ весов важности. Если аппроксимирующее распределение Q существенно отличается от истинного P в регионах высокой вероятности, веса важности имеют тяжелые хвосты. Параметр формы распределения Парето для хвоста весов диагностирует надежность аппроксимации: значения < 0.5 хорошие, > 0.7 проблематичные. Заключение В байесовской парадигме статистический вывод превращается в постоянное обновление убеждений по мере поступления новой информации. Параметры модели уже не фиксированы и неизвестны, а рассматриваются как случайные величины с меняющимися распределениями. Это концептуальное смещение хорошо согласуется с динамикой финансовых рынков: высокая неопределенность, регулярные структурные изменения и постоянная ценность новой информации для корректировки стратегий. В количественном анализе байесовский подход дает несколько важных преимуществ: Представление неопределенности через распределения параметров трансформирует управление рисками: размеры позиций калибруются не только на точечные оценки доходности, но и на ширину байесовских интервалов. Иерархические модели автоматически регуляризуют оценки через частичное объединение информации между активами, защищая модель от переобучения. Последовательное обновление убеждений позволяет стратегиям адаптироваться к меняющимся рыночным условиям, сохраняя память о предыдущих режимах через информативные приоры. Байесовская оптимизация эффективно находит оптимальные гиперпараметры стратегий при ограниченном бюджете вычислений. Таким образом, байесовские методы позволяют строить модели и находить стратегии, способные эффективно работать даже в условиях рыночного шума и постоянных изменений, обеспечивая взвешенный подход к управлению рисками. ### Мониторинг ML-моделей: детекция дрифта и снижения метрик качества Модель обучена, метрики на валидации отличные, деплой в продакшен прошел успешно. Через два месяца точность падает на 15%, а через полгода модель работает хуже бейзлайна. Деградация качества ML-моделей в продакшене — это, увы, довольно частое явление. Данные меняются, распределения сдвигаются, зависимости трансформируются. Мониторинг ML-моделей позволяет обнаружить проблемы до того, как они повлияют на бизнес-метрики. Система детекции дрифта выявляет изменения в данных и поведении модели, метрики качества количественно оценивают деградацию, алертинг сигнализирует о необходимости переобучения. Типы дрифта в ML-моделях Дрифт — изменение статистических свойств данных или целевой переменной во времени. Различают три основных типа, каждый требует специфических методов детекции и стратегий реагирования. Концептуальный дрифт (Concept Drift) При концептуальном дрифте изменяется зависимость между признаками и целевой переменной, тогда как распределение входных данных остается неизменным. Модель получает привычные признаки, но их связь с таргетом уже трансформировалась. Примеры концептуального дрифта: Модель кредитного скоринга: уровень доходов клиентов остался прежним, но изменилась корреляция между доходом и вероятностью дефолта из-за экономического кризиса; Рекомендательная система: пользователи с теми же демографическими характеристиками начали предпочитать другой контент после появления нового культурного тренда; Медицинская диагностика: симптомы остались идентичными, но их связь с заболеванием изменилась при появлении новых штаммов. Концептуальный дрифт сложнее всего обнаружить без доступа к истинным значениям (ground truth). Единственный надежный индикатор — снижение метрик качества на реальных данных. Дрифт данных (Data Drift) При дрифте данных изменяется распределение входных признаков, при этом целевая зависимость может оставаться стабильной. Модель сталкивается с данными из областей признакового пространства, которые слабо представлены в обучающей выборке. Типичные сценарии: Изменение демографии пользователей после расширения на новые рынки; Сезонные паттерны в поведенческих данных; Технические изменения в системах сбора данных; Появление новых категорий в категориальных признаках. Дрифт данных выявляется с помощью статистических тестов, оценивающих различия в распределениях. Его критичность определяется тем, насколько новые данные выходят за пределы распределения, использованного при обучении модели. Дрифт предсказаний (Prediction Drift) Распределение выходов модели меняется со временем. Это может происходить как следствие дрифта данных или концептуального дрифта, но иногда само по себе служит независимым индикатором потенциальных проблем. Практическое значение prediction drift: Ранний сигнал о проблемах до получения ground truth; Детекция технических ошибок в пайплайне; Мониторинг консистентности модели. Дрифт предсказаний весьма коварен. Другие метрики могут быть стабильны, точность предсказаний тоже. Если модель классификации внезапно начала предсказывать положительный класс в 30% случаев вместо привычных 7%, это требует немедленного расследования независимо от стабильности других метрик. Статистические методы детекции дрифта Обнаружение дрифта строится на сравнении статистических распределений: Референсное (эталонное) распределение (reference distribution) - данные обучения или стабильный период; Текущее распределение (current distribution) - текущие данные в продакшене. Выбор метода зависит от типа признаков и требований к чувствительности. Тест Колмогорова-Смирнова Kolmogorov-Smirnov-тест (KS-тест) оценивает максимальную разницу между кумулятивными функциями распределения двух выборок. И хотя метод работает только для непрерывных одномерных распределений, он дает достаточно хорошую оценку статистической значимости различий. import numpy as np from scipy.stats import ks_2samp import pandas as pd def detect_drift_ks(reference_data, current_data, features, threshold=0.05): """ Детекция дрифта методом Kolmogorov-Smirnov Args: reference_data: базовое распределение (обучающие данные) current_data: текущее распределение (продакшен) features: список числовых признаков для анализа threshold: уровень значимости (alpha) Returns: dict с результатами для каждого признака """ drift_report = {} for feature in features: ref_values = reference_data[feature].dropna() curr_values = current_data[feature].dropna() # KS-статистика и p-value ks_stat, p_value = ks_2samp(ref_values, curr_values) drift_report[feature] = { 'ks_statistic': ks_stat, 'p_value': p_value, 'drift_detected': p_value < threshold, 'reference_mean': ref_values.mean(), 'current_mean': curr_values.mean(), 'reference_std': ref_values.std(), 'current_std': curr_values.std() } return drift_report # Генерация примера с дрифтом np.random.seed(42) reference = pd.DataFrame({ 'feature_1': np.random.normal(0, 1, 1000), 'feature_2': np.random.exponential(2, 1000), 'feature_3': np.random.normal(5, 2, 1000) }) # feature_1 без дрифта, feature_2 с дрифтом среднего, feature_3 с дрифтом дисперсии current = pd.DataFrame({ 'feature_1': np.random.normal(0, 1, 500), 'feature_2': np.random.exponential(3, 500), 'feature_3': np.random.normal(5, 4, 500) }) results = detect_drift_ks(reference, current, ['feature_1', 'feature_2', 'feature_3']) for feature, metrics in results.items(): print(f"\n{feature}:") print(f" KS-статистика: {metrics['ks_statistic']:.4f}") print(f" p-value: {metrics['p_value']:.4f}") print(f" Дрифт: {'Да' if metrics['drift_detected'] else 'Нет'}") print(f" Среднее: {metrics['reference_mean']:.2f} → {metrics['current_mean']:.2f}") print(f" Std: {metrics['reference_std']:.2f} → {metrics['current_std']:.2f}") feature_1: KS-статистика: 0.0380 p-value: 0.7169 Дрифт: Нет Среднее: 0.02 → -0.04 Std: 0.98 → 1.03 feature_2: KS-статистика: 0.1650 p-value: 0.0000 Дрифт: Да Среднее: 2.02 → 2.92 Std: 2.01 → 2.90 feature_3: KS-статистика: 0.1900 p-value: 0.0000 Дрифт: Да Среднее: 5.03 → 4.73 Std: 1.94 → 3.95 Представленный выше код реализует классический воркфлоу детекции дрифта: независимое сравнение референсной и текущей выборок по каждому признаку. KS-тест возвращает статистику (максимальное расхождение CDF) и p-value для проверки нулевой гипотезы об идентичности распределений. При p-value < threshold отвергаем гипотезу и фиксируем дрифт. Дополнительно рассчитываются описательные статистики для интерпретации природы изменений. Ограничения метода: KS-тест чувствителен к любым различиям в распределении, включая сдвиги медианы, изменения дисперсии и трансформации формы распределения; Тест работает только для непрерывных признаков, требует достаточного объема данных (минимум 50-100 наблюдений в каждой выборке); Тест чувствителен к выбросам; Не годится для категориальных признаков, в таком случае используются альтернативные подходы. Population Stability Index (PSI) Индекс PSI измеряет сдвиг распределения через сравнение долей наблюдений в бинах. Метод универсален: работает для непрерывных и категориальных признаков, дает интерпретируемую числовую оценку степени дрифта. Формула PSI: PSI = Σ(Pₐ - Pₑ) × ln(Pₐ / Pₑ) где: Pₐ — доля наблюдений в бине для текущего распределения; Pₑ — доля наблюдений в бине для референсного распределения; ln — натуральный логарифм. Интерпретация значений: PSI < 0.1 — дрифт отсутствует, 0.1 ≤ PSI < 0.25 — умеренный дрифт (мониторинг), PSI ≥ 0.25 — критический дрифт (требуется переобучение). import numpy as np import pandas as pd def calculate_psi(reference, current, bins=10, categorical=False): """ Расчет Population Stability Index (PSI) Args: reference: референсное распределение current: текущее распределение bins: количество бинов для непрерывных признаков categorical: True для категориальных признаков Returns: float: значение PSI """ if categorical: # Для категориальных: каждая категория = бин ref_counts = reference.value_counts() curr_counts = current.value_counts() # Объединение всех категорий all_categories = set(ref_counts.index) | set(curr_counts.index) ref_props = np.array([ref_counts.get(cat, 0) for cat in all_categories], dtype=float) curr_props = np.array([curr_counts.get(cat, 0) for cat in all_categories], dtype=float) else: # Для непрерывных: квантильные бины на reference breakpoints = np.quantile(reference, np.linspace(0, 1, bins + 1)) breakpoints[0] = -np.inf breakpoints[-1] = np.inf ref_binned = pd.cut(reference, bins=breakpoints) curr_binned = pd.cut(current, bins=breakpoints) ref_counts = ref_binned.value_counts() curr_counts = curr_binned.value_counts() ref_props = ref_counts.values.astype(float) curr_props = curr_counts.values.astype(float) # Нормализация ref_props /= ref_props.sum() curr_props /= curr_props.sum() # Защита от нулевых долей ref_props = np.where(ref_props == 0, 1e-10, ref_props) curr_props = np.where(curr_props == 0, 1e-10, curr_props) # PSI формула psi_value = np.sum((curr_props - ref_props) * np.log(curr_props / ref_props)) return psi_value # Пример с разными уровнями дрифта ref_normal = np.random.normal(0, 1, 2000) curr_no_drift = np.random.normal(0, 1, 1000) curr_moderate = np.random.normal(0.3, 1.1, 1000) curr_severe = np.random.normal(1, 2, 1000) print("PSI для разных сценариев:") print(f"Без дрифта: {calculate_psi(ref_normal, curr_no_drift):.4f}") print(f"Умеренный дрифт: {calculate_psi(ref_normal, curr_moderate):.4f}") print(f"Критический дрифт: {calculate_psi(ref_normal, curr_severe):.4f}") # Категориальный пример ref_categorical = pd.Series(np.random.choice(['A', 'B', 'C', 'D'], 2000, p=[0.4, 0.3, 0.2, 0.1])) curr_categorical = pd.Series(np.random.choice(['A', 'B', 'C', 'D'], 1000, p=[0.2, 0.3, 0.3, 0.2])) print(f"\nPSI для категориального признака: {calculate_psi(ref_categorical, curr_categorical, categorical=True):.4f}") PSI для разных сценариев: Без дрифта: 0.0136 Умеренный дрифт: 0.1147 Критический дрифт: 0.8746 PSI для категориального признака: 0.2821 PSI обладает важным преимуществом: одна метрика для всех типов признаков. Для непрерывных переменных создаются квантильные бины на референсных данных, затем текущее распределение проецируется на эти границы. Для категориальных каждая уникальная категория становится отдельным бином. Метод устойчив к выбросам, так как оперирует долями в бинах, а не сырыми значениями. Квантильное биннирование гарантирует равное количество наблюдений в каждом бине для референсного распределения, что повышает статистическую мощность детекции. Выбор количества бинов влияет на чувствительность: 10 бинов — стандарт для большинства задач; 20 бинов — для больших выборок и детального анализа; 5 бинов — для малых выборок. PSI не дает p-value, но пороговые значения 0.1 и 0.25 подтверждены эмпирически в индустрии. Jensen-Shannon Divergence JS-дивергенция измеряет симметричное различие между двумя вероятностными распределениями. В отличие от других типов дивергенций, метод симметричен и ограничен диапазоном [0, 1], что упрощает его интерпретацию. from scipy.spatial.distance import jensenshannon from scipy.stats import entropy def calculate_js_divergence(reference, current, bins=50): """ Расчет Jensen-Shannon Divergence для непрерывных признаков Args: reference: референсное распределение current: текущее распределение bins: количество бинов для гистограммы Returns: float: JS дивергенция в диапазоне [0, 1] """ # Определение общих границ бинов combined = np.concatenate([reference, current]) bin_edges = np.histogram_bin_edges(combined, bins=bins) # Гистограммы с нормализацией ref_hist, _ = np.histogram(reference, bins=bin_edges, density=True) curr_hist, _ = np.histogram(current, bins=bin_edges, density=True) # Нормализация для получения вероятностей ref_probs = ref_hist / ref_hist.sum() curr_probs = curr_hist / curr_hist.sum() # Защита от нулей ref_probs = np.where(ref_probs == 0, 1e-10, ref_probs) curr_probs = np.where(curr_probs == 0, 1e-10, curr_probs) # JS дивергенция js_div = jensenshannon(ref_probs, curr_probs, base=2) return js_div def monitor_multivariate_drift(reference_df, current_df, features, method='psi'): """ Мониторинг дрифта по множеству признаков Returns: DataFrame с метриками дрифта для каждого признака """ results = [] for feature in features: ref_data = reference_df[feature].dropna() curr_data = current_df[feature].dropna() if method == 'psi': drift_score = calculate_psi(ref_data, curr_data) threshold_warn = 0.1 threshold_critical = 0.25 elif method == 'js': drift_score = calculate_js_divergence(ref_data, curr_data) threshold_warn = 0.1 threshold_critical = 0.3 status = 'OK' if drift_score >= threshold_critical: status = 'CRITICAL' elif drift_score >= threshold_warn: status = 'WARNING' results.append({ 'feature': feature, 'drift_score': drift_score, 'status': status, 'ref_mean': ref_data.mean(), 'curr_mean': curr_data.mean(), 'mean_shift_%': ((curr_data.mean() - ref_data.mean()) / ref_data.mean() * 100) if ref_data.mean() != 0 else 0 }) return pd.DataFrame(results).sort_values('drift_score', ascending=False) # Пример мониторинга датасета np.random.seed(123) reference_data = pd.DataFrame({ 'age': np.random.normal(35, 10, 5000), 'income': np.random.lognormal(10, 1, 5000), 'credit_score': np.random.normal(650, 80, 5000), 'loan_amount': np.random.uniform(5000, 50000, 5000) }) current_data = pd.DataFrame({ 'age': np.random.normal(37, 11, 2000), 'income': np.random.lognormal(10.2, 1.1, 2000), 'credit_score': np.random.normal(630, 90, 2000), 'loan_amount': np.random.uniform(8000, 55000, 2000) }) drift_report = monitor_multivariate_drift( reference_data, current_data, ['age', 'income', 'credit_score', 'loan_amount'], method='psi' ) print(drift_report.to_string(index=False)) feature drift_score status ref_mean curr_mean mean_shift_% loan_amount 0.124800 WARNING 27623.168220 31505.408501 14.054290 credit_score 0.101958 WARNING 652.058734 627.260767 -3.803027 income 0.058415 OK 36834.662862 47908.792175 30.064424 age 0.023493 OK 35.210829 36.489371 3.631105 Код демонстрирует полноценный workflow мониторинга множественных признаков. JS-дивергенция вычисляется через гистограммы с общими границами бинов для обоих распределений. Функция monitor_multivariate_drift агрегирует результаты по всем признакам и ранжирует их по степени дрифта. Практическая интерпретация: признаки с наибольшим drift_score требуют приоритетного анализа. Процентное изменение среднего показывает направление дрифта. Статус CRITICAL сигнализирует о необходимости срочного переобучения или ревизии фичей. JS-дивергенция обладает математическими преимуществами: симметричность (JS(P||Q) = JS(Q||P)), ограниченность квадратным корнем из 1, гладкость. Метод менее чувствителен к редким значениям по сравнению с KL-дивергенцией, что максимально ценно для признаков с длинными хвостами распределения, которые часто встречаются в финансовых временных рядах. Метрики качества классификации в MLOps Метрики классификации измеряют соответствие предсказаний модели реальным меткам классов. Выбор метрики определяется балансом классов, стоимостью ошибок и спецификой задачи. Базовые метрики: Precision, Recall, F1-Score Precision (точность) показывает долю корректных предсказаний положительного класса среди всех предсказаний положительного класса. Recall (полнота) измеряет долю найденных положительных объектов среди всех истинно положительных. Precision = TP / (TP + FP) Recall = TP / (TP + FN) TP — true positives (истинно положительные) FP — false positives (ложно положительные) FN — false negatives (ложно отрицательные) F1-score объединяет precision и recall через гармоническое среднее, обеспечивая баланс между двумя метриками. from sklearn.metrics import precision_score, recall_score, f1_score, confusion_matrix from sklearn.metrics import precision_recall_curve, roc_curve, roc_auc_score import matplotlib.pyplot as plt def calculate_classification_metrics(y_true, y_pred, y_pred_proba=None): """ Расчет полного набора метрик классификации Args: y_true: истинные метки y_pred: предсказанные метки (0/1) y_pred_proba: вероятности положительного класса Returns: dict с метриками """ metrics = { 'precision': precision_score(y_true, y_pred), 'recall': recall_score(y_true, y_pred), 'f1': f1_score(y_true, y_pred), 'accuracy': (y_pred == y_true).mean() } # Confusion matrix cm = confusion_matrix(y_true, y_pred) metrics['true_negatives'] = cm[0, 0] metrics['false_positives'] = cm[0, 1] metrics['false_negatives'] = cm[1, 0] metrics['true_positives'] = cm[1, 1] # Дополнительные метрики для несбалансированных классов metrics['specificity'] = cm[0, 0] / (cm[0, 0] + cm[0, 1]) if (cm[0, 0] + cm[0, 1]) > 0 else 0 if y_pred_proba is not None: metrics['roc_auc'] = roc_auc_score(y_true, y_pred_proba) return metrics def monitor_classification_drift(reference_metrics, current_metrics, threshold=0.05): """ Детекция дрифта качества модели классификации Args: reference_metrics: метрики на reference периоде current_metrics: метрики на текущем периоде threshold: допустимое падение метрик Returns: dict с результатами мониторинга """ drift_report = {} for metric_name in ['precision', 'recall', 'f1', 'roc_auc']: if metric_name in reference_metrics and metric_name in current_metrics: ref_value = reference_metrics[metric_name] curr_value = current_metrics[metric_name] degradation = ref_value - curr_value degradation_pct = (degradation / ref_value * 100) if ref_value > 0 else 0 drift_report[metric_name] = { 'reference': ref_value, 'current': curr_value, 'degradation': degradation, 'degradation_%': degradation_pct, 'alert': degradation > threshold } return drift_report # Симуляция деградации модели np.random.seed(42) # Reference период: модель работает хорошо y_true_ref = np.random.binomial(1, 0.3, 1000) y_pred_proba_ref = np.clip(y_true_ref + np.random.normal(0, 0.3, 1000), 0, 1) y_pred_ref = (y_pred_proba_ref > 0.5).astype(int) # Current период: концептуальный дрифт, модель деградирует y_true_curr = np.random.binomial(1, 0.3, 1000) y_pred_proba_curr = np.clip(y_true_curr + np.random.normal(0, 0.5, 1000), 0, 1) y_pred_curr = (y_pred_proba_curr > 0.5).astype(int) ref_metrics = calculate_classification_metrics(y_true_ref, y_pred_ref, y_pred_proba_ref) curr_metrics = calculate_classification_metrics(y_true_curr, y_pred_curr, y_pred_proba_curr) drift_report = monitor_classification_drift(ref_metrics, curr_metrics, threshold=0.03) print("Мониторинг деградации модели:\n") for metric, data in drift_report.items(): alert_marker = "⚠️ ALERT" if data['alert'] else "✓ OK" print(f"{metric.upper()}: {data['reference']:.3f} → {data['current']:.3f} " f"(падение {data['degradation_%']:.1f}%) {alert_marker}") Мониторинг деградации модели: PRECISION: 0.845 → 0.660 (падение 21.9%) ⚠️ ALERT RECALL: 0.948 → 0.840 (падение 11.4%) ⚠️ ALERT F1: 0.894 → 0.740 (падение 17.2%) ⚠️ ALERT ROC_AUC: 0.986 → 0.910 (падение 7.7%) ⚠️ ALERT Реализация включает расчет базовых метрик и детекцию их деградации во времени. Матрица несоответствий (Confusion matrix) разбивается на компоненты для анализа типов ошибок. Специфичность (Specificity - доля корректно классифицированных отрицательных примеров) дополняет полноту (Recall) для полной картины производительности. Функция monitor_classification_drift сравнивает метрики текущего периода и референсного, вычисляя абсолютное и процентное падение. Threshold определяет допустимую деградацию: 3-5% — стандарт для production систем, 10% — критический уровень требующий немедленного переобучения. Я рекомендую вести мониторинг сразу всех метрик, поскольку это позволяет определить характер деградации: Падение Precision при стабильном Recall указывает на рост False positives; Снижение Recall при сохранении Precision сигнализирует о пропуске положительных примеров; Одновременное падение обеих метрик — признак концептуального дрифта. ROC-AUC и PR-AUC Метрика ROC-AUC (Area Under Receiver Operating Characteristic) измеряет способность модели ранжировать примеры: вероятность того, что случайно выбранный положительный пример получит более высокий скор, чем отрицательный. Метрика PR-AUC (Area Under Precision-Recall Curve) фокусируется на производительности для положительного класса. Ее часто используют для несбалансированных задач, где доля положительного класса крайне мала и доминирует отрицательный класс. from sklearn.metrics import average_precision_score def plot_roc_pr_curves(y_true, y_pred_proba, title_prefix=""): """ Визуализация ROC и PR кривых для анализа качества """ fig, axes = plt.subplots(1, 2, figsize=(14, 5)) # ROC Curve fpr, tpr, _ = roc_curve(y_true, y_pred_proba) roc_auc = roc_auc_score(y_true, y_pred_proba) axes[0].plot(fpr, tpr, color='#2C3E50', linewidth=2, label=f'ROC (AUC = {roc_auc:.3f})') axes[0].plot([0, 1], [0, 1], 'k--', linewidth=1, label='Random') axes[0].set_xlabel('False Positive Rate', fontsize=11) axes[0].set_ylabel('True Positive Rate', fontsize=11) axes[0].set_title(f'{title_prefix}ROC Curve', fontsize=12, fontweight='bold') axes[0].legend(loc='lower right') axes[0].grid(alpha=0.3) # PR Curve precision, recall, _ = precision_recall_curve(y_true, y_pred_proba) pr_auc = average_precision_score(y_true, y_pred_proba) baseline = y_true.mean() axes[1].plot(recall, precision, color='#2C3E50', linewidth=2, label=f'PR (AUC = {pr_auc:.3f})') axes[1].axhline(y=baseline, color='gray', linestyle='--', linewidth=1, label=f'Baseline ({baseline:.3f})') axes[1].set_xlabel('Recall', fontsize=11) axes[1].set_ylabel('Precision', fontsize=11) axes[1].set_title(f'{title_prefix}Precision-Recall Curve', fontsize=12, fontweight='bold') axes[1].legend(loc='upper right') axes[1].grid(alpha=0.3) plt.tight_layout() return fig # Сравнение моделей с разной степенью дрифта fig1 = plot_roc_pr_curves(y_true_ref, y_pred_proba_ref, "Reference Period: ") fig2 = plot_roc_pr_curves(y_true_curr, y_pred_proba_curr, "Current Period (with Drift): ") plt.show() print(f"\nReference ROC-AUC: {roc_auc_score(y_true_ref, y_pred_proba_ref):.3f}") print(f"Current ROC-AUC: {roc_auc_score(y_true_curr, y_pred_proba_curr):.3f}") print(f"\nReference PR-AUC: {average_precision_score(y_true_ref, y_pred_proba_ref):.3f}") print(f"Current PR-AUC: {average_precision_score(y_true_curr, y_pred_proba_curr):.3f}") Рис. 1: Сравнение метрик для текущего (current) и референсного (reference) периодов: левая панель: ROC-кривые. Правая панель: PR-кривые. Деградация модели визуализируется смещением кривых относительно предыдущих уровней Reference ROC-AUC: 0.986 Current ROC-AUC: 0.910 Reference PR-AUC: 0.973 Current PR-AUC: 0.811 ROC-AUC устойчива к дисбалансу классов только в смысле ранжирования, но не отражает абсолютное качество предсказаний при малом Positive rate. PR-AUC решает эту проблему: метрика чувствительна к качеству именно положительного класса, что критично в задачах детекции аномалий, фрода, медицинской диагностики. Я обычно использую ROC-AUC для оценки общей разделимости классов, PR-AUC — для оценки практической ценности модели при дисбалансе. Если положительный класс составляет менее 10% выборки, PR-AUC становится основной метрикой мониторинга. Метрики качества регрессии в MLOps Метрики регрессии оценивают точность предсказания непрерывных величин. Выбор зависит от масштаба целевой переменной, чувствительности к выбросам и интерпретируемости для бизнеса. MAE, RMSE, MAPE MAE (Mean Absolute Error) усредняет абсолютные ошибки предсказаний. Метрика линейна: все ошибки вносят пропорциональный вклад независимо от величины. MAE = (1/n) × Σ|yᵢ - ŷᵢ| где: n — количество наблюдений; yᵢ — истинное значение; ŷᵢ — предсказание модели. RMSE (Root Mean Squared Error) возводит ошибки в квадрат перед усреднением, затем извлекает корень. Метрика штрафует большие отклонения сильнее, чем малые. RMSE = √((1/n) × Σ(yᵢ - ŷᵢ)²) MAPE (Mean Absolute Percentage Error) нормализует ошибки относительно истинных значений, давая процентную оценку точности. MAPE = (100%/n) × Σ|((yᵢ - ŷᵢ) / yᵢ)| from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score def calculate_regression_metrics(y_true, y_pred): """ Расчет набора метрик для задач регрессии Returns: dict с метриками качества """ mae = mean_absolute_error(y_true, y_pred) rmse = np.sqrt(mean_squared_error(y_true, y_pred)) # MAPE с защитой от деления на ноль mask = y_true != 0 mape = np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask])) * 100 # R-squared r2 = r2_score(y_true, y_pred) # Median Absolute Error (устойчив к выбросам) medae = np.median(np.abs(y_true - y_pred)) # Symmetric MAPE (для случаев с нулевыми значениями) smape = np.mean(2 * np.abs(y_true - y_pred) / (np.abs(y_true) + np.abs(y_pred))) * 100 return { 'MAE': mae, 'RMSE': rmse, 'MAPE': mape, 'R²': r2, 'MedAE': medae, 'sMAPE': smape } def regression_drift_monitor(reference_y_true, reference_y_pred, current_y_true, current_y_pred): """ Мониторинг деградации регрессионной модели Returns: DataFrame со сравнением метрик """ ref_metrics = calculate_regression_metrics(reference_y_true, reference_y_pred) curr_metrics = calculate_regression_metrics(current_y_true, current_y_pred) comparison = [] for metric_name in ref_metrics.keys(): ref_val = ref_metrics[metric_name] curr_val = curr_metrics[metric_name] # Для R² большее значение лучше, для остальных — меньшее if metric_name == 'R²': degradation = ref_val - curr_val worse = curr_val < ref_val else: degradation = curr_val - ref_val worse = curr_val > ref_val degradation_pct = (degradation / abs(ref_val) * 100) if ref_val != 0 else 0 comparison.append({ 'Metric': metric_name, 'Reference': f"{ref_val:.4f}", 'Current': f"{curr_val:.4f}", 'Change_%': f"{degradation_pct:+.2f}%", 'Status': '⚠️ WORSE' if worse else '✓ OK' }) return pd.DataFrame(comparison) # Симуляция деградации регрессионной модели np.random.seed(42) # Reference: модель точна X_ref = np.linspace(0, 10, 500) y_true_ref = 2 * X_ref + 5 + np.random.normal(0, 2, 500) y_pred_ref = 2 * X_ref + 5 + np.random.normal(0, 1, 500) # Current: концептуальный дрифт, зависимость изменилась X_curr = np.linspace(0, 10, 500) y_true_curr = 2.25 * X_curr + 5 + np.random.normal(0, 2, 500) y_pred_curr = 2 * X_curr + 5 + np.random.normal(0, 1, 500) # модель не адаптировалась drift_report = regression_drift_monitor(y_true_ref, y_pred_ref, y_true_curr, y_pred_curr) print(drift_report.to_string(index=False)) # Визуализация ошибок fig, axes = plt.subplots(1, 2, figsize=(14, 5)) axes[0].scatter(y_true_ref, y_pred_ref, alpha=0.5, s=20, color='#2C3E50') axes[0].plot([y_true_ref.min(), y_true_ref.max()], [y_true_ref.min(), y_true_ref.max()], 'r--', linewidth=2, label='Perfect Prediction') axes[0].set_xlabel('True Values', fontsize=11) axes[0].set_ylabel('Predicted Values', fontsize=11) axes[0].set_title('Reference Period', fontsize=12, fontweight='bold') axes[0].legend() axes[0].grid(alpha=0.3) axes[1].scatter(y_true_curr, y_pred_curr, alpha=0.5, s=20, color='#E74C3C') axes[1].plot([y_true_curr.min(), y_true_curr.max()], [y_true_curr.min(), y_true_curr.max()], 'r--', linewidth=2, label='Perfect Prediction') axes[1].set_xlabel('True Values', fontsize=11) axes[1].set_ylabel('Predicted Values', fontsize=11) axes[1].set_title('Current Period (with Drift)', fontsize=12, fontweight='bold') axes[1].legend() axes[1].grid(alpha=0.3) plt.tight_layout() plt.show() Metric Reference Current Change_% Status MAE 1.8116 2.2291 +23.05% ⚠️ WORSE RMSE 2.2558 2.7723 +22.90% ⚠️ WORSE MAPE 15.9465 15.7851 -1.01% ✓ OK R² 0.8654 0.8346 +3.56% ⚠️ WORSE MedAE 1.5484 1.8659 +20.50% ⚠️ WORSE sMAPE 14.7467 16.0373 +8.75% ⚠️ WORSE Рис. 2: Графики рассеяния значений истинных против предсказанных для reference и current периодов. Левый график показывает хорошую калибровку модели с точками близко к диагонали. Правый график демонстрирует систематическое смещение предсказаний после концептуального дрифта Код демонстрирует комплексный мониторинг регрессии с расчетом 6 метрик и детекцией их изменений. Функция calculate_regression_metrics включает защиту от граничных случаев: MAPE исключает нулевые истинные значения, sMAPE (Symmetric MAPE) работает корректно при y_true близких к нулю. MedAE (Median Absolute Error) устойчив к выбросам и ценен для задач, где редкие экстремальные ошибки не должны доминировать в оценке. R² показывает долю объясненной дисперсии: значение 0.8 означает, что модель объясняет 80% вариативности таргета. Directional Accuracy для прогнозных моделей Метрика Directional Accuracy (направленная верность) оценивает способность модели правильно предсказывать направление изменения целевой переменной. Она часто применяется в задачах прогнозирования временных рядов, где важнее определить знак изменения, чем его точную величину. import numpy as np import matplotlib.pyplot as plt def calculate_directional_accuracy(y_true, y_pred, y_prev): true_direction = np.sign(y_true - y_prev) pred_direction = np.sign(y_pred - y_prev) mask = true_direction != 0 return (true_direction[mask] == pred_direction[mask]).mean() # Синтетический временной ряд для примера np.random.seed(42) n_points = 500 time = np.arange(n_points) base_trend = 0.3 * time # Инициализация амплитуды и фазы amp_drift = np.ones(n_points) phase_drift = np.zeros(n_points) # Стабильность amp_drift[:125] = 1 phase_drift[:125] = 0 # Начало расхождения amp_drift[80:140] = np.linspace(1.0, 1.02, 60) phase_drift[80:140] = np.linspace(0, 0.05, 60) # Легкое расхождение amp_drift[140:190] = np.linspace(1.02, 1.05, 50) phase_drift[140:190] = np.linspace(0.05, 0.2, 50) # Усиливающийся дрифт amp_drift[190:240] = np.linspace(1.1, 1.1, 50) phase_drift[190:240] = np.linspace(0.2, 0.8, 50) # Ускоряющийся дрифт amp_drift[240:] = np.linspace(1.5, 1.5, n_points - 240) phase_drift[240:] = np.linspace(0.8, 2.5, n_points - 240) # Истинный ряд с плавными изменениями true_series = ( 100 + base_trend + 10 * amp_drift * np.sin(time / 10 + phase_drift) + np.random.normal(0, 1, n_points) ) # Модель застряла в старом паттерне pred_series = 100 + base_trend + 10 * np.sin(time / 10) + np.random.normal(0, 1, n_points) # Расчет Directional Accuracy window = 25 acc_values = [] for i in range(window, n_points): y_true = true_series[i-window+1:i+1] y_prev = true_series[i-window:i] y_pred = pred_series[i-window+1:i+1] acc_values.append(calculate_directional_accuracy(y_true, y_pred, y_prev)) # Визуализация fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True) # Истинный ряд и прогноз axes[0].plot(time, true_series, label='Истинные значения', color='black', linewidth=2) axes[0].plot(time, pred_series, label='Прогноз модели', color='red', alpha=0.7) axes[0].set_title('Постепенное появление дрифта из-за изменения фазы и амплитуды временного ряда', fontsize=13, fontweight='bold') axes[0].set_ylabel('Значение ряда') axes[0].legend() axes[0].grid(alpha=0.3) # Динамика Directional Accuracy axes[1].plot(time[window:], acc_values, color='#E67E22', linewidth=2) initial_level = np.mean(acc_values[:int(0.25 * len(acc_values))]) axes[1].axhline(initial_level, color='green', linestyle='--', alpha=0.8, label='Начальный уровень') axes[1].set_xlabel('Время') axes[1].set_ylabel('Directional Accuracy') axes[1].set_title('Динамика показателя Directional Accuracy', fontsize=12) axes[1].legend() axes[1].grid(alpha=0.3) plt.tight_layout() plt.show() Рис. 3: Постепенное снижение направленной верности (Directional Accuracy) ML-модели из-за дрифта, возникающего вследствие поэтапного изменения амплитуды и фазы временного ряда Метрика Directional Accuracy фокусируется на правильности предсказания знака изменения, игнорируя величину отклонения. Метрика превосходит MAE/RMSE в задачах, где решение принимается на основе направления движения: прогнозирование спроса (увеличить или уменьшить заказ), предсказание оттока клиентов (вырастет или снизится), энергопотребление (больше или меньше вчерашнего). Дополнительные метрики Accuracy_Up и Accuracy_Down выявляют асимметрию в качестве предсказаний. Это тоже полезные метрики, так как обученная модель может хорошо предсказывать рост, но плохо — падение. Различие между этими метриками более 15% сигнализирует о необходимости ребалансировки обучающих данных или корректировки функции потерь. Системы непрерывного мониторинга Мониторинг ML-моделей в продакшене требует автоматизированной инфраструктуры для сбора метрик, хранения временных рядов, визуализации трендов и настройки оповещений при обнаружении деградации. Система мониторинга интегрируется в конвейер инференса и функционирует в режиме реального времени. Архитектура мониторинга Если это ML-модели для финансовых рядов, то традиционно архитектура системы мониторинга включает четыре основных компонента: Сборщик метрик; Базу данных временных рядов (time-series database); Систему визуализации; Модуль оповещений (алертинга). Сборщик метрик функционирует как промежуточный слой (middleware) в конвейере инференса, логируя при каждом запросе входные признаки, предсказания и метаданные (временную метку, версию модели, идентификатор запроса), а также периодически (ежечасно или ежедневно) рассчитывая агрегированные метрики дрифта и качества. База данных временных рядов хранит метрики с привязкой ко времени. Стандартами индустрии являются InfluxDB и Prometheus, при этом InfluxDB оптимизирована для высокочастотной записи и аналитических запросов и поддерживает политики хранения (retention policies) для автоматического удаления устаревших данных. Визуализация реализуется с помощью Grafana: дашборды отображают тренды метрик, распределения признаков и матрицы ошибок во времени, при этом критически важные метрики выводятся на главный дашборд для постоянного мониторинга. Модуль алертинга формирует уведомления при превышении пороговых значений. При этом правила учитывают не только абсолютные значения метрик, но и их динамику: резкое падение за короткий период рассматривается как более критическое событие по сравнению с постепенной деградацией. Алертинг и пороговые значения Эффективная система алертинга обеспечивает баланс между ложными срабатываниями (false positives) и пропуском реальных проблем (false negatives), при этом многоуровневая настройка порогов для уровней WARNING и CRITICAL позволяет снизить эффект усталости от оповещений (alert fatigue). from dataclasses import dataclass from enum import Enum from typing import List, Dict, Callable from datetime import datetime import random import smtplib from email.mime.text import MIMEText class AlertSeverity(Enum): WARNING = "WARNING" CRITICAL = "CRITICAL" @dataclass class AlertRule: """Правило алертинга для метрики""" metric_name: str warning_threshold: float critical_threshold: float comparison: str # 'less_than', 'greater_than' window_size: int # количество последовательных нарушений для триггера @dataclass class Alert: """Объект алерта""" severity: AlertSeverity metric_name: str current_value: float threshold: float message: str timestamp: datetime class AlertingSystem: """ Система алертинга для ML-моделей """ def __init__(self, model_name: str): self.model_name = model_name self.alert_rules: Dict[str, AlertRule] = {} self.violation_counters: Dict[str, int] = {} self.alert_handlers: List[Callable] = [] def add_rule(self, rule: AlertRule): """Добавление правила алертинга""" self.alert_rules[rule.metric_name] = rule self.violation_counters[rule.metric_name] = 0 def add_handler(self, handler: Callable[[Alert], None]): """Добавление обработчика алертов (email, Slack, PagerDuty)""" self.alert_handlers.append(handler) def check_metrics(self, metrics: Dict[str, float]) -> List[Alert]: """ Проверка метрик на превышение порогов Returns: List[Alert]: список сгенерированных алертов """ alerts = [] for metric_name, metric_value in metrics.items(): if metric_name not in self.alert_rules: continue rule = self.alert_rules[metric_name] violated = self._check_threshold(metric_value, rule) if violated: self.violation_counters[metric_name] += 1 # Триггер алерта только после window_size последовательных нарушений if self.violation_counters[metric_name] >= rule.window_size: alert = self._create_alert(metric_name, metric_value, rule) alerts.append(alert) # Выполнение обработчиков for handler in self.alert_handlers: handler(alert) # Сброс счетчика после алерта self.violation_counters[metric_name] = 0 else: # Сброс счетчика при восстановлении метрики self.violation_counters[metric_name] = 0 return alerts def _check_threshold(self, value: float, rule: AlertRule) -> bool: """Проверка нарушения порогов""" if rule.comparison == 'less_than': return value < rule.critical_threshold or value < rule.warning_threshold elif rule.comparison == 'greater_than': return value > rule.critical_threshold or value > rule.warning_threshold return False def _create_alert(self, metric_name: str, value: float, rule: AlertRule) -> Alert: """Создание объекта алерта""" if rule.comparison == 'less_than': if value < rule.critical_threshold: severity = AlertSeverity.CRITICAL threshold = rule.critical_threshold else: severity = AlertSeverity.WARNING threshold = rule.warning_threshold else: if value > rule.critical_threshold: severity = AlertSeverity.CRITICAL threshold = rule.critical_threshold else: severity = AlertSeverity.WARNING threshold = rule.warning_threshold message = (f"[{severity.value}] Model '{self.model_name}': " f"{metric_name} = {value:.4f} " f"({'<' if rule.comparison == 'less_than' else '>'} " f"threshold {threshold:.4f})") return Alert( severity=severity, metric_name=metric_name, current_value=value, threshold=threshold, message=message, timestamp=datetime.now() ) # Обработчики алертов def console_alert_handler(alert: Alert): """Вывод алерта в консоль""" emoji = "🚨" if alert.severity == AlertSeverity.CRITICAL else "⚠️" print(f"{emoji} {alert.message}") def email_alert_handler(alert: Alert, recipients: List[str], smtp_config: Dict): """Отправка алерта по email (упрощенная версия)""" # В продакшене используйте надежную email библиотеку subject = f"ML Model Alert: {alert.severity.value}" body = f""" Model Alert Notification Severity: {alert.severity.value} Metric: {alert.metric_name} Current Value: {alert.current_value:.4f} Threshold: {alert.threshold:.4f} Timestamp: {alert.timestamp} Action Required: Investigate model performance degradation. """ print(f"📧 Email alert sent to {recipients}: {subject}") # В реальности: отправка через smtplib # Пример настройки системы алертинга def setup_alerting_system(): alerting = AlertingSystem(model_name="fraud_detector") # Правила для метрик качества (качество падает - bad) alerting.add_rule(AlertRule( metric_name='precision', warning_threshold=0.80, critical_threshold=0.75, comparison='less_than', window_size=3 )) alerting.add_rule(AlertRule( metric_name='recall', warning_threshold=0.75, critical_threshold=0.70, comparison='less_than', window_size=3 )) # Правило для дрифта (дрифт растет - bad) alerting.add_rule(AlertRule( metric_name='feature_psi', warning_threshold=0.15, critical_threshold=0.25, comparison='greater_than', window_size=2 )) # Добавление обработчиков alerting.add_handler(console_alert_handler) # alerting.add_handler(lambda alert: email_alert_handler( # alert, ['ml-team@company.com'], smtp_config # )) return alerting # Симуляция мониторинга с алертами alerting_system = setup_alerting_system() print("Симуляция мониторинга модели с алертингом:\n") for hour in range(10): # Симуляция деградации метрик metrics = { 'precision': 0.85 - hour * 0.015 + random.uniform(-0.01, 0.01), 'recall': 0.80 - hour * 0.012 + random.uniform(-0.01, 0.01), 'feature_psi': 0.05 + hour * 0.025 + random.uniform(0, 0.02) } print(f"Hour {hour+1}: Precision={metrics['precision']:.3f}, " f"Recall={metrics['recall']:.3f}, PSI={metrics['feature_psi']:.3f}") alerts = alerting_system.check_metrics(metrics) if not alerts: print(" ✓ All metrics within thresholds\n") else: print() Симуляция мониторинга модели с алертингом: Hour 1: Precision=0.855, Recall=0.793, PSI=0.064 ✓ All metrics within thresholds Hour 2: Precision=0.837, Recall=0.782, PSI=0.079 ✓ All metrics within thresholds Hour 3: Precision=0.813, Recall=0.769, PSI=0.104 ✓ All metrics within thresholds Hour 4: Precision=0.801, Recall=0.762, PSI=0.125 ✓ All metrics within thresholds Hour 5: Precision=0.785, Recall=0.759, PSI=0.167 ✓ All metrics within thresholds Hour 6: Precision=0.770, Recall=0.731, PSI=0.184 ⚠️ [WARNING] Model 'fraud_detector': feature_psi = 0.1841 (> threshold 0.1500) Hour 7: Precision=0.763, Recall=0.719, PSI=0.218 ⚠️ [WARNING] Model 'fraud_detector': precision = 0.7632 (< threshold 0.8000) Hour 8: Precision=0.754, Recall=0.719, PSI=0.229 ⚠️ [WARNING] Model 'fraud_detector': recall = 0.7187 (< threshold 0.7500) ⚠️ [WARNING] Model 'fraud_detector': feature_psi = 0.2293 (> threshold 0.1500) Hour 9: Precision=0.721, Recall=0.696, PSI=0.253 ✓ All metrics within thresholds Hour 10: Precision=0.709, Recall=0.692, PSI=0.277 🚨 [CRITICAL] Model 'fraud_detector': precision = 0.7093 (< threshold 0.7500) 🚨 [CRITICAL] Model 'fraud_detector': feature_psi = 0.2766 (> threshold 0.2500) Система AlertingSystem реализует подход на основе скользящего окна (window-based), при котором алерт срабатывает только после window_size последовательных нарушений порога, что позволяет уменьшить количество ложных срабатываний на единичные аномалии или шум в метриках. Двухуровневые пороги (WARNING/CRITICAL) позволяют ранжировать реакцию: WARNING требует мониторинга и расследования, CRITICAL — немедленного действия (остановка модели, откат на предыдущую версию, экстренное переобучение). Обработчики алертов (alert handlers) разделяют логику детекции и уведомлений, обеспечивая поддержку множества каналов доставки: консоль для разработки, email для команды, Slack для оперативного реагирования и PagerDuty для on-call инженеров. Декомпозиция логики детекции и уведомлений через обработчики алертов обеспечивает гибкость и масштабируемость системы. Разделение ответственности позволяет легко добавлять новые каналы оповещений или интегрировать алертинг с существующими DevOps-процессами, минимизируя риск пропуска критических событий и ускоряя реакцию команды на снижение качества модели. Заключение Дрифт данных и концептуальный дрифт — главные источники незаметной деградации моделей. Даже небольшие изменения распределения признаков или связи с таргетом могут со временем снизить точность прогнозов. Детекция дрифта с помощью PSI, KS-теста и JS-дивергенции позволяет выявлять отклонения в распределении признаков до критического падения качества. Метрики классификации и регрессии дают количественную оценку производительности, а метрика Directional accuracy обеспечивает контроль специфичных для прогнозирования временных рядов направлений предсказаний. Инфраструктура непрерывного мониторинга, интегрированная с базами данных, дашбордами и настроенным алертингом превращает пассивный подход в проактивный: модель перестает ломаться внезапно, а система заранее предупреждает о деградации. Правильно настроенный мониторинг экономит недели инженерного времени на отладку и защищает бизнес-метрики от незаметного падения качества прогнозов. ### Скорость и ускорение в последовательностях временных рядов. Методы расчета В анализе временных рядов скорость показывает, как быстро изменяется значение показателя во времени, а ускорение — насколько стремительно меняется сама скорость. По сути, это первая и вторая производные функции по времени, которые в дискретных данных приближенно рассчитывают как разности между последовательными значениями. В алгоритмическом трейдинге эти метрики применяются для детекции разворотов тренда, измерения волатильности режима и адаптации размера позиций. В риск-менеджменте вторая производная помогает выявить крайне важные сигналы: места, где скорость изменения цены резко меняется — сигнал потенциальной турбулентности рынка. Математические основы производных временных рядов Расчет производных дискретных временных рядов требует численных методов аппроксимации. Прямое применение конечных разностей усиливает шум, что делает результаты непригодными для принятия торговых решений. Регуляризированные подходы с предварительным сглаживанием обеспечивают баланс между точностью и стабильностью оценок. Для непрерывной функции y(t) первая производная определяется как предел отношения приращения функции к приращению аргумента: dy/dt = lim(Δt→0) [y(t + Δt) - y(t)] / Δt где: y(t) — значение функции в момент времени t; Δt — приращение времени; dy/dt — скорость изменения функции. Первая производная показывает направление и интенсивность изменения. Положительные значения указывают на рост, отрицательные — на падение. Вторая производная характеризует скорость изменения первой производной: d²y/dt² = lim(Δt→0) [dy/dt(t + Δt) - dy/dt(t)] / Δt где: d²y/dt² — ускорение изменения функции; dy/dt(t) — первая производная в момент t. Вторая производная отражает кривизну траектории. Положительное ускорение означает выпуклость вверх, отрицательное — выпуклость вниз. Дискретная аппроксимация Временные ряды представляют собой последовательность дискретных наблюдений y₁, y₂, ..., yₙ с шагом Δt. Производные аппроксимируются через конечные разности. Простейший вариант — первая прямая разность: Δyᵢ = (yᵢ₊₁ - yᵢ) / Δt где: yᵢ — значение ряда в точке i; Δt — шаг дискретизации (обычно равен 1 для равномерных рядов); Δyᵢ — аппроксимация первой производной. Эта формула дает смещенную оценку, так как использует только правую точку. Центральная разность точнее: Δyᵢ = (yᵢ₊₁ - yᵢ₋₁) / (2Δt) Центральная схема симметрична относительно точки i и обеспечивает погрешность второго порядка по Δt. Методы расчета первой производной Конечные разности Конечные разности — базовый инструмент численного дифференцирования. Выбор схемы зависит от требований к точности и доступности соседних точек. Forward difference использует текущую и следующую точку: Δyᵢ = (yᵢ₊₁ - yᵢ) / Δt Backward difference опирается на текущую и предыдущую: Δyᵢ = (yᵢ - yᵢ₋₁) / Δt Central difference усредняет информацию с обеих сторон: Δyᵢ = (yᵢ₊₁ - yᵢ₋₁) / (2Δt) Методы Forward и backward имеют погрешность O(Δt), central — O(Δt²). На практике central difference предпочтительнее для внутренних точек ряда, forward и backward применяются на границах. import numpy as np import matplotlib.pyplot as plt # Генерация синтетического ряда: тренд + шум np.random.seed(42) n = 200 t = np.arange(n) trend = 0.05 * t + 10 * np.sin(2 * np.pi * t / 50) noise = np.random.normal(0, 0.5, n) y = trend + noise # Конечные разности forward_diff = np.diff(y, prepend=y[0]) backward_diff = np.diff(y, append=y[-1]) central_diff = (np.roll(y, -1) - np.roll(y, 1)) / 2 central_diff[0] = forward_diff[0] central_diff[-1] = backward_diff[-1] # Визуализация fig, axes = plt.subplots(2, 1, figsize=(12, 8)) axes[0].plot(t, y, color='black', linewidth=1.5, label='Исходный ряд') axes[0].set_ylabel('Значение', fontsize=11) axes[0].legend(fontsize=10) axes[0].grid(alpha=0.3) axes[1].plot(t, forward_diff, color='#666666', alpha=0.6, label='Forward') axes[1].plot(t, backward_diff, color='#999999', alpha=0.6, label='Backward') axes[1].plot(t, central_diff, color='black', linewidth=1.5, label='Central') axes[1].set_xlabel('Время', fontsize=11) axes[1].set_ylabel('Первая производная', fontsize=11) axes[1].legend(fontsize=10) axes[1].grid(alpha=0.3) plt.tight_layout() plt.show() Рис. 1: Сравнение схем конечных разностей. Верхняя панель — анализируем временной ряд с трендом и шумом. Нижняя панель — первая производная, рассчитанная тремя методами. Central difference (черная линия) показывает более гладкую траекторию по сравнению с forward и backward схемами, и более пригодна для использования в торговых стратегиях Представленный выше код генерирует синусоидальный тренд с добавлением гауссовского шума, после чего вычисляет три типа разностных схем. Схемы Forward и backward дают смещенные оценки на границах интервала, central difference обеспечивает симметричную аппроксимацию. Результат показывает, что все три метода улавливают общую динамику, но central difference менее чувствительна к локальным флуктуациям благодаря усреднению. Выбор шага дискретизации Шаг Δt определяет временное разрешение производной. Для равномерных рядов с единичным шагом Δt = 1, и формулы упрощаются. Если данные имеют нерегулярный шаг, необходимо использовать фактические временные метки. Уменьшение шага повышает чувствительность к высокочастотным компонентам, однако усиливает влияние шума. Увеличение шага сглаживает результат, однако снижает разрешение по времени. В финансовых данных Δt обычно соответствует периоду бара: 1 день для дневных данных, 1 минута для внутридневных. При анализе высокочастотных стратегий выбор шага становится ключевым моментом — слишком мелкий шаг приводит к избыточной реакции на микроструктурный шум. Сглаживание и дифференцирование: фильтр Савицкого-Голея Фильтр Savitzky-Golay выполняет локальную полиномиальную регрессию в скользящем окне и вычисляет коэффициенты этого полинома для аппроксимации производных. Метод одновременно сглаживает данные и оценивает производную, что снижает влияние шума. Основные параметры: window_length — ширина окна (нечетное число), определяет степень сглаживания; polyorder — порядок полинома (обычно 2-4), контролирует гибкость аппроксимации; deriv — порядок производной (1 или 2). Фильтр работает следующим образом: Для каждой точки ряда строится полином степени polyorder по соседним точкам в окне window_length; Коэффициенты полинома используются для вычисления производной в центральной точке; После чего процедура повторяется для всех точек. from scipy.signal import savgol_filter # Генерация ряда: экспоненциальный тренд + шум np.random.seed(123) n = 300 t = np.linspace(0, 10, n) y_true = np.exp(0.1 * t) * np.sin(2 * t) noise = np.random.normal(0, 0.3, n) y = y_true + noise # Истинная первая производная dy_true = 0.1 * np.exp(0.1 * t) * np.sin(2 * t) + np.exp(0.1 * t) * 2 * np.cos(2 * t) # Прямая разность (без сглаживания) dy_raw = np.gradient(y, t) # Savitzky-Golay с разными параметрами dy_sg_11 = savgol_filter(y, window_length=11, polyorder=3, deriv=1, delta=t[1]-t[0]) dy_sg_21 = savgol_filter(y, window_length=21, polyorder=3, deriv=1, delta=t[1]-t[0]) dy_sg_51 = savgol_filter(y, window_length=51, polyorder=3, deriv=1, delta=t[1]-t[0]) # Визуализация fig, axes = plt.subplots(2, 1, figsize=(12, 8)) axes[0].plot(t, y, color='#CCCCCC', linewidth=1, label='Зашумленный ряд') axes[0].plot(t, y_true, color='black', linewidth=2, label='Истинный сигнал') axes[0].set_ylabel('Значение', fontsize=11) axes[0].legend(fontsize=10) axes[0].grid(alpha=0.3) axes[1].plot(t, dy_true, color='black', linewidth=2, label='Истинная производная', linestyle='--') axes[1].plot(t, dy_raw, color='#CCCCCC', alpha=0.7, label='Прямая разность') axes[1].plot(t, dy_sg_11, color='#FF6B6B', linewidth=1.5, label='SG window=11') axes[1].plot(t, dy_sg_21, color='#4ECDC4', linewidth=1.5, label='SG window=21') axes[1].plot(t, dy_sg_51, color='#45B7D1', linewidth=1.5, label='SG window=51') axes[1].set_xlabel('Время', fontsize=11) axes[1].set_ylabel('Первая производная', fontsize=11) axes[1].legend(fontsize=10) axes[1].grid(alpha=0.3) plt.tight_layout() plt.show() Рис. 2: Влияние параметров фильтра Савицкого-Голея на оценку 1-й производной. Верхняя панель — исходный сигнал с шумом. Нижняя панель — сравнение методов расчета производной. Прямая разность (светло-серая) непригодна из-за шума. Цветные линии - фильтры с разной шириной окна: чем шире окно, тем более гладкие линии Код демонстрирует влияние ширины окна на качество оценки производной. Прямая разность сильно зашумлена и непригодна для анализа. Savitzky-Golay с window=11 улавливает быстрые изменения, но сохраняет часть шума. Window=21 обеспечивает баланс между точностью и гладкостью. Window=51 дает самую гладкую траекторию, но начинает терять детали быстрых осцилляций. Выбор параметров зависит от характеристик данных: Для высокочастотных стратегий с быстрыми изменениями предпочтительны узкие окна (11-21) и низкий polyorder (2-3); Для позиционных стратегий подходят широкие окна (31-51) и polyorder=3-4; Экспериментально window_length ≈ 5-10% от длины ряда обеспечивает хорошие результаты. Методы на основе сплайнов Интерполяция сплайнами строит гладкую кривую через точки данных, после чего аналитически вычисляет производные этой кривой. В финансовом анализе наиболее популярны кубические сплайны благодаря непрерывности до второй производной. Кубический сплайн представляет функцию как кусочно-полиномиальную. Между каждой парой точек (xᵢ, yᵢ) и (xᵢ₊₁, yᵢ₊₁) строится полином третьей степени: Sᵢ(x) = aᵢ + bᵢ(x - xᵢ) + cᵢ(x - xᵢ)² + dᵢ(x - xᵢ)³ где: aᵢ, bᵢ, cᵢ, dᵢ — коэффициенты полинома на i-м интервале; x — точка, в которой вычисляется значение; Sᵢ(x) — значение сплайна на интервале [xᵢ, xᵢ₊₁]. Первая производная сплайна рассчитывается так: S'ᵢ(x) = bᵢ + 2cᵢ(x - xᵢ) + 3dᵢ(x - xᵢ)² Коэффициенты определяются из условий непрерывности функции, первой и второй производных в узлах интерполяции. from scipy.interpolate import CubicSpline # Генерация разреженного ряда с шумом np.random.seed(456) n_sparse = 30 t_sparse = np.sort(np.random.uniform(0, 10, n_sparse)) y_sparse = 5 * np.sin(t_sparse) + 0.5 * t_sparse + np.random.normal(0, 0.4, n_sparse) # Построение кубического сплайна cs = CubicSpline(t_sparse, y_sparse) # Плотная сетка для визуализации t_dense = np.linspace(0, 10, 500) y_spline = cs(t_dense) dy_spline = cs(t_dense, 1) # Первая производная # Прямая разность для сравнения dy_direct = np.gradient(y_sparse, t_sparse) # Визуализация fig, axes = plt.subplots(2, 1, figsize=(12, 8)) axes[0].scatter(t_sparse, y_sparse, color='black', s=50, zorder=3, label='Исходные точки') axes[0].plot(t_dense, y_spline, color='#666666', linewidth=1.5, label='Кубический сплайн') axes[0].set_ylabel('Значение', fontsize=11) axes[0].legend(fontsize=10) axes[0].grid(alpha=0.3) axes[1].scatter(t_sparse, dy_direct, color='#CCCCCC', s=50, label='Прямая разность', zorder=2) axes[1].plot(t_dense, dy_spline, color='black', linewidth=1.5, label='Производная сплайна') axes[1].set_xlabel('Время', fontsize=11) axes[1].set_ylabel('Первая производная', fontsize=11) axes[1].legend(fontsize=10) axes[1].grid(alpha=0.3) plt.tight_layout() plt.show() Рис. 3: Кубическая сплайн-интерполяция для разреженных данных. Верхняя панель — исходные точки и сплайн-кривая. Нижняя панель — первая производная. Сплайн обеспечивает гладкую непрерывную производную в отличие от зашумленной прямой разности Интерполяция сплайнами эффективна для разреженных данных или рядов с нерегулярным шагом. Метод создает гладкую функцию, которая точно проходит через исходные точки. Производная сплайна непрерывна и не требует дополнительного сглаживания. В отличие от Savitzky-Golay, сплайн не усредняет локальные флуктуации, а строит гладкую интерполяцию между точками. Недостаток сплайнов — чувствительность к выбросам. Единичная аномальная точка искажает сплайн в локальной окрестности. Поэтому перед применением метода необходима предобработка: детекция и удаление выбросов или замена их интерполированными значениями. Методы расчета второй производной Прямое дифференцирование разностей Вторая производная аппроксимируется через вторую центральную разность: d²yᵢ/dt² ≈ (yᵢ₊₁ - 2yᵢ + yᵢ₋₁) / Δt² где: yᵢ₋₁, yᵢ, yᵢ₊₁ — три последовательные точки ряда; Δt — шаг дискретизации; d²yᵢ/dt² — аппроксимация второй производной в точке i. Формула получается из двукратного применения центральной разности. Первая производная между точками i-1 и i: dy₁ = (yᵢ - yᵢ₋₁) / Δt Первая производная между точками i и i+1: dy₂ = (yᵢ₊₁ - yᵢ) / Δt Вторая производная — разность этих производных: d²y = (dy₂ - dy₁) / Δt = (yᵢ₊₁ - 2yᵢ + yᵢ₋₁) / Δt² # Генерация ряда: кусочно-квадратичная функция + шум np.random.seed(789) n = 200 t = np.linspace(0, 20, n) y_clean = np.piecewise(t, [t < 10, t >= 10], [lambda x: 0.5 * x**2, lambda x: 0.5 * 100 - 0.3 * (x - 10)**2]) noise = np.random.normal(0, 1.5, n) y = y_clean + noise # Первая производная: центральная разность dy = np.gradient(y, t) # Вторая производная: прямая двойная разность d2y_raw = np.gradient(dy, t) # Вторая производная через формулу dt = t[1] - t[0] d2y_formula = (np.roll(y, -1) - 2*y + np.roll(y, 1)) / dt**2 d2y_formula[0] = d2y_formula[1] d2y_formula[-1] = d2y_formula[-2] # Визуализация fig, axes = plt.subplots(3, 1, figsize=(12, 10)) axes[0].plot(t, y, color='#999999', linewidth=1, label='Зашумленный ряд') axes[0].plot(t, y_clean, color='black', linewidth=2, linestyle='--', label='Чистый сигнал') axes[0].set_ylabel('Значение', fontsize=11) axes[0].legend(fontsize=10) axes[0].grid(alpha=0.3) axes[1].plot(t, dy, color='black', linewidth=1.5, label='Первая производная') axes[1].axhline(0, color='#CCCCCC', linestyle='--', linewidth=1) axes[1].set_ylabel('dy/dt', fontsize=11) axes[1].legend(fontsize=10) axes[1].grid(alpha=0.3) axes[2].plot(t, d2y_raw, color='#666666', linewidth=1.5, label='Вторая производная (gradient)') axes[2].plot(t, d2y_formula, color='black', linewidth=1, alpha=0.7, label='Вторая производная (формула)') axes[2].axhline(0, color='#CCCCCC', linestyle='--', linewidth=1) axes[2].set_xlabel('Время', fontsize=11) axes[2].set_ylabel('d²y/dt²', fontsize=11) axes[2].legend(fontsize=10) axes[2].grid(alpha=0.3) plt.tight_layout() plt.show() Рис. 4: Усиление шума при расчете 2-й производной. Верхняя панель — исходный сигнал с шумом. Средняя панель — первая производная, где еще виден сигнал смены тренда в точке t=10. Нижняя панель — вторая производная полностью зашумлена, практически непригодна для анализа без дополнительного сглаживания Регуляризированные подходы Для получения пригодной оценки второй производной применяется предварительное сглаживание. Два основных подхода: двукратное применение сглаживающего фильтра или специализированные методы регуляризации. Для сглаживания обычно применяют фильтр Савицкого-Голея. Этот фильтр может напрямую вычислять вторую производную через параметр deriv=2. Альтернативный подход — последовательное сглаживание: сначала применить фильтр с deriv=0 для подавления шума, затем вычислить вторую производную сглаженного ряда. # Продолжение предыдущего примера с кусочно-квадратичной функцией # Метод 1: Прямой расчет второй производной через SG d2y_sg_direct = savgol_filter(y, window_length=31, polyorder=3, deriv=2, delta=dt) # Метод 2: Двухэтапное сглаживание y_smoothed = savgol_filter(y, window_length=31, polyorder=3, deriv=0) d2y_sg_twostep = savgol_filter(y_smoothed, window_length=21, polyorder=3, deriv=2, delta=dt) # Метод 3: Сглаживание + прямая разность y_smooth2 = savgol_filter(y, window_length=51, polyorder=3, deriv=0) d2y_smooth_diff = np.gradient(np.gradient(y_smooth2, t), t) # Истинная вторая производная (кусочно-постоянная) d2y_true = np.piecewise(t, [t < 10, t >= 10], [1.0, -0.6]) # Визуализация fig, axes = plt.subplots(2, 1, figsize=(12, 8)) axes[0].plot(t, y, color='#CCCCCC', linewidth=1, alpha=0.7, label='Зашумленный ряд') axes[0].plot(t, y_clean, color='black', linewidth=2, label='Чистый сигнал') axes[0].plot(t, y_smoothed, color='#666666', linewidth=1.5, linestyle='--', label='Сглаженный (SG)') axes[0].set_ylabel('Значение', fontsize=11) axes[0].legend(fontsize=10) axes[0].grid(alpha=0.3) axes[1].plot(t, d2y_true, color='black', linewidth=2, linestyle='--', label='Истинная d²y/dt²') axes[1].plot(t, d2y_sg_direct, color='#FF6B6B', linewidth=1.5, label='SG прямой (deriv=2)') axes[1].plot(t, d2y_sg_twostep, color='#4ECDC4', linewidth=1.5, label='SG двухэтапный') axes[1].plot(t, d2y_smooth_diff, color='#45B7D1', linewidth=1.5, label='Сглаживание + gradient') axes[1].axhline(0, color='#CCCCCC', linestyle='--', linewidth=1) axes[1].set_xlabel('Время', fontsize=11) axes[1].set_ylabel('d²y/dt²', fontsize=11) axes[1].legend(fontsize=10) axes[1].grid(alpha=0.3) plt.tight_layout() plt.show() Рис. 5: Сравнение методов регуляризации для 2-й производной. Верхняя панель — исходный, чистый и сглаженный ряды. Нижняя панель — оценки второй производной разными методами. Прямой Savitzky-Golay с deriv=2 наиболее точно восстанавливает структуру (константы 1.0 и -0.6), но размывает точку перелома в t=10. Двухэтапное сглаживание сохраняет резкость перехода ценой большей дисперсии оценки При фильтрации нам приходится идти на компромисс между чувствительностью и стабильностью. Прямое вычисление второй производной через Savitzky-Golay (deriv=2) дает наименее зашумленный результат, но сглаживает резкие переходы. Двухэтапное сглаживание обеспечивает баланс: первый этап подавляет высокочастотный шум, второй извлекает кривизну. Метод "сглаживание + gradient" самый консервативный, однако теряет детали. Другой метод регуляризации Total Variation (TV) минимизирует сумму абсолютных изменений производной при сохранении близости к исходным данным. Метод эффективен для рядов с кусочно-гладкой структурой, где вторая производная имеет разрывы. Задача оптимизации: minimize: Σᵢ(yᵢ - xᵢ)² + λ Σᵢ|xᵢ₊₁ - xᵢ| где: yᵢ — исходные данные; xᵢ — сглаженный ряд; λ — параметр регуляризации, контролирует степень сглаживания. Первое слагаемое обеспечивает близость к данным, второе подавляет резкие скачки. После получения сглаженного ряда x вычисляется вторая производная стандартными методами. from scipy.optimize import minimize def tv_regularization(y, lambda_tv): """Total Variation регуляризация""" n = len(y) def objective(x): data_term = np.sum((y - x)**2) tv_term = np.sum(np.abs(np.diff(x))) return data_term + lambda_tv * tv_term result = minimize(objective, y, method='L-BFGS-B') return result.x # Применение TV регуляризации с разными lambda y_tv_001 = tv_regularization(y, lambda_tv=1.0) y_tv_01 = tv_regularization(y, lambda_tv=10.0) y_tv_1 = tv_regularization(y, lambda_tv=100.0) # Вторая производная после TV d2y_tv_001 = np.gradient(np.gradient(y_tv_001, t), t) d2y_tv_01 = np.gradient(np.gradient(y_tv_01, t), t) d2y_tv_1 = np.gradient(np.gradient(y_tv_1, t), t) # Визуализация fig, axes = plt.subplots(2, 1, figsize=(12, 8)) axes[0].plot(t, y, color='#CCCCCC', linewidth=1, alpha=0.6, label='Зашумленный ряд') axes[0].plot(t, y_clean, color='black', linewidth=2, linestyle='--', label='Чистый сигнал') axes[0].plot(t, y_tv_001, color='#FF6B6B', linewidth=1.5, label='TV λ=1.0') axes[0].plot(t, y_tv_01, color='#4ECDC4', linewidth=1.5, label='TV λ=10.0') axes[0].plot(t, y_tv_1, color='#45B7D1', linewidth=1.5, label='TV λ=100.0') axes[0].set_ylabel('Значение', fontsize=11) axes[0].legend(fontsize=10) axes[0].grid(alpha=0.3) axes[1].plot(t, d2y_true, color='black', linewidth=2, linestyle='--', label='Истинная d²y/dt²') axes[1].plot(t, d2y_tv_001, color='#FF6B6B', linewidth=1.5, label='TV λ=1.0') axes[1].plot(t, d2y_tv_01, color='#4ECDC4', linewidth=1.5, label='TV λ=10.0') axes[1].plot(t, d2y_tv_1, color='#45B7D1', linewidth=1.5, label='TV λ=100.0') axes[1].axhline(0, color='#CCCCCC', linestyle='--', linewidth=1) axes[1].set_xlabel('Время', fontsize=11) axes[1].set_ylabel('d²y/dt²', fontsize=11) axes[1].legend(fontsize=10) axes[1].grid(alpha=0.3) plt.tight_layout() plt.show() Рис. 6: Регуляризация рядов методом Total Variation для сохранения разрывов 2-й производной. Верхняя панель — эффект различных значений параметра λ на сглаживание. Малые λ следуют данным, большие дают сильное сглаживание. Нижняя панель — восстановленная вторая производная. TV с λ=1.0 лучше других сохраняет резкий скачок в точке t=10, характерный для кусочно-гладких функций TV регуляризация сохраняет резкие переходы лучше, чем полиномиальное сглаживание: При λ=1.00 метод близко следует данным, сохраняя шум; λ=10.0 обеспечивает баланс: подавляет флуктуации, но сохраняет структурные изломы; λ=100.0 дает чрезмерное сглаживание, теряя детали кривизны. Оптимальное значение λ зависит от соотношения сигнал/шум: для данных с высоким шумом используются большие значения, для относительно чистых рядов — малые. Практические аспекты и подводные камни Дифференцирование усиливает высокочастотные компоненты сигнала. Если исходный ряд содержит шум с дисперсией σ², дисперсия первой производной пропорциональна σ²/Δt², второй — σ²/Δt⁴. Это объясняет катастрофическое ухудшение качества при прямом расчете второй производной. Работа с шумом в производных Соотношение сигнал/шум (Signal-to-Noise Ratio, SNR) определяет выбор метода. SNR измеряется как отношение мощности сигнала к мощности шума: SNR = 10 log₁₀(P_signal / P_noise) где: P_signal — дисперсия полезного сигнала; P_noise — дисперсия шума; SNR — выражается в децибелах (dB). При SNR > 20 dB прямые методы (центральная разность, Savitzky-Golay с узким окном) дают приемлемые результаты. При SNR < 10 dB необходима агрессивная регуляризация: широкие окна сглаживания (51+), Total Variation, или предварительная фильтрация низких частот. # Анализ влияния SNR на качество производных np.random.seed(100) n = 200 t = np.linspace(0, 10, n) signal = 5 * np.sin(2 * np.pi * t / 5) # Генерация рядов с разным SNR snr_levels = [30, 15, 5] # dB noise_levels = [] for snr_db in snr_levels: signal_power = np.mean(signal**2) noise_power = signal_power / (10**(snr_db / 10)) noise_std = np.sqrt(noise_power) noise_levels.append(noise_std) # Визуализация fig, axes = plt.subplots(3, 2, figsize=(14, 10)) for idx, (snr_db, noise_std) in enumerate(zip(snr_levels, noise_levels)): y_noisy = signal + np.random.normal(0, noise_std, n) # Первая производная: прямая и сглаженная dy_raw = np.gradient(y_noisy, t) dy_smooth = savgol_filter(y_noisy, window_length=31, polyorder=3, deriv=1, delta=t[1]-t[0]) # Истинная производная dy_true = (2 * np.pi / 5) * 5 * np.cos(2 * np.pi * t / 5) # Левая колонка: исходный ряд axes[idx, 0].plot(t, signal, color='black', linewidth=2, linestyle='--', label='Сигнал') axes[idx, 0].plot(t, y_noisy, color='#999999', linewidth=1, alpha=0.7, label=f'SNR={snr_db} dB') axes[idx, 0].set_ylabel('Значение', fontsize=10) axes[idx, 0].legend(fontsize=9) axes[idx, 0].grid(alpha=0.3) # Правая колонка: производная axes[idx, 1].plot(t, dy_true, color='black', linewidth=2, linestyle='--', label='Истинная') axes[idx, 1].plot(t, dy_raw, color='#CCCCCC', linewidth=1, alpha=0.6, label='Прямая') axes[idx, 1].plot(t, dy_smooth, color='#666666', linewidth=1.5, label='SG window=31') axes[idx, 1].set_ylabel('dy/dt', fontsize=10) axes[idx, 1].legend(fontsize=9) axes[idx, 1].grid(alpha=0.3) axes[2, 0].set_xlabel('Время', fontsize=10) axes[2, 1].set_xlabel('Время', fontsize=10) plt.tight_layout() plt.show() Рис. 7: Влияние соотношения сигнал/шум на оценку производной. Три строки соответствуют SNR 30, 15 и 5 dB. Левая колонка — зашумленные ряды. Правая колонка — оценки первой производной. При высоком SNR прямая разность работает удовлетворительно. При низком SNR только агрессивное сглаживание дает приемлемый результат Код демонстрирует деградацию качества производной при снижении SNR: При SNR=30 dB прямая разность все еще улавливает структуру, хотя и зашумлена; При SNR=15 dB прямой метод непригоден, но Savitzky-Golay восстанавливает форму; При SNR=5 dB даже сглаженная оценка отклоняется от истины, требуется более широкое окно или предварительная фильтрация. Выбор параметров сглаживания Параметры фильтра определяют компромисс между точностью и стабильностью. Узкие окна сохраняют детали, но пропускают шум. Широкие окна подавляют шум, но размывают быстрые изменения. Я рекомендую начать со следующих настроек для фильтра Savitzky-Golay: Дневные данные (позиционные стратегии): window_length = 21-51, polyorder = 3; Внутридневные данные (интрадей): window_length = 11-21, polyorder = 2-3; Тиковые данные (HFT): window_length = 5-11, polyorder = 2. Window_length должен быть нечетным и больше polyorder. Для адаптации к изменяющейся динамике используются скользящие окна переменной ширины: узкие в периоды низкой волатильности, широкие — в периоды высокой. Обработка пропусков и выбросов Пропуски в данных нарушают равномерность шага дискретизации, что искажает производные. Выбросы создают ложные всплески скорости и ускорения. Поэтому для получения корректных оценок лучше делать предобработку данных. Стратегии обработки пропусков: Forward-fill: заполнение последним известным значением. Подходит для краткосрочных пропусков (1-2 точки). Создает плато с нулевой производной; Линейная интерполяция: построение прямой между соседними точками. Предпочтительна для умеренных пропусков (3-5 точек). Производная принимает постоянное значение; Сплайн-интерполяция: гладкое заполнение через кубические сплайны. Оптимальна для длинных пропусков (5+ точек). Производная непрерывна и реалистична. Выбросы определяются как точки, отклоняющиеся от локального тренда более чем на k стандартных отклонений (обычно k=3-5). Модифицированный Z-score учитывает медиану вместо среднего: Mᵢ = 0.6745 · (yᵢ - median(y)) / MAD где: MAD = median(|yᵢ - median(y)|) — медианное абсолютное отклонение; Mᵢ — модифицированный Z-score; Точки с |Mᵢ| > 3.5 считаются выбросами. После обнаружения выбросы заменяются интерполированными значениями или удаляются с последующим заполнением пропусков. from scipy.interpolate import interp1d # Генерация ряда с пропусками и выбросами np.random.seed(200) n = 150 t_full = np.arange(n) y_full = 10 + 0.05 * t_full + 3 * np.sin(2 * np.pi * t_full / 30) + np.random.normal(0, 0.3, n) # Добавление пропусков missing_idx = np.random.choice(range(20, 130), 15, replace=False) t_missing = np.delete(t_full, missing_idx) y_missing = np.delete(y_full, missing_idx) # Добавление выбросов outlier_idx = np.random.choice(range(len(y_missing)), 5, replace=False) y_outliers = y_missing.copy() y_outliers[outlier_idx] += np.random.choice([-1, 1], 5) * np.random.uniform(5, 8, 5) # Детекция выбросов через Modified Z-score def detect_outliers_mod_z(y, threshold=3.5): median_y = np.median(y) mad = np.median(np.abs(y - median_y)) modified_z_scores = 0.6745 * (y - median_y) / (mad + 1e-8) return np.abs(modified_z_scores) > threshold outliers_mask = detect_outliers_mod_z(y_outliers) y_cleaned = y_outliers.copy() y_cleaned[outliers_mask] = np.nan # Интерполяция пропусков и выбросов valid_mask = ~np.isnan(y_cleaned) interp_func = interp1d(t_missing[valid_mask], y_cleaned[valid_mask], kind='cubic', fill_value='extrapolate') y_interpolated = interp_func(t_missing) # Производные до и после очистки dy_raw = np.gradient(y_outliers, t_missing) dy_cleaned = np.gradient(y_interpolated, t_missing) # Визуализация fig, axes = plt.subplots(2, 1, figsize=(12, 8)) axes[0].plot(t_full, y_full, color='#CCCCCC', linewidth=1, alpha=0.5, label='Полный ряд') axes[0].scatter(t_missing, y_outliers, color='black', s=30, label='Данные с пропусками и выбросами', zorder=3) axes[0].scatter(t_missing[outliers_mask], y_outliers[outliers_mask], color='red', s=80, marker='x', linewidths=2, label='Выбросы', zorder=4) axes[0].plot(t_missing, y_interpolated, color='#666666', linewidth=1.5, linestyle='--', label='После очистки и интерполяции') axes[0].set_ylabel('Значение', fontsize=11) axes[0].legend(fontsize=10) axes[0].grid(alpha=0.3) axes[1].plot(t_missing, dy_raw, color='#CCCCCC', linewidth=1.5, alpha=0.7, label='Производная (с выбросами)') axes[1].plot(t_missing, dy_cleaned, color='black', linewidth=1.5, label='Производная (после очистки)') axes[1].set_xlabel('Время', fontsize=11) axes[1].set_ylabel('dy/dt', fontsize=11) axes[1].legend(fontsize=10) axes[1].grid(alpha=0.3) plt.tight_layout() plt.show() Рис. 8: Влияние пропусков и выбросов на оценку производной. Верхняя панель — исходный ряд с пропусками (отсутствующие точки) и выбросами (красные крестики). Пунктирная линия — ряд после детекции выбросов и кубической интерполяции. Нижняя панель — первая производная. Выбросы генерируют ложные всплески скорости. Предобработка данных устраняет артефакты и восстанавливает гладкую траекторию Выбросы создают острые пики в производной, которые интерпретируются как резкие изменения скорости. После удаления выбросов и интерполяции производная становится гладкой и отражает реальную динамику ряда. Модифицированный Z-score надежнее классического, так как медиана устойчива к экстремальным значениям. Скорость и ускорение рядов: применение в алгоритмическом трейдинге Анализ скорости и ускорения временных рядов позволяет глубже понять динамику рынка — определить моменты, когда тренд набирает силу или, наоборот, начинает замедляться. Эти показатели формируют основу для более точных торговых сигналов, фильтрации ложных пробоев и оценки импульсности движения цены. Использование производных помогает алгоритмам принимать решения не только на основании текущих уровней, но и на основе изменения динамики самой тенденции, что делает такие модели особенно ценными для краткосрочных стратегий и высокочастотного трейдинга. Детекция смены тренда Комбинация 1-й и 2-й производной позволяет идентифицировать точки разворота тренда. Первая производная показывает направление движения, вторая — изменение скорости. Локальный экстремум первой производной (dy/dt = 0) при смене знака второй производной (d²y/dt²) сигнализирует о развороте. Условия для определения разворота: Вершина (разворот вниз): dy/dt ≈ 0, d²y/dt² < 0 Дно (разворот вверх): dy/dt ≈ 0, d²y/dt² > 0 На практике dy/dt редко достигает нуля из-за шума. Используется пороговое значение: |dy/dt| < ε, где ε зависит от масштаба данных. import pandas as pd import yfinance as yf from datetime import datetime, timedelta # Загрузка котировок через yfinance end_date = datetime.now() start_date = end_date - timedelta(days=365) ticker = yf.Ticker("BA") # Boeing Co. data = ticker.history(start=start_date, end=end_date) # Проверка на MultiIndex if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(0) price = data['Close'].values t_trading = np.arange(len(price)) # Расчет первой и второй производной с сглаживанием window = 21 poly = 3 dy = savgol_filter(price, window_length=window, polyorder=poly, deriv=1) d2y = savgol_filter(price, window_length=window, polyorder=poly, deriv=2) # Детекция разворотов threshold_dy = np.std(dy) * 0.3 # Порог для "близко к нулю" peaks = (np.abs(dy) < threshold_dy) & (d2y < -np.std(d2y) * 0.5) troughs = (np.abs(dy) < threshold_dy) & (d2y > np.std(d2y) * 0.5) # Визуализация fig, axes = plt.subplots(3, 1, figsize=(14, 10)) # Цена axes[0].plot(t_trading, price, color='black', linewidth=1.5, label='Цена закрытия Boeing') axes[0].scatter(t_trading[peaks], price[peaks], color='red', s=100, marker='v', label='Потенциальные вершины', zorder=5) axes[0].scatter(t_trading[troughs], price[troughs], color='green', s=100, marker='^', label='Потенциальные впадины', zorder=5) axes[0].set_ylabel('Цена (USD)', fontsize=11) axes[0].legend(fontsize=10) axes[0].grid(alpha=0.3) # Первая производная axes[1].plot(t_trading, dy, color='#666666', linewidth=1.5, label='dy/dt (скорость)') axes[1].axhline(0, color='black', linestyle='--', linewidth=1) axes[1].axhline(threshold_dy, color='red', linestyle=':', linewidth=1, alpha=0.5) axes[1].axhline(-threshold_dy, color='red', linestyle=':', linewidth=1, alpha=0.5) axes[1].set_ylabel('dy/dt', fontsize=11) axes[1].legend(fontsize=10) axes[1].grid(alpha=0.3) # Вторая производная axes[2].plot(t_trading, d2y, color='black', linewidth=1.5, label='d²y/dt² (ускорение)') axes[2].axhline(0, color='#666666', linestyle='--', linewidth=1) axes[2].scatter(t_trading[peaks], d2y[peaks], color='red', s=80, marker='v', zorder=5) axes[2].scatter(t_trading[troughs], d2y[troughs], color='green', s=80, marker='^', zorder=5) axes[2].set_xlabel('Дни с начала периода', fontsize=11) axes[2].set_ylabel('d²y/dt²', fontsize=11) axes[2].legend(fontsize=10) axes[2].grid(alpha=0.3) plt.tight_layout() plt.show() # Статистика по сигналам print(f"Обнаружено потенциальных вершин: {np.sum(peaks)}") print(f"Обнаружено потенциальных впадин: {np.sum(troughs)}") Обнаружено потенциальных вершин: 18 Обнаружено потенциальных впадин: 14 Рис. 9: Детекция разворотов тренда через производные цены акций Boeing. Верхняя панель — дневные котировки с отмеченными потенциальными вершинами (красные треугольники вниз) и впадинами (зеленые треугольники вверх). Средняя панель — первая производная с пороговой зоной (красные пунктирные линии), где скорость считается близкой к нулю. Нижняя панель — вторая производная с выделенными точками разворота. Отрицательное ускорение в точках вершин указывает на замедление роста, положительное на впадинах — на ослабление падения Алгоритм определяет точки, в которых скорость изменения цены стремится к нулю, а ускорение указывает на возможное начало нового движения. Однако не каждый такой сигнал означает существенный разворот — часть из них отражает лишь краткосрочные коррекции. Чтобы отфильтровать ложные сигналы, используется дополнительное условие: цена после предполагаемого разворота должна измениться как минимум на 2–3% от текущего уровня в течение ближайших 5–10 дней. Измерение волатильности режима Вторая производная количественно описывает изменение скорости движения цены. Высокие абсолютные значения d²y/dt² указывают на резкие ускорения или торможения — характерный признак повышенной волатильности. Метрика локальной волатильности рассчитывается через вторую производную: σ_local = √(Σᵢ₌ₜ₋ₙᵗ (d²yᵢ/dt²)²) / n где: n — размер окна для расчета (например, 20 дней); t — текущий момент времени; σ_local — локальная волатильность режима. Этот показатель отражает турбулентность рынка независимо от направления движения. Рост σ_local сигнализирует о входе в режим высокой неопределенности, что требует снижения размера позиций или расширения стоп-лоссов. # Продолжение примера с Boeing # Расчет локальной волатильности через d2y window_vol = 20 local_vol = np.array([np.sqrt(np.mean(d2y[max(0, i-window_vol):i+1]**2)) for i in range(len(d2y))]) # Нормализация для сравнения с классической волатильностью returns = np.diff(price) / price[:-1] rolling_std = np.array([np.std(returns[max(0, i-window_vol):i+1]) for i in range(len(returns))]) rolling_std = np.append(rolling_std[0], rolling_std) # Выравнивание длины # Корреляция между метриками corr = np.corrcoef(local_vol[window_vol:], rolling_std[window_vol:])[0, 1] # Визуализация fig, axes = plt.subplots(3, 1, figsize=(14, 10)) axes[0].plot(t_trading, price, color='black', linewidth=1.5) axes[0].set_ylabel('Цена TSM (USD)', fontsize=11) axes[0].grid(alpha=0.3) axes[1].plot(t_trading, local_vol, color='#666666', linewidth=1.5, label='Волатильность через d²y/dt²') axes[1].set_ylabel('σ_local', fontsize=11) axes[1].legend(fontsize=10) axes[1].grid(alpha=0.3) axes[2].plot(t_trading, rolling_std, color='black', linewidth=1.5, label=f'Классическая волатильность (корр={corr:.2f})') axes[2].set_xlabel('Дни с начала периода', fontsize=11) axes[2].set_ylabel('Rolling Std', fontsize=11) axes[2].legend(fontsize=10) axes[2].grid(alpha=0.3) plt.tight_layout() plt.show() # Вывод периодов высокой волатильности high_vol_threshold = np.percentile(local_vol, 75) high_vol_periods = local_vol > high_vol_threshold print(f"Процент времени в режиме высокой волатильности: {np.mean(high_vol_periods)*100:.1f}%") Процент времени в режиме высокой волатильности: 24.9% Рис. 10: Сравнение волатильности через вторую производную и классическую метрику. Верхняя панель — котировки Boeing. Средняя панель — локальная волатильность σ_local, рассчитанная через d²y/dt². Нижняя панель — классическая волатильность (скользящее стандартное отклонение доходностей). Всплески σ_local часто опережают рост классической волатильности, что позволяет превентивно корректировать риски В своих стратегиях я использую данный подход для динамической адаптации позиции обратно пропорционально σ_local. Если текущая волатильность превышает медианную в 1.5 раза, размер позиции сокращается вдвое. Если волатильность ниже медианной, позиция увеличивается на 20-30% от базовой. Фильтрация ложных пробоев через ускорение Пробой уровня поддержки или сопротивления часто оказывается ложным — цена возвращается в прежний диапазон через несколько баров. Вторая производная помогает отфильтровать слабые пробои от истинных прорывов: устойчивое движение после пробоя сопровождается положительным ускорением в направлении пробоя. Логика фильтра: Истинный пробой вверх: цена выше уровня, d²y/dt² > 0 (ускорение роста); Истинный пробой вниз: цена ниже уровня, d²y/dt² < 0 (ускорение падения); Ложный пробой: пробой уровня при отрицательном ускорении в направлении движения. Стратегия входит в позицию только при подтверждении через ускорение, избегая преждевременных сигналов. import yfinance as yf import pandas as pd import numpy as np import matplotlib.pyplot as plt from scipy.signal import savgol_filter # Загрузка внутридневных данных data = yf.download( tickers="BTC-USD", period="7d", interval="5m", progress=False ) # Берем только последние 288 баров (2 дня по 5-минуткам) data = data.iloc[-288:] # Проверка на MultiIndex и извлечение цены if isinstance(data.columns, pd.MultiIndex): price = data.loc[:, ('Close', 'BTC-USD')].values else: price = data['Close'].values t_trading = np.arange(len(price)) # Расчет поддержки и сопротивления lookback = 20 resistance = np.array([ np.max(price[max(0, i-lookback):i]) if i > 0 else price[0] for i in range(len(price)) ]) support = np.array([ np.min(price[max(0, i-lookback):i]) if i > 0 else price[0] for i in range(len(price)) ]) # Первая и вторая производная window = 7 dy = savgol_filter(price, window_length=window, polyorder=3, deriv=1) d2y = savgol_filter(price, window_length=window, polyorder=3, deriv=2) # Порог ускорения для фильтрации мелких шумов threshold_d2 = 0.01 # Детекция пробоев breakout_up_raw = price > resistance * 1.0002 breakout_up_conf = breakout_up_raw & (d2y > threshold_d2) breakout_down_raw = price < support * 0.9998 breakout_down_conf = breakout_down_raw & (d2y < -threshold_d2) print("Пробоев вверх (raw):", np.sum(breakout_up_raw)) print("Пробоев вверх (подтвержд):", np.sum(breakout_up_conf)) print("Пробоев вниз (raw):", np.sum(breakout_down_raw)) print("Пробоев вниз (подтвержд):", np.sum(breakout_down_conf)) # Оценка успешности пробоев def evaluate_breakout(price, breakout_idx, horizon=10, threshold=0.001): results = [] for idx in breakout_idx: if idx + horizon >= len(price): continue future_return = (price[idx + horizon] - price[idx]) / price[idx] results.append(future_return > threshold) return results idx_up_raw = np.where(breakout_up_raw)[0] idx_up_conf = np.where(breakout_up_conf)[0] idx_down_raw = np.where(breakout_down_raw)[0] idx_down_conf = np.where(breakout_down_conf)[0] success_up_raw = evaluate_breakout(price, idx_up_raw) success_up_conf = evaluate_breakout(price, idx_up_conf) success_down_raw = evaluate_breakout(price, idx_down_raw) success_down_conf = evaluate_breakout(price, idx_down_conf) print_stats(success_up_raw, success_up_conf, "Пробои вверх") print_stats(success_down_raw, success_down_conf, "Пробои вниз") # Визуализация fig, axes = plt.subplots(3, 1, figsize=(16, 10)) axes[0].plot(t_trading, price, color='black', label='Цена') axes[0].plot(t_trading, resistance, '--', color='gray', label='Сопротивление') axes[0].plot(t_trading, support, '--', color='gray', label='Поддержка') axes[0].scatter(idx_up_raw, price[idx_up_raw], color='darkgray', label='Пробои вверх (raw)') axes[0].scatter(idx_up_conf, price[idx_up_conf], color='green', label='Пробои вверх (подтвержд)') axes[0].scatter(idx_down_raw, price[idx_down_raw], color='darkgray', label='Пробои вниз (raw)') axes[0].scatter(idx_down_conf, price[idx_down_conf], color='red', label='Пробои вниз (подтвержд)') axes[0].set_ylabel('Цена') axes[0].legend() axes[0].grid(alpha=0.3) axes[1].plot(t_trading, dy, color='black', label='dy/dt') axes[1].axhline(0, color='gray', linestyle='--') axes[1].set_ylabel('dy/dt') axes[1].legend() axes[1].grid(alpha=0.3) axes[2].plot(t_trading, d2y, color='black', label='d²y/dt²') axes[2].axhline(0, color='gray', linestyle='--') axes[2].set_ylabel('d²y/dt²') axes[2].legend() axes[2].grid(alpha=0.3) plt.tight_layout() plt.show() Рис. 11: Фильтрация пробоев уровней поддержки / сопротивления через 2-ю производную на примере 5М котировок Bitcoin. Верхняя панель - цена и динамический уровень сопротивления. Серые круги - все пробои, цветные - пробои с положительным ускорением. Средняя панель: 1-я производная показывает направление движения. Нижняя панель: 2-я производная в моменты пробоя. Видно, что не все пробои сопровождаются положительным ускорением: часть происходит при d²y/dt² < 0, что указывает на слабый импульс Фильтр через ускорение отсекает до 80% ложных пробоев в зависимости от рыночного режима и настроек window и threshold_d2. Неподтвержденные пробои часто происходят на фоне замедления роста (d²y/dt² < 0) — цена формально преодолевает уровень, но импульс уже иссякает. Подтвержденные пробои имеют win rate на 15-25 процентных пунктов выше благодаря требованию положительного ускорения. Адаптивное управление стоп-лоссом Фиксированный процентный стоп-лосс не учитывает текущую динамику цены. В периоды высокой волатильности узкий стоп срабатывает от случайного шума. В периоды низкой волатильности широкий стоп избыточен. Первая производная позволяет адаптировать расстояние стопа к скорости движения. Базовая модель адаптивного стопа: stop_distance = base_distance + k · |dy/dt| где: base_distance — минимальное расстояние стопа (например, 1-2%); k — коэффициент чувствительности (подбирается эмпирически); |dy/dt| — абсолютная скорость изменения цены. Когда цена быстро движется (высокая |dy/dt|), стоп расширяется, давая позиции пространство для флуктуаций. Когда движение медленное, стоп затягивается, фиксируя прибыль при первых признаках разворота. import yfinance as yf import numpy as np import matplotlib.pyplot as plt from scipy.signal import savgol_filter # Загрузка данных ticker = "BTC-USD" data = yf.download( tickers=ticker, period="7d", # последние 7 дней interval="15m", # 15-минутный интервал progress=False ) # Извлечение цены закрытия if isinstance(data.columns, pd.MultiIndex): price = data[('Close', ticker)].values else: price = data['Close'].values t_trading = np.arange(len(price)) # Расчет производной (скорости изменения) window = 5 dy = savgol_filter(price, window_length=window, polyorder=3, deriv=1) dy_normalized = np.abs(dy) / price # нормализованная скорость # Параметры стратегии base_stop_pct = 0.02 # 2% фиксированный стоп k_adaptive = 0.5 # коэффициент адаптации entry_idx = 260 # точка входа на 260-й бар entry_price = price[entry_idx] # Фиксированный стоп fixed_stop = entry_price * (1 - base_stop_pct) fixed_stop_array = np.full(len(price), fixed_stop) # Адаптивный стоп adaptive_stop = np.zeros(len(price)) adaptive_stop[entry_idx] = entry_price * (1 - base_stop_pct) # стартовое значение for i in range(entry_idx + 1, len(price)): adaptive_distance = base_stop_pct + k_adaptive * dy_normalized[i] adaptive_distance = np.clip(adaptive_distance, 0.015, 0.10) # ограничение 1.5%-10% new_stop = price[i] * (1 - adaptive_distance) adaptive_stop[i] = max(new_stop, adaptive_stop[i-1]) # Проверка срабатывания стопов fixed_hit_idx = np.where(price[entry_idx:] < fixed_stop)[0] adaptive_hit_idx = np.where(price[entry_idx:] < adaptive_stop[entry_idx:])[0] fixed_exit = entry_idx + fixed_hit_idx[0] if len(fixed_hit_idx) > 0 else len(price)-1 adaptive_exit = entry_idx + adaptive_hit_idx[0] if len(adaptive_hit_idx) > 0 else len(price)-1 fixed_pnl = (price[fixed_exit] - entry_price) / entry_price adaptive_pnl = (price[adaptive_exit] - entry_price) / entry_price # Визуализация fig, axes = plt.subplots(2, 1, figsize=(14, 9)) # Цена и стопы axes[0].plot(t_trading, price, color='black', linewidth=1.5, label='Цена') axes[0].plot(t_trading, fixed_stop_array, '--', color='red', linewidth=1.5, label=f'Фиксированный стоп (-{base_stop_pct*100:.0f}%)') axes[0].plot(t_trading, adaptive_stop, color='green', linewidth=1.5, label='Адаптивный стоп') axes[0].scatter([entry_idx], [entry_price], color='blue', s=150, marker='o', zorder=5, label='Вход') axes[0].scatter([fixed_exit], [price[fixed_exit]], color='red', s=150, marker='x', linewidths=3, zorder=5, label=f'Выход (fixed): {fixed_pnl*100:.1f}%') axes[0].scatter([adaptive_exit], [price[adaptive_exit]], color='green', s=150, marker='x', linewidths=3, zorder=5, label=f'Выход (adaptive): {adaptive_pnl*100:.1f}%') axes[0].set_ylabel('Цена', fontsize=11) axes[0].legend(fontsize=9, loc='upper left') axes[0].grid(alpha=0.3) axes[0].set_ylim(105_000, 120_000) # Нормализованная скорость axes[1].plot(t_trading, dy_normalized, color='#666666', linewidth=1.5, label='|dy/dt| / price') axes[1].axvline(entry_idx, color='blue', linestyle=':', alpha=0.5) axes[1].axvline(fixed_exit, color='red', linestyle=':', alpha=0.5) axes[1].axvline(adaptive_exit, color='green', linestyle=':', alpha=0.5) axes[1].set_xlabel('Бар', fontsize=11) axes[1].set_ylabel('Нормализованная скорость', fontsize=11) axes[1].legend(fontsize=10) axes[1].grid(alpha=0.3) plt.tight_layout() plt.show() # Результаты print(f"\nТочка входа: день {entry_idx}, цена ${entry_price:.2f}") print(f"Фиксированный стоп: выход на день {fixed_exit}, P&L = {fixed_pnl*100:.2f}%") print(f"Адаптивный стоп: выход на день {adaptive_exit}, P&L = {adaptive_pnl*100:.2f}%") print(f"Разница в удержании позиции: {adaptive_exit - fixed_exit} баров") Рис. 12: Сравнение фиксированного и адаптивного стоп-лосса на котировках Bitcoin. Верхняя панель — цена с уровнями стопов. Синий круг — точка входа, красный крестик — выход по фиксированному стопу, зеленый — по адаптивному. Фиксированный стоп срабатывает по достижению убытка 2%. Адаптивный стоп (зеленая линия) расширяется в периоды высокой скорости и останавливается при замедлении, позволяя дольше удерживать позицию и сохранять капитал при резком развороте. Нижняя панель — нормализованная первая производная показывает изменение скорости движения цены Эмпирически коэффициент k в диапазоне 0.3-0.7 обеспечивает баланс между защитой капитала и удержанием прибыльных позиций. Значения ниже 0.3 дают слишком узкие стопы, значения выше 1.0 — избыточно широкие, теряющие защитную функцию. Метод особенно эффективен для трендовых стратегий, где важно поймать продолжительное движение без остановки на случайных флуктуациях. Заключение Производные временных рядов превращают сырые финансовые данные в метрики динамики: скорость показывает интенсивность изменения, а ускорение — моменты перелома тренда. Эти инструменты применимы не только в трейдинге, но и в управлении рисками, портфелем и детекции аномалий. Ключевое условие успешного применения — понимание того, что численное дифференцирование требует регуляризации. Прямые конечные разности непригодны для зашумленных финансовых данных, тогда как фильтры типа Savitzky-Golay с правильно подобранными параметрами или сплайн-методы обеспечивают стабильные оценки. Вторая производная — мощный, но чувствительный инструмент. Без сглаживания она усиливает шум, но при корректной регуляризации позволяет количественно оценивать турбулентность рынка и обнаруживать точки перегиба тренда раньше классических индикаторов. Важно помнить, что производные не заменяют другие методы анализа рынка и здравый смысл, однако они дают численный язык для описания текущей динамики цены — и именно это преимущество позволяет принимать более обоснованные решения в условиях рыночной неопределенности. ### Задачи оптимизации в биржевой торговле и методы их решения Оптимизация в алгоритмической торговле решает задачи поиска наилучших параметров при заданных ограничениях. Портфельные менеджеры максимизируют доходность при контроле риска, трейдеры минимизируют издержки исполнения, квантовые исследователи подбирают параметры стратегий. Каждая задача требует специфических методов оптимизации. Основные классы оптимизационных задач в трейдинге: Портфельная оптимизация (распределение капитала между активами), Параметрическая оптимизация (настройка торговых стратегий), Оптимизация исполнения (минимизация влияния ордеров на рынок и проскальзываний). Методы решения варьируются от классических алгоритмов выпуклой оптимизации до метаэвристик для сложных нелинейных задач. Оптимизация портфеля Mean-Variance оптимизация Подход Mean-Variance, разработанный Марковицем, формализует компромисс между доходностью и риском. Задача: найти веса активов w, максимизирующие ожидаемую доходность при заданном уровне риска. Целевая функция: max w^T μ - λ/2 w^T Σ w где: w — вектор весов активов (доли капитала); μ — вектор ожидаемых доходностей; Σ — ковариационная матрица доходностей; λ — коэффициент неприятия риска. Параметр λ контролирует компромисс: при λ→0 максимизируется доходность без учета риска, при больших λ минимизируется волатильность. Ограничения включают условие полной инвестиции (сумма весов равна 1) и неотрицательность весов для long-only портфелей. import numpy as np import pandas as pd import yfinance as yf from scipy.optimize import minimize import matplotlib.pyplot as plt # Загрузка данных для диверсифицированного портфеля tickers = ['TSM', 'BABA', 'NIO', 'JD', 'BIDU'] data = yf.download(tickers, start='2022-09-01', end='2025-09-01')['Close'] # Расчет доходностей returns = data.pct_change().dropna() mean_returns = returns.mean() * 252 # Аннуализация cov_matrix = returns.cov() * 252 def portfolio_performance(weights, mean_returns, cov_matrix): """Расчет доходности и риска портфеля""" portfolio_return = np.dot(weights, mean_returns) portfolio_std = np.sqrt(np.dot(weights.T, np.dot(cov_matrix, weights))) return portfolio_return, portfolio_std def negative_sharpe(weights, mean_returns, cov_matrix, risk_free_rate=0.02): """Минимизируем негативный Sharpe ratio""" p_return, p_std = portfolio_performance(weights, mean_returns, cov_matrix) return -(p_return - risk_free_rate) / p_std # Ограничения и границы constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1} bounds = tuple((0, 1) for _ in range(len(tickers))) initial_weights = np.array([1/len(tickers)] * len(tickers)) # Оптимизация максимального Sharpe ratio result = minimize(negative_sharpe, initial_weights, args=(mean_returns, cov_matrix), method='SLSQP', bounds=bounds, constraints=constraints) optimal_weights = result.x opt_return, opt_std = portfolio_performance(optimal_weights, mean_returns, cov_matrix) # Построение эффективной границы target_returns = np.linspace(mean_returns.min(), mean_returns.max(), 50) efficient_portfolios = [] for target in target_returns: constraints_frontier = [ {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}, {'type': 'eq', 'fun': lambda w: portfolio_performance(w, mean_returns, cov_matrix)[0] - target} ] result_frontier = minimize(lambda w: portfolio_performance(w, mean_returns, cov_matrix)[1], initial_weights, method='SLSQP', bounds=bounds, constraints=constraints_frontier) if result_frontier.success: efficient_portfolios.append(result_frontier.x) # Визуализация efficient_std = [portfolio_performance(w, mean_returns, cov_matrix)[1] for w in efficient_portfolios] efficient_ret = [portfolio_performance(w, mean_returns, cov_matrix)[0] for w in efficient_portfolios] plt.figure(figsize=(10, 6)) plt.plot(efficient_std, efficient_ret, 'k-', linewidth=2, label='Efficient Frontier') plt.scatter(opt_std, opt_return, color='red', s=100, marker='*', label='Max Sharpe Ratio') plt.xlabel('Волатильность (Std Dev)', fontsize=12) plt.ylabel('Ожидаемая доходность', fontsize=12) plt.title('Эффективная граница Mean-Variance', fontsize=14) plt.legend() plt.grid(True, alpha=0.3) plt.show() # Вывод оптимальных весов weights_df = pd.DataFrame({'Asset': tickers, 'Weight': optimal_weights}) print(f"\nOptimal Portfolio Weights:\n{weights_df}") print(f"\nExpected Annual Return: {opt_return:.2%}") print(f"Annual Volatility: {opt_std:.2%}") print(f"Sharpe Ratio: {(opt_return - 0.02) / opt_std:.2f}") Рис. 1: Эффективная граница Марковица для портфеля из пяти активов. Черная кривая показывает оптимальные комбинации риска и доходности. Красная звезда отмечает портфель с максимальным Sharpe ratio — оптимальный выбор для инвестора, максимизирующего доходность на единицу риска Optimal Portfolio Weights: Asset Weight 0 TSM 1.453048e-01 1 BABA 1.866996e-16 2 NIO 5.527263e-16 3 JD 0.000000e+00 4 BIDU 8.546952e-01 Expected Annual Return: 41.22% Annual Volatility: 35.43% Sharpe Ratio: 1.11 Представленный выше код реализует полный цикл портфельной оптимизации: Загружаются исторические данные для китайских tech-компаний, рассчитываются статистики доходностей; Функция negative_sharpe минимизирует отрицательный коэффициент Шарпа, что эквивалентно максимизации оригинального показателя; Затем используется оптимизация методом SLSQP (Sequential Least Squares Programming). Он эффективен для задач квадратичного программирования с ограничениями-равенствами и неравенствами. Построение эффективной границы демонстрирует множество оптимальных портфелей для различных уровней риска. Каждая точка на кривой представляет минимальный риск для заданной целевой доходности. Портфель с максимальным коэффициентом Шарпа (Sharpe ratio) находится на касательной из безрисковой ставки к эффективной границе. Метод Риск-паритет (Risk Parity) и альтернативные подходы Оптимизационный метод Risk Parity распределяет капитал так, чтобы каждый актив вносил равный вклад в общий риск портфеля. Классический подход Mean-Variance концентрирует риск в волатильных активах с высокой ожидаемой доходностью, а Risk Parity диверсифицирует источники риска. Вклад актива i в риск портфеля: RC_i = w_i × (Σw)_i / √(w^T Σ w) где: RC_i — risk contribution актива i; w_i — вес актива i; (Σw)_i — i-й элемент вектора Σw (ковариация актива с портфелем); √(w^T Σ w) — волатильность портфеля. Risk Parity находит веса, при которых RC_i = 1/N для всех активов, где N — количество позиций. Низковолатильные активы получают больший вес, высокорисковые — меньший. import numpy as np import pandas as pd import yfinance as yf from scipy.optimize import minimize # Загрузка данных для multi-asset портфеля tickers = ['GLD', 'TLT', 'VNQ', 'DBC', 'EEM'] # Золото, облигации, недвижимость, сырье, emerging markets data = yf.download(tickers, start='2022-09-01', end='2025-09-01')['Close'] returns = data.pct_change().dropna() cov_matrix = returns.cov() * 252 def risk_contribution(weights, cov_matrix): """Расчет вкладов активов в риск портфеля""" portfolio_var = np.dot(weights.T, np.dot(cov_matrix, weights)) portfolio_std = np.sqrt(portfolio_var) marginal_contrib = np.dot(cov_matrix, weights) risk_contrib = weights * marginal_contrib / portfolio_std return risk_contrib def risk_parity_objective(weights, cov_matrix): """Минимизация разницы между вкладами в риск""" risk_contrib = risk_contribution(weights, cov_matrix) target_risk = np.ones(len(weights)) / len(weights) return np.sum((risk_contrib - target_risk * risk_contrib.sum())**2) # Оптимизация Risk Parity n_assets = len(tickers) constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1} bounds = tuple((0.01, 1) for _ in range(n_assets)) initial_weights = np.array([1/n_assets] * n_assets) result_rp = minimize(risk_parity_objective, initial_weights, args=(cov_matrix,), method='SLSQP', bounds=bounds, constraints=constraints) rp_weights = result_rp.x rp_risk_contrib = risk_contribution(rp_weights, cov_matrix) # Сравнение с равновесным портфелем equal_weights = np.array([1/n_assets] * n_assets) equal_risk_contrib = risk_contribution(equal_weights, cov_matrix) # Mean-Variance портфель для сравнения mean_returns = returns.mean() * 252 def negative_sharpe_mv(weights, mean_returns, cov_matrix): ret = np.dot(weights, mean_returns) vol = np.sqrt(np.dot(weights.T, np.dot(cov_matrix, weights))) return -(ret - 0.02) / vol result_mv = minimize(negative_sharpe_mv, initial_weights, args=(mean_returns, cov_matrix), method='SLSQP', bounds=bounds, constraints=constraints) mv_weights = result_mv.x mv_risk_contrib = risk_contribution(mv_weights, cov_matrix) # Визуализация сравнения fig, axes = plt.subplots(1, 3, figsize=(15, 5)) portfolios = [ ('Equal Weight', equal_weights, equal_risk_contrib), ('Risk Parity', rp_weights, rp_risk_contrib), ('Mean-Variance', mv_weights, mv_risk_contrib) ] for idx, (title, weights, risk_contrib) in enumerate(portfolios): axes[idx].bar(tickers, weights, alpha=0.7, color='darkgray', label='Weights') axes[idx].set_ylabel('Weight', fontsize=11) axes[idx].set_ylim(0, max(max(equal_weights), max(rp_weights), max(mv_weights)) * 1.1) ax2 = axes[idx].twinx() ax2.plot(tickers, risk_contrib, 'ko-', linewidth=2, markersize=8, label='Risk Contribution') ax2.set_ylabel('Risk Contribution', fontsize=11) ax2.set_ylim(0, max(max(equal_risk_contrib), max(rp_risk_contrib), max(mv_risk_contrib)) * 1.1) axes[idx].set_title(title, fontsize=12, fontweight='bold') axes[idx].tick_params(axis='x', rotation=45) plt.tight_layout() plt.show() # Количественное сравнение comparison_df = pd.DataFrame({ 'Asset': tickers, 'Equal Weight': equal_weights, 'Risk Parity': rp_weights, 'Mean-Variance': mv_weights }) risk_df = pd.DataFrame({ 'Asset': tickers, 'Equal RC': equal_risk_contrib, 'RP RC': rp_risk_contrib, 'MV RC': mv_risk_contrib }) print("Portfolio Weights:\n", comparison_df.round(3)) print("\nRisk Contributions:\n", risk_df.round(3)) print(f"\nRisk Parity std of RC: {np.std(rp_risk_contrib):.4f}") print(f"Equal Weight std of RC: {np.std(equal_risk_contrib):.4f}") print(f"Mean-Variance std of RC: {np.std(mv_risk_contrib):.4f}") Рис. 2: Сравнение распределения весов и вкладов в риск для трех методов портфельной оптимизации. Столбцы показывают веса активов, черные линии — вклады в риск портфеля. Risk Parity выравнивает вклады в риск, тогда как Equal Weight и Mean-Variance концентрируют риск в отдельных позициях. Стандартное отклонение risk contributions минимально для Risk Parity, что подтверждает равномерную диверсификацию источников риска Portfolio Weights: Asset Equal Weight Risk Parity Mean-Variance 0 GLD 0.2 0.223 0.010 1 TLT 0.2 0.178 0.013 2 VNQ 0.2 0.216 0.957 3 DBC 0.2 0.226 0.010 4 EEM 0.2 0.158 0.010 Risk Contributions: Asset Equal RC RP RC MV RC 0 GLD 0.018 0.021 0.001 1 TLT 0.026 0.023 0.001 2 VNQ 0.020 0.023 0.148 3 DBC 0.017 0.021 0.000 4 EEM 0.029 0.021 0.000 Risk Parity std of RC: 0.0009 Equal Weight std of RC: 0.0047 Mean-Variance std of RC: 0.0591 Код демонстрирует три подхода к построению портфеля из разных классов активов. Risk Parity минимизирует квадратичное отклонение вкладов в риск от равномерного распределения. Функция risk_contribution вычисляет маргинальный вклад каждого актива, умножая его вес на ковариацию с портфелем и нормируя на общую волатильность. Интерпретация результатов: Risk Parity (RP): Веса распределены почти равномерно. Вклады в риск (RC) тоже почти одинаковые — около 0.021–0.023, а стандартное отклонение RC = 0.0009, что говорит о почти идеальном балансе риска. Такой портфель стабилен, хорошо диверсифицирован и менее чувствителен к падению отдельных активов. Equal Weight (EW): Все активы имеют долю по 0.2, но вклады в риск сильно различаются. Стандартное отклонение RC = 0.0047, что указывает на умеренный дисбаланс. Хотя капитал распределен равномерно, более волатильные активы создают больший риск, и портфель может быть нестабилен в стрессовых условиях. Mean-Variance (MV): Почти весь капитал в VNQ (0.957), остальные активы занимают лишь ~1%. Вклад VNQ в риск = 0.148, тогда как у остальных активов почти ноль. Стандартное отклонение RC = 0.0591, что означает крайнюю концентрацию риска. Такой портфель ориентирован на максимальную доходность, но при этом очень уязвим — падение одного актива способно обрушить весь результат. Сравнение показывает ключевые различия подходов: Equal Weight игнорирует структуру ковариаций, что приводит к концентрации риска в волатильных активах. Mean-Variance концентрирует капитал в активах с высоким Sharpe ratio, риск доминируется несколькими позициями. Risk Parity обеспечивает равномерное распределение риска, часто увеличивая веса низковолатильных активов типа TLT (казначейские облигации). Оптимизация параметров торговых стратегий Grid Search и Random Search Метод поиска по сетке Grid Search перебирает все комбинации параметров из заданной сетки. Для стратегии с параметрами lookback period и threshold создается двумерная сетка значений, каждая комбинация тестируется на исторических данных. Метод гарантирует нахождение оптимума в пределах сетки, однако вычислительная сложность растет экспоненциально с количеством параметров. Метод случайного поиска Random Search сэмплирует случайные комбинации параметров из заданных распределений. Подход эффективнее Grid Search для многомерных пространств: при фиксированном бюджете вычислений Random Search исследует больше различных значений по каждому измерению. Bergstra и Bengio (2012) показали превосходство Random Search для оптимизации гиперпараметров нейросетей. import numpy as np import pandas as pd import yfinance as yf from itertools import product import matplotlib.pyplot as plt # Загрузка данных ticker = 'QCOM' data = yf.download(ticker, start='2022-09-01', end='2025-09-01') prices = data['Close'] if isinstance(prices, pd.DataFrame): prices = prices.squeeze() def momentum_strategy(prices, lookback, threshold): """ Простая momentum стратегия с параметрами: - lookback: период расчета доходности - threshold: порог для входа в позицию """ returns = prices.pct_change() momentum = prices.pct_change(lookback) # Сигналы: long при momentum > threshold, short при < -threshold positions = np.where(momentum > threshold, 1, np.where(momentum < -threshold, -1, 0)) positions = pd.Series(positions, index=prices.index) positions = positions.shift(1).fillna(0) # Избегаем look-ahead bias strategy_returns = positions * returns cumulative_return = (1 + strategy_returns).prod() - 1 sharpe = strategy_returns.mean() / strategy_returns.std() * np.sqrt(252) return { 'cumulative_return': cumulative_return, 'sharpe': sharpe, 'positions': positions } # Grid Search lookback_range = range(5, 51, 5) # 5, 10, 15, ..., 50 threshold_range = np.arange(0.01, 0.11, 0.01) # 0.01, 0.02, ..., 0.10 grid_results = [] for lookback, threshold in product(lookback_range, threshold_range): result = momentum_strategy(prices, lookback, threshold) grid_results.append({ 'lookback': lookback, 'threshold': threshold, 'sharpe': result['sharpe'], 'return': result['cumulative_return'] }) grid_df = pd.DataFrame(grid_results) best_grid = grid_df.loc[grid_df['sharpe'].idxmax()] # Random Search (то же количество итераций) n_iterations = len(grid_results) random_results = [] np.random.seed(42) for _ in range(n_iterations): lookback = np.random.randint(5, 51) threshold = np.random.uniform(0.01, 0.11) result = momentum_strategy(prices, lookback, threshold) random_results.append({ 'lookback': lookback, 'threshold': threshold, 'sharpe': result['sharpe'], 'return': result['cumulative_return'] }) random_df = pd.DataFrame(random_results) best_random = random_df.loc[random_df['sharpe'].idxmax()] # Визуализация fig, axes = plt.subplots(1, 2, figsize=(14, 5)) # Grid Search heatmap grid_pivot = grid_df.pivot(index='threshold', columns='lookback', values='sharpe') im1 = axes[0].imshow(grid_pivot, cmap='RdYlGn', aspect='auto', origin='lower') axes[0].set_xticks(range(len(lookback_range))) axes[0].set_xticklabels(lookback_range) axes[0].set_yticks(range(len(threshold_range))) axes[0].set_yticklabels([f'{t:.2f}' for t in threshold_range]) axes[0].set_xlabel('Lookback Period', fontsize=11) axes[0].set_ylabel('Threshold', fontsize=11) axes[0].set_title('Grid Search: Sharpe Ratio Heatmap', fontsize=12) plt.colorbar(im1, ax=axes[0]) # Random Search scatter scatter = axes[1].scatter(random_df['lookback'], random_df['threshold'], c=random_df['sharpe'], cmap='RdYlGn', s=50, alpha=0.6) axes[1].scatter(best_random['lookback'], best_random['threshold'], color='red', s=200, marker='*', edgecolors='black', linewidths=2, label=f"Best (Sharpe={best_random['sharpe']:.2f})") axes[1].set_xlabel('Lookback Period', fontsize=11) axes[1].set_ylabel('Threshold', fontsize=11) axes[1].set_title('Random Search: Explored Parameter Space', fontsize=12) axes[1].legend() plt.colorbar(scatter, ax=axes[1]) plt.tight_layout() plt.show() print(f"Grid Search Best Parameters:") print(f"Lookback: {best_grid['lookback']}, Threshold: {best_grid['threshold']:.3f}") print(f"Sharpe: {best_grid['sharpe']:.2f}, Return: {best_grid['return']:.2%}\n") print(f"Random Search Best Parameters:") print(f"Lookback: {best_random['lookback']}, Threshold: {best_random['threshold']:.3f}") print(f"Sharpe: {best_random['sharpe']:.2f}, Return: {best_random['return']:.2%}") Рис. 3: Сравнение Grid Search и Random Search для оптимизации momentum стратегии. Левая панель показывает полную карту Sharpe ratio для всех комбинаций параметров. Правая панель отображает случайно исследованные точки, красная звезда отмечает найденный оптимум. Grid Search обеспечивает систематическое покрытие, Random Search быстрее находит конкурентоспособные параметры при ограниченном бюджете вычислений Grid Search Best Parameters: Lookback: 20.0, Threshold: 0.010 Sharpe: -0.00, Return: -17.15% Random Search Best Parameters: Lookback: 12.0, Threshold: 0.083 Sharpe: 0.23, Return: 8.13% Реализация демонстрирует базовую momentum стратегию с двумя параметрами. Grid Search строит полную карту пространства параметров, каждая комбинация тестируется один раз. Random Search исследует то же количество точек, но распределенных случайным образом. При малом числе параметров (2-3) Grid Search покрывает пространство равномерно, для 5+ параметров Random Search эффективнее. Тепловая карта Heatmap Grid Search показывает структуру целевой функции: регионы высокого Sharpe ratio концентрируются вокруг определенных значений lookback и threshold. Random Search может пропустить оптимум в дискретной сетке, но находит хорошие решения быстрее в непрерывных пространствах. Для production стратегий оба метода служат начальным этапом перед более сложной оптимизацией. Байесовская оптимизация Байесовская оптимизация эффективна для оптимизации дорогостоящих «чёрных ящиков» — функций, вычисление которых требует значительных ресурсов. Метод строит вероятностную модель целевой функции (обычно на основе гауссовского процесса) и использует функцию выбора следующей точки (Acquisition function), чтобы сбалансировать эксплуатацию (поиск около текущего оптимума) и исследование (поиск в еще не изученных областях). Функция выбора (Acquisition function) формализует компромисс между эксплуатацией и исследованием. Метод Expected Improvement (EI) максимизирует ожидаемое улучшение относительно текущего наилучшего значения: EI(x) = E[max(f(x) - f(x⁺), 0)] где: f(x) — значение целевой функции в точке x; x⁺ — текущая лучшая найденная точка; E[·] — математическое ожидание по апостериорному распределению. Показатель ожидаемого улучшения (EI) обычно выше в областях с высоким предсказанным значением функции (mean) и высокой неопределенностью (uncertainty). Гауссовский процесс предоставляет оба этих компонента: μ(x) — предсказанное значение, σ(x) — меру неопределенности. import numpy as np import pandas as pd import yfinance as yf from hyperopt import hp, fmin, tpe, Trials, STATUS_OK from hyperopt.pyll import scope import matplotlib.pyplot as plt # Загрузка данных ticker = 'AMD' data = yf.download(ticker, start='2023-09-01', end='2025-09-01') # Берем только Close и превращаем в Series prices = data['Close'] if isinstance(prices, pd.DataFrame): prices = prices.iloc[:, 0] returns = prices.pct_change().dropna() # Пространство поиска Hyperopt space = { 'window': scope.int(hp.quniform('window', 10, 100, 1)), 'entry_threshold': hp.uniform('entry_threshold', 1.0, 3.0), 'exit_threshold': hp.uniform('exit_threshold', 0.1, 1.0) } # Стратегия def mean_reversion_strategy(params, prices, returns): window = int(params['window']) entry_threshold = float(params['entry_threshold']) exit_threshold = float(params['exit_threshold']) if window >= len(prices): window = max(2, len(prices) // 2) rolling_mean = prices.rolling(window).mean() rolling_std = prices.rolling(window).std() z_score = (prices - rolling_mean) / rolling_std z_score = z_score.fillna(0) positions = pd.Series(0, index=prices.index) position = 0 for i in range(window, len(prices)): z = z_score.iat[i] # скалярное значение if position == 0: if z < -entry_threshold: position = 1 elif z > entry_threshold: position = -1 elif position == 1 and z > -exit_threshold: position = 0 elif position == -1 and z < exit_threshold: position = 0 positions.iat[i] = position positions = positions.shift(1).fillna(0) strategy_returns = positions * returns strategy_returns = strategy_returns.replace([np.inf, -np.inf], 0).fillna(0) mean_ret = float(strategy_returns.mean()) std_ret = float(strategy_returns.std()) sharpe = float(mean_ret / std_ret * np.sqrt(252)) if std_ret > 0 else 0.0 cumulative_return = float((1 + strategy_returns).prod() - 1) max_dd = float((strategy_returns.cumsum() - strategy_returns.cumsum().expanding().max()).min()) n_trades = int((positions.diff().fillna(0) != 0).sum()) return { 'sharpe': sharpe, 'return': cumulative_return, 'max_drawdown': max_dd, 'n_trades': n_trades } # Objective function для Hyperopt def objective(params): params_safe = { 'window': int(params['window']), 'entry_threshold': float(params['entry_threshold']), 'exit_threshold': float(params['exit_threshold']) } result = mean_reversion_strategy(params_safe, prices, returns) sharpe = float(result['sharpe']) n_trades = int(result['n_trades']) # Штраф за малое количество сделок if n_trades < 10: return {'loss': 10, 'status': STATUS_OK} return {'loss': -sharpe, 'status': STATUS_OK, 'result': result} # Байесовская оптимизация trials = Trials() best_params = fmin( fn=objective, space=space, algo=tpe.suggest, max_evals=50, trials=trials, rstate=np.random.default_rng(42) ) best_params['window'] = int(best_params['window']) final_result = mean_reversion_strategy(best_params, prices, returns) # Вывод результатов print(f"Best Parameters Found:") print(f"Window: {best_params['window']}") print(f"Entry Threshold: {best_params['entry_threshold']:.2f}") print(f"Exit Threshold: {best_params['exit_threshold']:.2f}\n") print(f"Performance Metrics:") print(f"Sharpe Ratio: {final_result['sharpe']:.2f}") print(f"Cumulative Return: {final_result['return']:.2%}") print(f"Max Drawdown: {final_result['max_drawdown']:.2%}") print(f"Number of Trades: {final_result['n_trades']}") Best Parameters Found: Window: 35 Entry Threshold: 2.22 Exit Threshold: 0.76 Performance Metrics: Sharpe Ratio: 0.61 Cumulative Return: 32.35% Max Drawdown: -32.36% Number of Trades: 26 Данный код демонстрирует автоматизированный подход к поиску оптимальных параметров торговой стратегии с использованием байесовской оптимизации, что позволяет минимизировать человеческий фактор и повысить эффективность тестирования. Давайте рассмотрим его этапы: Сначала мы загружаем исторические цены акций AMD, выбираем Close и вычисляем доходности; Затем определяем пространства поиска Hyperopt – задаем диапазоны для параметров стратегии (window, entry_threshold, exit_threshold); Далее следует функция стратегии mean-reversion – вычисляем скользящее среднее, стандартное отклонение, z-score и генерируем торговые позиции; Следующий этап - расчет метрик стратегии. Здесь вычисляем коэффициент Шарпа, кумулятивную доходность, максимальную просадку и количество сделок; Реализуем Objective-функцию для Hyperopt. Она оценивает стратегию по параметрам и возвращает функцию потерь (минус Sharpe), добавляя штраф за малое количество сделок; Следующий этап - байесовская оптимизация Hyperopt. Она находит оптимальные параметры стратегии путем последовательного тестирования различных комбинаций; Вывод финальных результатов – отображаются лучшие параметры и показатели эффективности стратегии. Оптимальные параметры стратегии указывают на использование скользящего окна в 35 дней для расчета средних значений цены, с порогом входа 2.22 и порогом выхода 0.76. Это означает, что стратегия активно реагирует на относительно сильные отклонения цены от среднего, открывая позиции при заметной перепроданности или перекупленности, и закрывает их при достижении более умеренного уровня. Полученный Sharpe Ratio 0.61 свидетельствует о хорошей соотношении доходности к риску, а кумулятивная доходность 32.35% показывает, что стратегия могла приносить стабильный прирост капитала на данном историческом периоде. Максимальная просадка в -32.36% отражает риски значительных краткосрочных потерь, а количество сделок (26) указывает на умеренную торговую активность, что снижает издержки на комиссии. Использование байесовской оптимизации позволяет эффективно подбирать параметры стратегии с минимальным количеством исторических прогонов, учитывая сложные взаимодействия между окнами скользящей средней и порогами входа/выхода. Так достигается баланс между исследованием новых параметров (exploration) и использованием уже известных хороших решений (exploitation), ускоряя процесс поиска оптимальной стратегии. Практически такой подход делает стратегию адаптивной, систематической и легко применимой в алгоритмической торговле, снижая субъективность принятия решений и повышая стабильность результатов на исторических данных. Walk-forward оптимизация Оптимизация методом Walk-forward предотвращает переобучение модели за счет разделения данных на последовательные периоды: in-sample (IS) для обучения и out-of-sample (OOS) для тестирования. Параметры настраиваются на IS, затем проверяются на следующем OOS, после чего окно сдвигается вперед. Такой подход имитирует реальную торговлю: стратегия калибруется на исторических данных и применяется к новым, еще не использованным данным. Процедура walk-forward: Выбрать размеры IS и OOS периодов (например, 12 месяцев IS, 3 месяца OOS); Оптимизировать параметры на первом IS периоде; Применить найденные параметры к следующему OOS периоду; Сдвинуть окно вперед на размер OOS; Повторить до конца данных. В практике тестирования торговых стратегий применяются различные подходы Walk-forward для оценки стабильности модели: Anchored walk-forward использует все данные с самого начала в качестве IS; Expanding walk-forward постепенно увеличивает IS окно на каждом шаге; Rolling walk-forward фиксирует размер IS окна, что позволяет быстрее адаптироваться к изменяющимся рыночным условиям. Выбор метода влияет на баланс между стабильностью параметров и чувствительностью к новым рыночным событиям, помогая лучше имитировать реальную торговлю. import numpy as np import pandas as pd import yfinance as yf import matplotlib.pyplot as plt from scipy.optimize import differential_evolution # Загрузка данных ticker = 'BKR' #Baker Hughes Co. data = yf.download(ticker, start='2022-09-01', end='2025-09-01', auto_adjust=True) # Убираем мультииндекс (если есть) if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.get_level_values(0) prices = data['Close'] returns = prices.pct_change().dropna() # Функция стратегии def dual_ma_crossover(prices, short_window, long_window): short_ma = prices.rolling(window=short_window).mean() long_ma = prices.rolling(window=long_window).mean() positions = pd.Series(0, index=prices.index) positions[short_ma > long_ma] = 1 positions = positions.shift(1).fillna(0) strategy_returns = positions * prices.pct_change() return strategy_returns # Оптимизация параметров def optimize_strategy(prices_train): def objective(params): short, long = int(params[0]), int(params[1]) if short >= long: return -999 strat_returns = dual_ma_crossover(prices_train, short, long) sharpe = strat_returns.mean() / strat_returns.std() * np.sqrt(252) if strat_returns.std() > 0 else -999 return -sharpe bounds = [(5, 50), (20, 200)] result = differential_evolution(objective, bounds, seed=42, maxiter=100, polish=False) return int(result.x[0]), int(result.x[1]) # Walk-forward параметры is_period = 252 # 1 год oos_period = 63 # 3 месяца min_train_size = 252 wf_results = [] all_oos_returns = pd.Series(dtype=float) start_idx = min_train_size while start_idx + is_period + oos_period <= len(prices): is_start = start_idx is_end = start_idx + is_period oos_end = is_end + oos_period prices_is = prices.iloc[is_start:is_end] short_opt, long_opt = optimize_strategy(prices_is) oos_returns = dual_ma_crossover(prices, short_opt, long_opt).iloc[is_end:oos_end] oos_sharpe = oos_returns.mean() / oos_returns.std() * np.sqrt(252) if oos_returns.std() > 0 else 0 wf_results.append({ 'is_start': prices.index[is_start], 'is_end': prices.index[is_end-1], 'oos_end': prices.index[oos_end-1], 'short_window': short_opt, 'long_window': long_opt, 'oos_sharpe': oos_sharpe, 'oos_return': (1 + oos_returns).prod() - 1 }) all_oos_returns = pd.concat([all_oos_returns, oos_returns]) start_idx += oos_period wf_df = pd.DataFrame(wf_results) # Полная оптимизация на всех данных short_full, long_full = optimize_strategy(prices) full_returns = dual_ma_crossover(prices, short_full, long_full) full_returns_aligned = full_returns.loc[all_oos_returns.index.intersection(full_returns.index)] # Метрики wf_cumulative = (1 + all_oos_returns).cumprod() full_cumulative = (1 + full_returns_aligned).cumprod() buy_hold = prices.loc[all_oos_returns.index.intersection(prices.index)] buy_hold_cum = buy_hold / buy_hold.iloc[0] wf_sharpe = all_oos_returns.mean() / all_oos_returns.std() * np.sqrt(252) full_sharpe = full_returns_aligned.mean() / full_returns_aligned.std() * np.sqrt(252) # Визуализация fig, axes = plt.subplots(3, 1, figsize=(14, 12)) axes[0].plot(wf_cumulative.index, wf_cumulative.values, 'k-', linewidth=2, label='Walk-Forward') axes[0].plot(full_cumulative.index, full_cumulative.values, 'gray', linewidth=2, alpha=0.7, label='Full In-Sample Optimization') axes[0].plot(buy_hold_cum.index, buy_hold_cum.values, 'darkgray', linewidth=1, alpha=0.5, label='Buy & Hold') axes[0].set_ylabel('Cumulative Return', fontsize=11) axes[0].set_title('Walk-Forward vs Full Optimization', fontsize=12, fontweight='bold') axes[0].legend(loc='upper left') axes[0].grid(True, alpha=0.3) axes[1].plot(wf_df['oos_end'], wf_df['short_window'], 'o-', color='black', label='Short Window') axes[1].plot(wf_df['oos_end'], wf_df['long_window'], 's-', color='gray', label='Long Window') axes[1].set_ylabel('Window Size', fontsize=11) axes[1].set_title('Parameter Evolution Over Time', fontsize=12) axes[1].legend() axes[1].grid(True, alpha=0.3) colors = ['green' if x > 0 else 'red' for x in wf_df['oos_sharpe']] axes[2].bar(range(len(wf_df)), wf_df['oos_sharpe'], color=colors, alpha=0.7) axes[2].axhline(y=0, color='black', linestyle='-', linewidth=0.8) axes[2].set_xlabel('Walk-Forward Period', fontsize=11) axes[2].set_ylabel('OOS Sharpe Ratio', fontsize=11) axes[2].set_title('Out-of-Sample Performance by Period', fontsize=12) axes[2].grid(True, alpha=0.3, axis='y') plt.tight_layout() plt.show() # Статистика print(f"Walk-Forward Results:") print(f"Overall OOS Sharpe: {wf_sharpe:.2f}") print(f"Cumulative Return: {(wf_cumulative.iloc[-1] - 1):.2%}") print(f"Win Rate (periods): {(wf_df['oos_sharpe'] > 0).mean():.1%}") print(f"Average params: Short={wf_df['short_window'].mean():.0f}, Long={wf_df['long_window'].mean():.0f}\n") print(f"Full In-Sample Optimization:") print(f"Parameters: Short={short_full}, Long={long_full}") print(f"OOS Sharpe: {full_sharpe:.2f}") print(f"Cumulative Return: {(full_cumulative.iloc[-1] - 1):.2%}") Walk-Forward Results: Overall OOS Sharpe: 1.61 Cumulative Return: 29.69% Win Rate (periods): 100.0% Average params: Short=39, Long=31 Full In-Sample Optimization: Parameters: Short=31, Long=27 OOS Sharpe: 0.56 Cumulative Return: 9.53% Результаты показывают, что стратегия на основе скользящих средних демонстрирует высокую эффективность при применении метода walk-forward: показатель Sharpe вне выборки составляет 1.61, что указывает на хорошее соотношение доходности и риска, а совокупная доходность за период достигла почти 30% при 100% успешных периодах, при этом оптимальные параметры короткой и длинной скользящей средней усредненно равны 39 и 31 соответственно. Для полной оптимизации на всем периоде без разбиения на окна показатели ниже: Sharpe вне выборки 0.56 и доходность около 9.5%, что отражает переобучение на исторических данных и подчеркивает преимущество walk-forward подхода для более устойчивой и надежной оценки стратегии. Рис. 4: Результаты walk-forward оптимизации торговой стратегии по 2 MA. Верхний график сравнивает equity curves: черная линия — WF подход с переоптимизацией параметров, серая — фиксированные параметры из полной IS оптимизации. Средний график показывает эволюцию оптимальных параметров во времени, отражая адаптацию к меняющимся рыночным условиям. Нижний график — OOS Sharpe ratio для каждого периода В коде выше мы использовали метод walk-forward с фиксированным in-sample периодом в 2 года. На каждом IS окне параметры стратегии оптимизируются с помощью Differential Evolution — метаэвристики, хорошо подходящей для негладких функций вроде коэффициента Шарпа. Каждый последующий тестовый (OOS, out-of-sample) период проверяет стратегию на еще «невиданных» данных, имитируя реальные условия ее применения. График эволюции параметров отражает адаптацию стратегии к меняющимся рыночным условиям: оптимальные окна скользящих средних меняются в зависимости от волатильности и направлений трендов. В периоды высокой волатильности чаще требуются более короткие окна для быстрой реакции. OOS Sharpe ratio по периодам показывает стабильность стратегии: естественно, что прибыльные и убыточные периоды чередуются, ключевое — положительное среднее значение. Стратегия с хорошей производительностью в walk-forward (WF) имеет больше шансов на успех в реальной торговле. Сравнение с полной in-sample оптимизацией выявляет переобучение: если full optimization заметно превосходит WF на тех же out-of-sample данных, это значит, что параметры подогнаны под исторический шум. Оптимизация исполнения ордеров Оптимизация исполнения ордеров минимизирует рыночное воздействие (market impact) при размещении крупных ордеров. Размещение всего объема одной заявкой сдвигает цену против трейдера (price impact), тогда как разбиение ордера на части снижает это воздействие, однако увеличивает риск из-за времени исполнения (timing risk). VWAP и TWAP стратегии Методы VWAP (Volume-Weighted Average Price) и TWAP (Time-Weighted Average Price) помогают сбалансировать эти два фактора. VWAP исполняет ордер пропорционально ожидаемому объему торгов в каждый временной интервал. Если исторически 30% дневного объема торгуется в первый час, VWAP размещает 30% ордера в этот период. Подход минимизирует информационную утечку и price impact, следуя естественному ритму рынка. TWAP равномерно распределяет исполнение во времени независимо от объема. Простота реализации делает TWAP популярным для менее ликвидных инструментов, где порой очень трудно сделать реалистичный прогноз внутридневного профиля объема (volume profile). import numpy as np import pandas as pd import matplotlib.pyplot as plt # Симуляция финансового ряда np.random.seed(42) minutes = 390 # торговый день 6.5 часов dates = pd.date_range("2025-10-31 09:30", periods=minutes, freq="1min") prices = 100 + np.cumsum(np.random.randn(minutes) * 0.1) volumes = 100 + 900*np.sin(np.linspace(0, np.pi, minutes)) + np.random.randint(0,50,minutes) data = pd.DataFrame({'Close': prices, 'Volume': volumes}, index=dates) # VWAP и TWAP исполнение def vwap_exec(df, total_shares, intervals): df['bucket'] = pd.cut(range(len(df)), bins=intervals, labels=False) agg = df.groupby('bucket').agg({'Close':'mean','Volume':'sum'}) agg['shares'] = (agg['Volume']/agg['Volume'].sum()*total_shares).astype(int) agg.iloc[-1, agg.columns.get_loc('shares')] += total_shares - agg['shares'].sum() return (agg['shares']*agg['Close']).sum()/total_shares, agg def twap_exec(df, total_shares, intervals): df['bucket'] = pd.cut(range(len(df)), bins=intervals, labels=False) agg = df.groupby('bucket').agg({'Close':'mean'}) agg['shares'] = total_shares // intervals agg.iloc[-1, agg.columns.get_loc('shares')] += total_shares - agg['shares'].sum() return (agg['shares']*agg['Close']).sum()/total_shares, agg # Параметры total_shares = 10000 num_intervals = 10 vwap_price, vwap_sched = vwap_exec(data, total_shares, num_intervals) twap_price, twap_sched = twap_exec(data, total_shares, num_intervals) # Визуализация fig, ax = plt.subplots(2, 1, figsize=(10,6)) ax[0].plot(data.index, data['Close'], color='black', label='Price') ax[0].axhline(vwap_price, color='blue', linestyle='--', label=f'VWAP ${vwap_price:.2f}') ax[0].axhline(twap_price, color='red', linestyle='--', label=f'TWAP ${twap_price:.2f}') ax[0].set_title("Intraday Price vs Execution Price") ax[0].legend() width = 0.35 ax[1].bar(range(num_intervals), vwap_sched['shares'], width, label='VWAP', alpha=0.7) ax[1].bar(np.arange(num_intervals)+width, twap_sched['shares'], width, label='TWAP', alpha=0.7) ax[1].set_title("Execution Schedule") ax[1].legend() plt.tight_layout() plt.show() # Сравнение print(f"VWAP Price: {vwap_price:.2f}, TWAP Price: {twap_price:.2f}") VWAP Price: 99.52, TWAP Price: 99.62 Рис. 5: Сравнение VWAP и TWAP алгоритмов исполнения. Верхняя панель показывает внутридневное движение цены с достигнутыми средними ценами исполнения. Нижняя панель - объем сделок по времени суток при подходе VWAP и TWAP Алгоритм VWAP распределяет ордер пропорционально объему на каждом временном интервале, учитывая пиковые моменты активности, тогда как TWAP равномерно делит объем по времени без учета объёмного профиля. Визуализация показывает, как различаются цены исполнения и распределение объема между интервалами для двух методов. Оптимизация размеров ордеров с помощью VWAP и TWAP позволяет трейдерам и алгоритмическим системам выбирать оптимальную стратегию исполнения для крупных ордеров, минимизируя рыночное воздействие и снижая риск проскальзывания. Алгоритмы оптимального исполнения Модель Almgren-Chriss формализует компромисс между рыночным воздействием (market impact) и риском времени исполнения (timing risk): постоянное воздействие на цену (permanent impact) сдвигает цену необратимо, временное воздействие на цену (temporary impact) действует только во время исполнения ордера. Модель вычисляет оптимальную траекторию размещения ордера, минимизируя ожидаемые издержки с учетом допустимого уровня риска. Целевая функция Almgren-Chriss: J = E[C] + λ Var[C] где: C — общая стоимость исполнения; E[C] — ожидаемые издержки (market impact); Var[C] — дисперсия издержек (timing risk); λ — параметр risk aversion. Параметр λ контролирует агрессивность исполнения: λ→0 минимизирует ожидаемые издержки (медленное исполнение), большие λ снижают риск за счет быстрого исполнения. Модель выдает оптимальную траекторию исполнения: сколько акций торговать в каждый момент времени. import numpy as np import pandas as pd import matplotlib.pyplot as plt class AlmgrenChrissOptimizer: """ Модель Almgren-Chriss для оптимального исполнения """ def __init__(self, total_shares, T, N, sigma, eta, gamma, lambda_risk=1e-6): self.X = total_shares self.T = T self.N = N self.tau = T / N self.sigma = sigma self.eta = eta self.gamma = gamma self.lambda_risk = lambda_risk def compute_optimal_trajectory(self): """Расчет оптимальной траектории исполнения""" kappa = np.sqrt(self.lambda_risk * self.sigma**2 / self.eta) sinh_total = np.sinh(kappa * self.T) if kappa != 0 else 1 holdings = np.zeros(self.N + 1) for j in range(self.N + 1): t_j = j * self.tau holdings[j] = self.X * np.sinh(kappa * (self.T - t_j)) / sinh_total trade_list = -np.diff(holdings) return holdings, trade_list def compute_costs(self, trade_list, price_path): """Расчет издержек исполнения""" permanent_impact = np.cumsum(self.gamma * trade_list) temporary_impact = self.eta * trade_list execution_prices = price_path[:-1] + permanent_impact + temporary_impact total_cost = np.sum(trade_list * execution_prices) benchmark_cost = self.X * price_path[0] implementation_shortfall = total_cost - benchmark_cost return { 'total_cost': total_cost, 'benchmark_cost': benchmark_cost, 'shortfall': implementation_shortfall, 'shortfall_bps': (implementation_shortfall / benchmark_cost) * 10000, 'permanent_impact': permanent_impact, 'temporary_impact': temporary_impact } # Параметры симуляции total_shares = 1000000 T = 1.0 N = 20 sigma = 0.1 # 10% дневная волатильность eta = 1e-6 # временный импакт gamma = 1e-5 # постоянный импакт initial_price = 100 lambda_values = [1e-2, 1e-1, 1.0] np.random.seed(42) price_path = initial_price * np.exp(np.cumsum(np.concatenate([[0], np.random.normal(0, sigma / np.sqrt(N), N)]))) results = {} fig, axes = plt.subplots(2, 2, figsize=(14, 10)) for lambda_risk in lambda_values: optimizer = AlmgrenChrissOptimizer(total_shares, T, N, sigma, eta, gamma, lambda_risk) holdings, trade_list = optimizer.compute_optimal_trajectory() costs = optimizer.compute_costs(trade_list, price_path) results[lambda_risk] = { 'holdings': holdings, 'trade_list': trade_list, 'costs': costs } time_points = np.linspace(0, T, N + 1) axes[0, 0].plot(time_points, holdings / total_shares, linewidth=2, label=f'λ = {lambda_risk:.2f}') axes[0, 1].plot(time_points[1:], trade_list / total_shares, linewidth=2, label=f'λ = {lambda_risk:.2f}') axes[0, 0].set_xlabel('Time (fraction of day)') axes[0, 0].set_ylabel('Remaining Holdings (fraction)') axes[0, 0].set_title('Optimal Execution Trajectories') axes[0, 0].legend() axes[0, 0].grid(True, alpha=0.3) axes[0, 1].set_xlabel('Time (fraction of day)') axes[0, 1].set_ylabel('Trading Rate (fraction)') axes[0, 1].set_title('Optimal Trading Rates') axes[0, 1].legend() axes[0, 1].grid(True, alpha=0.3) # Сравнение затрат на исполнение lambda_labels = [f'{lam:.2f}' for lam in lambda_values] shortfalls = [results[lam]['costs']['shortfall_bps'] for lam in lambda_values] colors = ['green', 'blue', 'red'] axes[1, 0].bar(lambda_labels, shortfalls, color=colors, alpha=0.7) axes[1, 0].set_xlabel('Risk Aversion (λ)') axes[1, 0].set_ylabel('Implementation Shortfall (bps)') axes[1, 0].set_title('Execution Cost vs Risk Aversion') axes[1, 0].grid(True, alpha=0.3, axis='y') # Декомпозиция импакта на рынок для среднего λ mid_lambda = lambda_values[1] permanent = results[mid_lambda]['costs']['permanent_impact'] temporary = results[mid_lambda]['costs']['temporary_impact'] time_intervals = np.linspace(optimizer.tau, T, N) axes[1, 1].fill_between(time_intervals, 0, permanent, alpha=0.5, color='red', label='Permanent Impact') axes[1, 1].fill_between(time_intervals, permanent, permanent + temporary, alpha=0.5, color='orange', label='Temporary Impact') axes[1, 1].set_xlabel('Time (fraction of day)') axes[1, 1].set_ylabel('Cumulative Price Impact') axes[1, 1].set_title(f'Market Impact Components (λ = {mid_lambda:.2f})') axes[1, 1].legend() axes[1, 1].grid(True, alpha=0.3) plt.tight_layout() plt.show() # Вывод результатов print("Almgren-Chriss Optimization Results:\n") for lambda_risk in lambda_values: costs = results[lambda_risk]['costs'] print(f"λ = {lambda_risk:.2f}:") print(f" Implementation Shortfall: {costs['shortfall_bps']:.2f} bps") print(f" Total Execution Cost: ${costs['total_cost']:,.0f}") print(f" vs Benchmark: ${costs['shortfall']:,.0f}\n") Рис. 6: Оптимизация исполнения биржевых ордеров по модели Almgren-Chriss для различных уровней неприятия к риску (risk aversion). Верхняя левая панель показывает траектории holdings: агрессивные стратегии (высокий λ) быстро снижают позицию, консервативные (низкий λ) исполняют медленнее. Верхняя правая — темп торговли в каждый интервал. Нижняя левая демонстрирует сравнение затрат Execution Cost в сравнении с Risk Aversion. Нижняя правая разлагает маркет-импакт на компоненты: permanent (необратимый сдвиг цены) и temporary (краткосрочное давление) Almgren-Chriss Optimization Results: λ = 0.01: Implementation Shortfall: 789.75 bps Total Execution Cost: $107,897,471 vs Benchmark: $7,897,471 λ = 0.10: Implementation Shortfall: 918.79 bps Total Execution Cost: $109,187,937 vs Benchmark: $9,187,937 λ = 1.00: Implementation Shortfall: 1092.72 bps Total Execution Cost: $110,927,198 vs Benchmark: $10,927,198 Результаты показывают, что с ростом λ (то есть с увеличением неприятия риска) стратегия исполняет ордер быстрее, что приводит к росту Implementation Shortfall: при λ = 0.01 оно составляет 789,75 б.п., при λ = 0.10 – 918,79 б.п., а при λ = 1.00 – уже 1092,72 б.п. Соответственно, общая стоимость исполнения и отклонение от базовой цены также увеличиваются, отражая компромисс между минимизацией риска задержки и ростом market impact. Представленный выше код решает задачу оптимального исполнения аналитически через систему дифференциальных уравнений. Оптимальная траектория имеет экспоненциальную форму: более агрессивное исполнение в начале снижает экспозицию к ценовой неопределенности (price uncertainty), однако увеличивает market impact. Параметр κ определяет кривизну траектории. Модель Almgren-Chriss широко применяется институциональными трейдерами для калибровки алгоритмов исполнения ордеров. Она требует оценки коэффициентов рыночного воздействия η и γ, обычно через регрессионный анализ исторических данных. Современные расширения модели учитывают нелинейный импакт, информированную торговлю и динамику книги заявок (LOB). Выпуклая (конвексная) оптимизация в трейдинге Конвексная оптимизация решает задачи с выпуклой целевой функцией и выпуклым множеством ограничений, что гарантирует нахождение глобального оптимума с помощью эффективных алгоритмов. Библиотека CVXPY для Python позволяет удобно формулировать и решать конвексные задачи и широко применяется в финансах: для портфельной оптимизации, хеджирования и ребалансировки с учетом транзакционных издержек. Преимущества конвексной постановки очевидны: Гарантированный глобальный оптимум; Высокая скорость решения даже при тысячах переменных; Строгие математические гарантии. Во многих задачах трейдинга структура естественно выпуклая: минимизация риска портфеля, максимизация функций полезности с убывающей предельной полезностью, оптимизация с линейными ограничениями и ограничениями на объемы сделок. Это делает конвексный подход особенно ценным для построения надежных и масштабируемых торговых стратегий. import numpy as np import pandas as pd import yfinance as yf import cvxpy as cp import matplotlib.pyplot as plt # Загрузка данных для portfolio и hedge portfolio_tickers = ['TSLA', 'F', 'GM', 'RIVN'] # Авто сектор hedge_tickers = ['SPY', 'XLY'] # S&P 500 и Consumer Discretionary ETF portfolio_data = yf.download(portfolio_tickers, start='2023-09-01', end='2025-09-01')['Close'] hedge_data = yf.download(hedge_tickers, start='2023-09-01', end='2025-09-01')['Close'] portfolio_returns = portfolio_data.pct_change().dropna() hedge_returns = hedge_data.pct_change().dropna() # Текущие holdings (в акциях) current_holdings = np.array([500, 1000, 800, 300]) # TSLA, F, GM, RIVN current_prices = portfolio_data.iloc[-1].values portfolio_value = np.sum(current_holdings * current_prices) print(f"Current Portfolio Value: ${portfolio_value:,.0f}") print(f"Current Holdings: {dict(zip(portfolio_tickers, current_holdings))}\n") # Задача 1: Minimum Variance Hedge # Найти веса hedge instruments, минимизирующие variance хеджированного портфеля # Объединенная матрица доходностей combined_returns = pd.concat([portfolio_returns, hedge_returns], axis=1) cov_matrix = combined_returns.cov().values * 252 n_portfolio = len(portfolio_tickers) n_hedge = len(hedge_tickers) # Текущие веса портфеля portfolio_weights = (current_holdings * current_prices) / portfolio_value # CVXPY переменные для hedge позиций (как доля portfolio value) hedge_weights = cp.Variable(n_hedge) # Комбинированные веса: portfolio (фиксирован) + hedge all_weights = cp.hstack([portfolio_weights, hedge_weights]) # Целевая функция: минимизация variance portfolio_variance = cp.quad_form(all_weights, cov_matrix) # Ограничения max_hedge_notional = 0.4 # Максимум 40% от стоимости портфеля на хедж constraints = [ cp.sum(cp.abs(hedge_weights)) <= max_hedge_notional, # Максимальный размер хеджа ] # Решение problem = cp.Problem(cp.Minimize(portfolio_variance), constraints) problem.solve() optimal_hedge_weights = hedge_weights.value optimal_variance = portfolio_variance.value # Hedged portfolio характеристики unhedged_weights = np.concatenate([portfolio_weights, np.zeros(n_hedge)]) unhedged_variance = unhedged_weights.T @ cov_matrix @ unhedged_weights print("Minimum Variance Hedge Results:") print(f"Optimal Hedge Weights: {dict(zip(hedge_tickers, optimal_hedge_weights))}") print(f"Hedge Notional: ${np.sum(np.abs(optimal_hedge_weights)) * portfolio_value:,.0f}") print(f"Unhedged Variance: {unhedged_variance:.6f}") print(f"Hedged Variance: {optimal_variance:.6f}") print(f"Variance Reduction: {(1 - optimal_variance/unhedged_variance) * 100:.1f}%\n") # Задача 2: Portfolio Rebalancing с Transaction Costs # Ребалансировка к целевым весам с минимизацией издержек target_weights = np.array([0.30, 0.25, 0.25, 0.20]) # Целевое распределение transaction_cost_rate = 0.001 # 10 bps на покупку/продажу # Текущие веса (до ребалансировки) current_weights_actual = (current_holdings * current_prices) / portfolio_value # Переменные: новые веса new_weights = cp.Variable(n_portfolio) # Tracking error к целевым весам tracking_error = cp.sum_squares(new_weights - target_weights) # Транзакционные издержки (пропорциональны turnover) turnover = cp.sum(cp.abs(new_weights - current_weights_actual)) transaction_costs = transaction_cost_rate * turnover # Ковариационная матрица портфеля portfolio_cov = portfolio_returns.cov().values * 252 # Multi-objective: минимизация tracking error + transaction costs + небольшой penalty на риск risk_penalty = 0.1 objective = tracking_error + transaction_costs + risk_penalty * cp.quad_form(new_weights, portfolio_cov) # Ограничения rebalance_constraints = [ cp.sum(new_weights) == 1, # Fully invested new_weights >= 0, # Long only new_weights <= 0.40 # Максимум 40% в один актив ] # Решение rebalance_problem = cp.Problem(cp.Minimize(objective), rebalance_constraints) rebalance_problem.solve() optimal_new_weights = new_weights.value # Расчет требуемых сделок required_trades_shares = (optimal_new_weights - current_weights_actual) * portfolio_value / current_prices total_turnover = np.sum(np.abs(required_trades_shares * current_prices)) / portfolio_value estimated_costs = total_turnover * transaction_cost_rate * portfolio_value print("Portfolio Rebalancing Results:") print(f"\nCurrent Weights: {dict(zip(portfolio_tickers, np.round(current_weights_actual, 3)))}") print(f"Target Weights: {dict(zip(portfolio_tickers, target_weights))}") print(f"Optimal New Weights: {dict(zip(portfolio_tickers, np.round(optimal_new_weights, 3)))}") print(f"\nRequired Trades (shares):") for ticker, trades in zip(portfolio_tickers, required_trades_shares): action = "BUY" if trades > 0 else "SELL" print(f" {ticker}: {action} {abs(trades):.0f} shares") print(f"\nTotal Turnover: {total_turnover:.2%}") print(f"Estimated Transaction Costs: ${estimated_costs:,.0f}") # Визуализация fig, axes = plt.subplots(1, 2, figsize=(14, 6)) # Hedge comparison categories = ['Unhedged', 'Hedged'] variances = [unhedged_variance, optimal_variance] volatilities = [np.sqrt(v) * 100 for v in variances] axes[0].bar(categories, volatilities, color=['red', 'green'], alpha=0.7) axes[0].set_ylabel('Annual Volatility (%)', fontsize=11) axes[0].set_title('Portfolio Risk: Unhedged vs Hedged', fontsize=12) axes[0].grid(True, alpha=0.3, axis='y') for i, (cat, vol) in enumerate(zip(categories, volatilities)): axes[0].text(i, vol + 0.5, f'{vol:.1f}%', ha='center', fontsize=10, fontweight='bold') # Rebalancing comparison x = np.arange(len(portfolio_tickers)) width = 0.25 axes[1].bar(x - width, current_weights_actual, width, label='Current', color='gray', alpha=0.7) axes[1].bar(x, target_weights, width, label='Target', color='blue', alpha=0.7) axes[1].bar(x + width, optimal_new_weights, width, label='Optimal', color='green', alpha=0.7) axes[1].set_xlabel('Assets', fontsize=11) axes[1].set_ylabel('Weight', fontsize=11) axes[1].set_title('Portfolio Weights Comparison', fontsize=12) axes[1].set_xticks(x) axes[1].set_xticklabels(portfolio_tickers) axes[1].legend() axes[1].grid(True, alpha=0.3, axis='y') plt.tight_layout() plt.show() Current Portfolio Value: $175,341 Current Holdings: {'TSLA': np.int64(500), 'F': np.int64(1000), 'GM': np.int64(800), 'RIVN': np.int64(300)} Minimum Variance Hedge Results: Optimal Hedge Weights: {'SPY': np.float64(-1.387574815140777e-23), 'XLY': np.float64(-0.4)} Hedge Notional: $70,136 Unhedged Variance: 0.187115 Hedged Variance: 0.131513 Variance Reduction: 29.7% Portfolio Rebalancing Results: Current Weights: {'TSLA': np.float64(0.034), 'F': np.float64(0.333), 'GM': np.float64(0.062), 'RIVN': np.float64(0.571)} Target Weights: {'TSLA': np.float64(0.3), 'F': np.float64(0.25), 'GM': np.float64(0.25), 'RIVN': np.float64(0.2)} Optimal New Weights: {'TSLA': np.float64(0.304), 'F': np.float64(0.255), 'GM': np.float64(0.243), 'RIVN': np.float64(0.198)} Required Trades (shares): TSLA: BUY 4022 shares F: SELL 234 shares GM: BUY 2343 shares RIVN: SELL 196 shares Total Turnover: 90.27% Estimated Transaction Costs: $158 Рис. 7: Результаты конвексной оптимизации для хеджирования и ребалансировки портфеля. Левая панель показывает снижение волатильности через оптимальный хедж с использованием ETF. Правая панель сравнивает текущие, целевые, и оптимальные веса после учета транзакционных издержек. Алгоритм находит баланс между точностью следования за целевыми весами и минимизацией объема сделок, поэтому новые веса изменяются относительно текущих лишь настолько, чтобы сократить транзакционные издержки Интерпретация: Оптимальный хедж через ETF XLY снизил годовую волатильность портфеля с ~43% до ~36%, что дало 29,7% сокращение дисперсии; Ребалансировка портфеля с учетом транзакционных издержек изменила текущие веса: TSLA увеличена с 3,4% до 30,4%, F с 33,3% до 25,5%, GM с 6,2% до 24,3%, RIVN уменьшена с 57,1% до 19,8%; Общий оборот составил 90,3% стоимости портфеля; Предполагаемые издержки на ребалансировки – всего $158, что демонстрирует, что оптимизация одновременно снижает риск и контролирует расходы на сделки. Приведенный код решает две задачи конвексной оптимизации: Minimum Variance Hedge — вычисляет оптимальные позиции в хеджирующих инструментах для снижения риска существующего портфеля. Задача формулируется как квадратичное программирование: минимизация квадратичной формы w^T Σ w при линейных ограничениях на размер хеджа. Portfolio Rebalancing — учитывает транзакционные издержки при приведении портфеля к целевым весам. Многоцелевой функционал одновременно минимизирует ошибку к целевому распределению (tracking error), издержки от оборота портфеля (portfolio turnover) и небольшое штрафное значение за риск. CVXPY автоматически подбирает подходящий solver (OSQP, ECOS, SCS) в зависимости от структуры задачи. Результаты демонстрируют практическую ценность оптимизации: хеджирование снижает волатильность портфеля при ограниченном объеме капитала, а ребалансировка находит баланс между точным отслеживанием целевых весов и минимизацией транзакционных издержек. Оптимальные веса отклоняются от целевых там, где прямое соответствие потребовало бы больших сделок, что позволяет снизить расходы и поддерживать эффективность портфеля. Квадратичное программирование Квадратичное программирование (QP) — частный случай конвексной оптимизации с квадратичной целевой функцией и линейными ограничениями. Большинство портфельных задач естественно формулируются как QP: минимизация variance, максимизация utility, оптимизация mean-variance. Стандартная форма QP: min (1/2) x^T Q x + c^T x subject to: Ax ≤ b, A_eq x = b_eq где: x — вектор переменных (веса активов); Q — матрица квадратичных коэффициентов (обычно ковариация); c — вектор линейных коэффициентов (ожидаемые доходности с минусом); A, b — матрица и вектор линейных неравенств; A_eq, b_eq — ограничения-равенства. Множитель 1/2 перед квадратичным членом упрощает вычисление производных. Для портфельной оптимизации: Q = Σ c = -λμ где: Σ - ковариационная матрица доходностей; λ — коэффициент компромисса между доходностью и риском; μ — вектор ожидаемых доходностей активов. import numpy as np import pandas as pd import yfinance as yf import cvxpy as cp import matplotlib.pyplot as plt from scipy.optimize import minimize # Загрузка данных tickers = ['VALE', 'PBR', 'ITUB', 'BBD', 'ABEV'] # Бразильские акции data = yf.download(tickers, start='2022-09-01', end='2025-09-01')['Close'] returns = data.pct_change().dropna() mean_returns = returns.mean() * 252 cov_matrix = returns.cov().values * 252 n_assets = len(tickers) # Задача: Максимизация utility = доходность - (λ/2) * риск с ограничениями def solve_qp_cvxpy(mean_returns, cov_matrix, risk_aversion, min_weight=0.0, max_weight=0.30): """Решение QP через CVXPY""" n = len(mean_returns) w = cp.Variable(n) # Utility function: return - (risk_aversion/2) * variance portfolio_return = mean_returns @ w portfolio_variance = cp.quad_form(w, cov_matrix) utility = portfolio_return - (risk_aversion / 2) * portfolio_variance constraints = [ cp.sum(w) == 1, w >= min_weight, w <= max_weight ] problem = cp.Problem(cp.Maximize(utility), constraints) problem.solve(solver=cp.OSQP) return w.value, problem.value def solve_qp_scipy(mean_returns, cov_matrix, risk_aversion, min_weight=0.0, max_weight=0.30): """Решение QP через scipy.optimize (квадратичная аппроксимация)""" n = len(mean_returns) def objective(w): portfolio_return = np.dot(mean_returns, w) portfolio_variance = np.dot(w.T, np.dot(cov_matrix, w)) return -(portfolio_return - (risk_aversion / 2) * portfolio_variance) def objective_grad(w): return -(mean_returns - risk_aversion * np.dot(cov_matrix, w)) constraints = [ {'type': 'eq', 'fun': lambda w: np.sum(w) - 1} ] bounds = tuple((min_weight, max_weight) for _ in range(n)) initial_guess = np.array([1/n] * n) result = minimize(objective, initial_guess, method='SLSQP', jac=objective_grad, bounds=bounds, constraints=constraints) return result.x, -result.fun # Сравнение методов для различных уровней risk aversion risk_aversions = [0.5, 2.0, 10.0] comparison_results = [] for ra in risk_aversions: # CVXPY solution weights_cvx, utility_cvx = solve_qp_cvxpy(mean_returns.values, cov_matrix, ra) ret_cvx = np.dot(weights_cvx, mean_returns.values) risk_cvx = np.sqrt(np.dot(weights_cvx.T, np.dot(cov_matrix, weights_cvx))) # Scipy solution weights_scipy, utility_scipy = solve_qp_scipy(mean_returns.values, cov_matrix, ra) ret_scipy = np.dot(weights_scipy, mean_returns.values) risk_scipy = np.sqrt(np.dot(weights_scipy.T, np.dot(cov_matrix, weights_scipy))) comparison_results.append({ 'risk_aversion': ra, 'weights_cvx': weights_cvx, 'weights_scipy': weights_scipy, 'return_cvx': ret_cvx, 'return_scipy': ret_scipy, 'risk_cvx': risk_cvx, 'risk_scipy': risk_scipy, 'utility_cvx': utility_cvx, 'utility_scipy': utility_scipy }) # Построение эффективной границы с QP target_returns = np.linspace(mean_returns.min(), mean_returns.max(), 30) efficient_frontier_qp = [] for target in target_returns: w = cp.Variable(n_assets) portfolio_risk = cp.quad_form(w, cov_matrix) constraints = [ cp.sum(w) == 1, mean_returns.values @ w == target, w >= 0, w <= 0.30 ] problem = cp.Problem(cp.Minimize(portfolio_risk), constraints) problem.solve(solver=cp.OSQP) if problem.status == 'optimal': efficient_frontier_qp.append({ 'return': target, 'risk': np.sqrt(problem.value), 'weights': w.value }) ef_df = pd.DataFrame(efficient_frontier_qp) # Визуализация fig, axes = plt.subplots(2, 2, figsize=(14, 10)) # Efficient Frontier axes[0, 0].plot(ef_df['risk'] * 100, ef_df['return'] * 100, 'k-', linewidth=2, label='Efficient Frontier') # Оптимальные портфели для разных risk aversion colors = ['green', 'blue', 'red'] for idx, (ra, color) in enumerate(zip(risk_aversions, colors)): res = comparison_results[idx] axes[0, 0].scatter(res['risk_cvx'] * 100, res['return_cvx'] * 100, s=150, c=color, marker='*', edgecolors='black', linewidths=1.5, label=f'λ = {ra}') axes[0, 0].set_xlabel('Volatility (%)', fontsize=11) axes[0, 0].set_ylabel('Expected Return (%)', fontsize=11) axes[0, 0].set_title('Efficient Frontier with Optimal Portfolios', fontsize=12) axes[0, 0].legend() axes[0, 0].grid(True, alpha=0.3) # Weights comparison для низкого risk aversion ra_idx = 0 res = comparison_results[ra_idx] [ra_idx] x = np.arange(len(tickers)) width = 0.35 axes[0, 1].bar(x - width/2, res['weights_cvx'], width, label='CVXPY', color='blue', alpha=0.7) axes[0, 1].bar(x + width/2, res['weights_scipy'], width, label='Scipy', color='green', alpha=0.7) axes[0, 1].set_xlabel('Assets', fontsize=11) axes[0, 1].set_ylabel('Weight', fontsize=11) axes[0, 1].set_title(f'Weights Comparison (λ = {risk_aversions[ra_idx]})', fontsize=12) axes[0, 1].set_xticks(x) axes[0, 1].set_xticklabels(tickers) axes[0, 1].legend() axes[0, 1].grid(True, alpha=0.3, axis='y') # Risk-return trade-off returns_list = [res['return_cvx'] for res in comparison_results] risks_list = [res['risk_cvx'] for res in comparison_results] axes[1, 0].plot(risk_aversions, returns_list, 'o-', color='blue', linewidth=2, markersize=8, label='Return') ax2 = axes[1, 0].twinx() ax2.plot(risk_aversions, risks_list, 's-', color='red', linewidth=2, markersize=8, label='Risk') axes[1, 0].set_xlabel('Risk Aversion (λ)', fontsize=11) axes[1, 0].set_ylabel('Expected Return', fontsize=11, color='blue') ax2.set_ylabel('Volatility', fontsize=11, color='red') axes[1, 0].set_title('Risk-Return Trade-off vs Risk Aversion', fontsize=12) axes[1, 0].tick_params(axis='y', labelcolor='blue') ax2.tick_params(axis='y', labelcolor='red') axes[1, 0].grid(True, alpha=0.3) # Portfolio concentration (Herfindahl index) herfindahl_indices = [] for ra in risk_aversions: w_cvx, _ = solve_qp_cvxpy(mean_returns.values, cov_matrix, ra) hhi = np.sum(w_cvx**2) herfindahl_indices.append(hhi) axes[1, 1].bar([str(ra) for ra in risk_aversions], herfindahl_indices, color=colors, alpha=0.7) axes[1, 1].set_xlabel('Risk Aversion (λ)', fontsize=11) axes[1, 1].set_ylabel('Herfindahl Index', fontsize=11) axes[1, 1].set_title('Portfolio Concentration', fontsize=12) axes[1, 1].axhline(y=1/n_assets, color='black', linestyle='--', linewidth=1, label='Equal Weight') axes[1, 1].legend() axes[1, 1].grid(True, alpha=0.3, axis='y') plt.tight_layout() plt.show() # Численные результаты print("Quadratic Programming Results:\n") for idx, ra in enumerate(risk_aversions): res = comparison_results[idx] print(f"Risk Aversion λ = {ra}:") print(f" Expected Return: {res['return_cvx'] * 100:.2f}%") print(f" Volatility: {res['risk_cvx'] * 100:.2f}%") print(f" Sharpe Ratio: {res['return_cvx'] / res['risk_cvx']:.2f}") print(f" Utility: {res['utility_cvx']:.4f}") print(f" Weights: {dict(zip(tickers, np.round(res['weights_cvx'], 3)))}") print(f" Herfindahl Index: {np.sum(res['weights_cvx']**2):.3f}\n") # Проверка согласованности методов print("Solver Comparison (CVXPY vs Scipy):") for idx, ra in enumerate(risk_aversions): res = comparison_results[idx] weights_diff = np.max(np.abs(res['weights_cvx'] - res['weights_scipy'])) utility_diff = abs(res['utility_cvx'] - res['utility_scipy']) print(f"λ = {ra}: Max weight diff = {weights_diff:.6f}, Utility diff = {utility_diff:.6f}") Рис. 8: Квадратичное программирование для портфельной оптимизации. Верхняя левая панель показывает эффективную границу с оптимальными портфелями для трех уровней risk aversion (зеленый — низкий, синий — средний, красный — высокий). Верхняя правая сравнивает веса от CVXPY и Scipy solvers. Нижняя левая демонстрирует влияние λ на риск и доходность: синяя линия — ожидаемая доходность снижается, красная — волатильность падает. Нижняя правая — концентрация портфеля через Herfindahl index: высокий risk aversion приводит к более диверсифицированным портфелям Quadratic Programming Results: Risk Aversion λ = 0.5: Expected Return: 19.10% Volatility: 29.23% Sharpe Ratio: 0.65 Utility: 0.1697 Weights: {'VALE': np.float64(-0.0), 'PBR': np.float64(0.3), 'ITUB': np.float64(0.3), 'BBD': np.float64(0.3), 'ABEV': np.float64(0.1)} Herfindahl Index: 0.280 Risk Aversion λ = 2.0: Expected Return: 18.61% Volatility: 27.41% Sharpe Ratio: 0.68 Utility: 0.1109 Weights: {'VALE': np.float64(-0.0), 'PBR': np.float64(0.115), 'ITUB': np.float64(0.3), 'BBD': np.float64(0.3), 'ABEV': np.float64(0.285)} Herfindahl Index: 0.274 Risk Aversion λ = 10.0: Expected Return: 16.76% Volatility: 25.72% Sharpe Ratio: 0.65 Utility: -0.1631 Weights: {'VALE': np.float64(0.222), 'PBR': np.float64(-0.0), 'ITUB': np.float64(0.3), 'BBD': np.float64(0.291), 'ABEV': np.float64(0.186)} Herfindahl Index: 0.259 Solver Comparison (CVXPY vs Scipy): λ = 0.5: Max weight diff = 0.000000, Utility diff = 0.000000 λ = 2.0: Max weight diff = 0.001170, Utility diff = 0.000000 λ = 10.0: Max weight diff = 0.001227, Utility diff = 0.000001 Код сравнивает два подхода к решению QP: специализированный solver CVXPY (OSQP) и общий оптимизатор scipy с SLSQP. OSQP эксплуатирует структуру квадратичной задачи и работает быстрее для больших портфелей (1000+ активов). SLSQP использует последовательное квадратичное программирование — итеративную аппроксимацию нелинейных задач. Эффективная граница портфеля (Efficient frontier) строится через серию QP задач: для каждой целевой доходности минимизируется риск. Полученная кривая представляет Парето-оптимальные портфели — улучшение доходности возможно только через увеличение риска. Звездочки на графике показывают оптимальные портфели для разных λ: они лежат на касательных к эффективной границе. График risk-return trade-off демонстрирует эффект параметра λ: его увеличение снижает ожидаемую доходность, однако при этом снижает и волатильность. Herfindahl index измеряет концентрацию портфеля: значения близкие к 1/N указывают на равномерное распределение, высокие — на концентрацию в нескольких активах. Заключение Оптимизация в биржевой торговле позволяет системно балансировать доходность, риск и издержки. Использование конвексных методов, квадратичного программирования и современных подходов к настройке стратегий (Random Search, байесовская оптимизация, Walk-forward) демонстрирует, что даже при ограниченном объеме капитала и высокой волатильности можно находить эффективные портфели и адаптивные параметры стратегий с высоким коэффициентом Шарпа и контролируемым риском. Понимание того, как использовать оптимизационные методы в биржевой торговле, позволяет принимать более обоснованные инвестиционные решения: минимизировать волатильность портфеля через хеджирование, балансировать риск между активами, снижать транзакционные издержки и повышать устойчивость торговых стратегий к рыночным изменениям. Такой подход обеспечивает системность в управлении капиталом и делает стратегии воспроизводимыми и пригодными для реальной торговли. ### Wavelet-анализ финансовых данных: преобразование Фурье vs вейвлеты, многомасштабный анализ волатильности Финансовые временные ряды нестационарны: волатильность меняется, тренды возникают и исчезают, корреляции нестабильны. Классический частотный анализ предполагает постоянство частотных компонент во времени, что противоречит природе рыночных данных. Вейвлет-преобразование решает эту проблему через одновременный анализ во временной и частотной областях. Эта статья сравнивает подходы Фурье и вейвлетов к анализу финансовых данных, объясняет механику вейвлет-преобразования и показывает практическое применение для многомасштабного анализа волатильности. Преобразование Фурье и его ограничения Преобразование Фурье раскладывает сигнал на сумму синусоид с различными частотами. Для дискретного сигнала формула выглядит так: X(k) = Σ(n=0 to N-1) x(n) · e^(-i2πkn/N) где: X(k) — коэффициент для частоты k; x(n) — значение сигнала в момент n; N — длина сигнала; i — мнимая единица. Преобразование показывает, какие частоты присутствуют в сигнале, но не показывает, когда они активны. Проблема стационарности Метод анализа Фурье эффективен для стационарных процессов, где частотный состав не меняется. Цена актива в спокойном рынке может демонстрировать относительно постоянные циклы. Но при смене режима — например, переходе от низкой к высокой волатильности — частотная структура трансформируется. Фурье-преобразование усредняет всю историю и не отражает эти изменения локально. Метод Short-Time Fourier Transform (STFT) частично решает проблему через анализ окон фиксированной длины. Однако приходится жертвовать одним из двух: узкое окно дает хорошее временное разрешение, но плохое частотное; широкое окно — наоборот. Принцип неопределенности Габора ограничивает одновременную точность во времени и частоте. Потеря временной локализации Коэффициенты Фурье отражают наличие частоты во всем сигнале. Если краткосрочный всплеск волатильности длился 2-3 дня, Фурье-анализ месячных данных размазывает этот сигнал по всему периоду. Для построения торговых стратегий критична точная локализация: нужно знать не только что волатильность изменилась, но и когда именно. import numpy as np import matplotlib.pyplot as plt from scipy import signal # Генерация сигнала с изменяющейся частотой t = np.linspace(0, 10, 1000) freq = np.where(t < 5, 2, 10) # 2 Гц до 5 сек, потом 10 Гц sig = np.sin(2 * np.pi * freq * t) # Фурье-преобразование fft = np.fft.fft(sig) freqs = np.fft.fftfreq(len(sig), t[1] - t[0]) power = np.abs(fft[:len(fft)//2]) # Создаём фигуру с 3 подграфиками plt.figure(figsize=(12, 10)) # Исходный сигнал plt.subplot(3, 1, 1) plt.plot(t, sig, color='steelblue') plt.title("Исходный сигнал с изменяющейся частотой") plt.xlabel("Время (с)") plt.ylabel("Амплитуда") plt.grid(True) # Амплитудный спектр plt.subplot(3, 1, 2) plt.plot(freqs[:len(freqs)//2], power, color='darkorange') plt.title("Амплитудный спектр (Фурье-преобразование)") plt.xlabel("Частота (Гц)") plt.ylabel("Амплитуда") plt.grid(True) # Спектрограмма (временная локализация частот) f, t_spec, Sxx = signal.spectrogram(sig, fs=1/(t[1]-t[0]), nperseg=128) plt.subplot(3, 1, 3) plt.pcolormesh(t_spec, f, 10 * np.log10(Sxx), shading='gouraud', cmap='viridis') plt.title("Спектрограмма сигнала (время ↔ частота)") plt.ylabel("Частота (Гц)") plt.xlabel("Время (с)") plt.colorbar(label='Мощность (дБ)') plt.tight_layout() plt.show() Рис. 1: Визуализация сигнала с изменяющейся частотой: исходный временной ряд, его амплитудный спектр и спектрограмма. Графики демонстрируют переход сигнала от низкой частоты (около 2 Гц) к высокой (около 10 Гц) и соответствующее изменение частотного состава во времени Пример выше демонстрирует базовую проблему: Фурье-спектр показывает присутствие частот 2 Гц и 10 Гц, но не указывает, что первая доминирует в начале сигнала, а вторая — в конце. Wavelet-преобразование: основы Вейвлет-преобразование использует базисные функции (вейвлеты), которые локализованы и во времени, и по частоте. Вейвлет — это короткая волна с нулевым средним, которая масштабируется и сдвигается вдоль сигнала. Непрерывное вейвлет-преобразование (CWT) определяется как: W(a,b) = (1/√a) ∫ x(t) · ψ*((t-b)/a) dt где: W(a,b) — вейвлет-коэффициент; a — параметр масштаба (связан с частотой); b — параметр сдвига (временная позиция); ψ(t) — материнский вейвлет; ψ* — комплексно-сопряженная функция; x(t) — анализируемый сигнал. Масштаб a обратно пропорционален частоте: малые значения соответствуют высоким частотам, большие — низким. Сдвиг b определяет временную позицию анализа. Преобразование вычисляет корреляцию между сигналом и масштабированным вейвлетом в каждой точке. Высокий коэффициент означает сильное сходство сигнала с вейвлетом данного масштаба в данный момент времени. Типы вейвлетов Выбор вейвлета зависит от характеристик анализируемого сигнала и задачи: Morlet вейвлет — комплексная функция, представляющая синусоиду в гауссовом окне. Хорошо локализован по частоте, применяется для выделения осциллирующих компонент. Оптимален для анализа периодичности в ценах и волатильности. Mexican Hat — вторая производная гауссианы, симметричная функция. Эффективна для обнаружения резких изменений и экстремумов. Подходит для поиска разворотов тренда и аномалий. Daubechies (db) — семейство ортогональных вейвлетов с компактным носителем. Используются в дискретном вейвлет-преобразовании (DWT) для декомпозиции сигнала. Параметр N определяет количество коэффициентов: db4, db6, db8. Высокие значения дают лучшее частотное разрешение, низкие — временное. Symlets — модификация Daubechies с улучшенной симметрией. Снижают фазовые искажения при реконструкции сигнала. Давайте рассмотрим разницу отработки сигнала каждым типом вейвлетов. import numpy as np import matplotlib.pyplot as plt import pywt # Параметры np.random.seed(42) n = 200 # длина ряда t = np.linspace(0, 2, n) # 2 секунды fs = (n - 1) / (t[-1] - t[0]) # приближенная частота дискретизации # Генерация исходного сигнала sig = ( np.sin(2 * np.pi * 5 * t) # основная низкая частота 5 Гц + 0.5 * np.sin(2 * np.pi * 20 * t) # более высокая 20 Гц + np.where((t > 0.8) & (t < 1.0), 3, 0) # резкий скачок (широкий импульс) + np.where((t > 1.4) & (t < 1.42), 4 * np.exp(-200 * (t - 1.41) ** 2), 0) # узкий пик + 0.2 * np.random.randn(len(t)) # шум ) wavelets = ['morl', 'mexh', 'db4', 'sym4'] # Визуализации fig, axes = plt.subplots(5, 1, figsize=(12, 14), constrained_layout=True) # Исходный сигнал ax = axes[0] ax.plot(t, sig, color='black', linewidth=1) ax.set_title('Исходный сигнал (n=200)') ax.set_ylabel('Амплитуда') ax.grid(True) # CWT для morl и mexh for idx, name in enumerate(['morl', 'mexh'], start=1): ax = axes[idx] scales = np.arange(1, 64) coef, freqs = pywt.cwt(sig, scales, name, sampling_period=(t[1] - t[0])) S = np.abs(coef) S = S / (S.max() + 1e-12) im = ax.pcolormesh(t, freqs, S, shading='auto') ax.set_ylabel(f'{name}') ax.set_ylim(freqs.max(), freqs.min()) ax.set_title(f'CWT: {name} (нормированная амплитуда)') fig.colorbar(im, ax=ax, orientation='vertical', label='Нормированная амплитуда') # DWT для db4 и sym4 for idx, name in enumerate(['db4', 'sym4'], start=3): ax = axes[idx] max_level = pywt.dwt_max_level(len(sig), pywt.Wavelet(name).dec_len) level = min(5, max_level) coeffs = pywt.wavedec(sig, name, level=level) details = coeffs[1:] det_matrix = [] # порядок: cD_n, cD_{n-1}, ..., cD1 for lev, c in enumerate(details, start=1): try: rec = pywt.upcoef('d', c, wavelet=name, level=len(details) - (lev - 1), take=len(sig)) except Exception: # fallback: интерполируем деталь до длины сигнала rec = np.interp(np.linspace(0, 1, len(sig)), np.linspace(0, 1, len(c)), c) rec = rec - np.mean(rec) det_matrix.append(rec) det_matrix = np.vstack(det_matrix) # нормализация построчно для визуальной читаемости det_norm = det_matrix / (np.max(np.abs(det_matrix), axis=1, keepdims=True) + 1e-12) im = ax.imshow(det_norm, aspect='auto', extent=[t[0], t[-1], 1, det_norm.shape[0]], origin='lower', cmap='RdBu_r') ax.set_ylabel(f'{name} (детали)') ax.set_yticks(np.arange(1, det_norm.shape[0] + 1)) ax.set_yticklabels([f'd{len(details)-i+1}' for i in range(det_norm.shape[0])]) # dN..d1 ax.set_title(f'DWT детали (уровни) — {name}') fig.colorbar(im, ax=ax, orientation='vertical', label='Нормированная амплитуда') axes[-1].set_xlabel('Время (с)') plt.show() Рис. 2: Сравнение обработки одного и того же сигнала различными типами вейвлетов В верхней части показан исходный сигнал, содержащий низко- и высокочастотные компоненты, резкий скачок и короткий импульс. Следующие два графика иллюстрируют результаты непрерывного вейвлет-преобразования (CWT) с использованием вейвлетов Morlet и Mexican Hat. Два нижних графика демонстрируют дискретное вейвлет-разложение (DWT) с вейвлетами Daubechies (db4) и Symlet (sym4). Для CWT построена скалограмма (время ↔ частота): различные непрерывные вейвлеты по-разному локализуют энергию пиков и переходов во времени и по частоте. Для DWT показаны детальные коэффициенты, восстановленные до длины исходного сигнала, что позволяет наблюдать, на каких временных интервалах и масштабах каждая дискретная база реагирует на скачки и импульсы. В контексте финансовых временных рядов вейвлет Morlet чаще применяют для анализа цикличности и скрытых колебаний, тогда как Daubechies — для сглаживания и фильтрации шума. Время-частотная локализация Вейвлет-преобразование создает двумерное представление сигнала: время на одной оси, масштаб (частота) на другой, интенсивность коэффициентов показывает амплитуду компоненты. Такие визуализации называют скалограммами — аналог спектрограммы STFT, но с адаптивным разрешением: На низких частотах (большие масштабы) вейвлет растягивается, захватывая больший временной интервал — хорошее частотное разрешение, слабое временное; На высоких частотах (малые масштабы) вейвлет сжимается — точная временная локализация, но менее точное определение частоты. Это естественное свойство, соответствующее принципу неопределенности, но в отличие от STFT разрешение автоматически адаптируется к частоте. Непрерывное vs дискретное вейвлет-преобразование CWT и DWT решают разные задачи анализа финансовых данных. Continuous Wavelet Transform: спектрограммы волатильности CWT вычисляет коэффициенты для непрерывного набора масштабов и позиций. Результат — детальная карта время-частотного распределения энергии сигнала. Применяется для визуализации динамики волатильности, выявления периодичности и анализа корреляций между активами. import numpy as np import matplotlib.pyplot as plt import pywt np.random.seed(42) t = np.arange(0, 200) price = 100 * np.exp(np.cumsum(np.random.randn(200) * 0.01)) # Всплеск волатильности price[100:150] += np.random.randn(50) * 10 # CWT scales = np.arange(1, 50) coefficients, frequencies = pywt.cwt(price, scales, 'morl') # Нормировка для визуализации S = np.abs(coefficients) S = S / (S.max() + 1e-12) plt.figure(figsize=(10, 6)) plt.subplot(2, 1, 1) plt.plot(t, price, color='steelblue') plt.title('Временной ряд с изменяющейся волатильностью') plt.ylabel('Цена') plt.grid(True) plt.subplot(2, 1, 2) plt.pcolormesh(t, frequencies, S, shading='auto', cmap='viridis') plt.title('Непрерывное вейвлет-преобразование (Morlet)') plt.xlabel('Время') plt.ylabel('Частота') plt.ylim(frequencies.max(), frequencies.min()) # высокие частоты сверху plt.colorbar(label='Нормированная амплитуда') plt.tight_layout() plt.show() Рис. 3: Анализ волатильности с помощью непрерывного вейвлет-преобразования (CWT) Верхний график иллюстрирует синтетический ценовой ряд с локальным всплеском волатильности. Нижний график показывает скалограмму, построенную с использованием вейвлета Morlet. Высокие значения коэффициентов (области высокой интенсивности) отражают периоды возрастания динамической активности сигнала. На скалограмме заметно, что в диапазоне наблюдений 100–150 увеличилась энергия на малых масштабах (высокочастотные колебания), что соответствует всплеску волатильности в ценовом ряде. Яркие области на карте визуализируют локальные изменения энергии сигнала и позволяют наглядно оценить временную динамику волатильности. При этом CWT имеет свои ограничения: коэффициенты сильно коррелированы между соседними масштабами, что приводит к избыточности данных, а вычислительная сложность возрастает с увеличением длины ряда и числа используемых масштабов. Discrete Wavelet Transform: декомпозиция сигнала DWT декомпозирует сигнал на ортогональные компоненты через последовательную фильтрацию и субдискретизацию. Каждый уровень разложения разделяет сигнал на приближение (approximation) и детали (details): Приближение — низкочастотная компонента, тренд сигнала; Детали — высокочастотные колебания, шум и краткосрочные флуктуации; На следующем уровне приближение снова декомпозируется, рекурсивно выделяя все более долгосрочные компоненты. import numpy as np import matplotlib.pyplot as plt import pywt # Синтетический ряд с волатильностью np.random.seed(42) t = np.arange(0, 200) price = 100 * np.exp(np.cumsum(np.random.randn(200) * 0.01)) price[100:150] += np.random.randn(50) * 10 # всплеск волатильности # Дискретное вейвлет-преобразование (DWT) wavelet = 'db4' level = 4 coeffs = pywt.wavedec(price, wavelet, level=level) approx = coeffs[0] # приближение (тренд) details = coeffs[1:] # детали уровней 4,3,2,1 # Восстановление тренда (удаление шума) price_denoised = pywt.waverec([coeffs[0]] + [None]*level, wavelet) price_denoised = price_denoised[:len(price)] # обрезаем до длины исходного ряда # Визуализация plt.figure(figsize=(10, 16)) n_subplots = 1 + 1 + len(details) + 1 # исходный + приближение + детали + восстановленный subplot_idx = 1 # Исходный ряд plt.subplot(n_subplots, 1, subplot_idx) plt.plot(t, price, color='black') plt.title('Исходный ценовой ряд') plt.ylabel('Цена') plt.grid(True) subplot_idx += 1 # Приближение (тренд) plt.subplot(n_subplots, 1, subplot_idx) plt.plot(np.linspace(0, t[-1], len(approx)), approx, color='blue') plt.title('Приближение (тренд) — уровень 0') plt.ylabel('Амплитуда') plt.grid(True) subplot_idx += 1 # Детали уровней 4,3,2,1 for i, d in enumerate(details, start=1): plt.subplot(n_subplots, 1, subplot_idx) plt.plot(np.linspace(0, t[-1], len(d)), d, color='red') plt.title(f'Детали — уровень {level-i+1}') plt.ylabel('Амплитуда') plt.grid(True) subplot_idx += 1 # Восстановленный сигнал без шума plt.subplot(n_subplots, 1, subplot_idx) plt.plot(t, price_denoised, color='green') plt.title('Восстановленный сигнал (только тренд, шум удалён)') plt.ylabel('Цена') plt.xlabel('Время') plt.grid(True) plt.tight_layout() plt.show() Рис. 4: Дискретное вейвлет-преобразование ценового ряда с локальным всплеском волатильности: верхний график показывает исходный сигнал, второй — низкочастотное приближение (тренд), следующие четыре — детали различных уровней, отражающие краткосрочные колебания, а нижний — восстановленный сигнал без шума. Эти визуализации наглядно демонстрируют разложение сигнала на ортогональные компоненты и влияние каждого уровня на временную структуру волатильности Функция wavedec рекурсивно разлагает сигнал на приближение и детали, где approx соответствует низкочастотной компоненте (тренду), а details отражают высокочастотные колебания, шум и краткосрочные флуктуации. С помощью waverec([coeffs[0]] + [None]*level, wavelet) можно восстановить только тренд, исключив шум. Графики позволяют наглядно оценить вклад каждого уровня в структуру сигнала и эффект фильтрации высокочастотных колебаний. DWT эффективен для сжатия данных, удаления шума и выделения трендов. Вычислительная сложность O(n), что позволяет обрабатывать длинные временные ряды в реальном времени. Выбор подхода для конкретных задач CWT применяется для построения детализированной карты изменений волатильности и анализа взаимосвязей между активами с помощью когерентности (Wavelet coherence). Типичные сценарии использования включают визуализацию рыночных режимов, исследовательский анализ временных рядов и построение индикаторов на основе энергии сигнала в определенных частотных диапазонах. DWT используется для фильтрации, сжатия и построения фичей для моделей. Декомпозированные компоненты становятся признаками: тренд, среднесрочные колебания, краткосрочный шум. Модель обучается предсказывать каждую компоненту отдельно или использует их как независимые входы. Если задача требует визуализации и интерпретации, то лучше выбирать CWT. Если нужна эффективная обработка и построение признаков — DWT. Многомасштабный анализ волатильности Волатильность финансовых активов проявляется на разных временных масштабах одновременно. Внутридневные всплески связаны с микроструктурой рынка и потоком ордеров. Недельные колебания отражают реакцию на новости и макроэкономические данные. Месячные и квартальные изменения обусловлены фундаментальными факторами и сезонностью. Вейвлет-декомпозиция разделяет общую волатильность на компоненты разных масштабов, позволяя анализировать их независимо. Декомпозиция временных рядов по масштабам DWT разлагает логарифмические доходности на уровни детализации: Первый уровень (D1) содержит колебания периодом 2-4 бара; Второй (D2) — 4-8 баров; Третий (D3) — 8-16 баров. Каждый следующий уровень удваивает характерный период. # Генерация доходностей с многомасштабной структурой np.random.seed(42) n = 500 t = np.arange(n) trend = -0.0003 * t # Низкочастотный тренд (долгосрочная компонента) mid_cycle = 0.01 * np.sin(2 * np.pi * t / 50) # Среднесрочный цикл (периоды ~50 баров) short_cycle = 0.005 * np.sin(2 * np.pi * t / 10) # Краткосрочный цикл (периоды ~10 баров) base_noise = np.random.randn(n) * 0.01 # Базовый шум # Модуляция волатильности для консолидаций vol_modulation = np.ones(n) vol_modulation[100:150] *= 0.3 # первая консолидация vol_modulation[300:350] *= 0.2 # вторая консолидация # Локальные всплески волатильности vol_spikes = np.zeros(n) vol_spikes[50:60] = np.random.randn(10) * 0.05 vol_spikes[200:210] = np.random.randn(10) * 0.07 vol_spikes[400:410] = np.random.randn(10) * 0.04 # Финальный ряд доходностей returns = trend + mid_cycle + short_cycle + base_noise * vol_modulation + vol_spikes # Декомпозиция на 5 уровней coeffs = pywt.wavedec(returns, 'db6', level=5) # Извлечение компонент approx = coeffs[0] # долгосрочный тренд details = coeffs[1:] # детали от D5 до D1 # Вычисление волатильности на каждом масштабе vol_multiscale = [np.std(d) for d in details] # Визуализация доходностей и DWT компонент plt.figure(figsize=(10, 16)) # Исходные доходности plt.subplot(7, 1, 1) plt.plot(returns, color='black') plt.title('Логарифмические доходности с многомасштабной структурой') plt.ylabel('Доходность') plt.grid(True) # Долгосрочный тренд (approx) plt.subplot(7, 1, 2) plt.plot(np.linspace(0, len(returns)-1, len(approx)), approx, color='blue') plt.title('Долгосрочный тренд (approx)') plt.ylabel('Амплитуда') plt.grid(True) # Детали D5–D1 for i, d in enumerate(details, start=1): plt.subplot(7, 1, i+2) plt.plot(np.linspace(0, len(returns)-1, len(d)), d, color='red') plt.title(f'Detail D{len(details)-i+1} (масштаб {2**(len(details)-i)}–{2**(len(details)-i+1)} баров)') plt.ylabel('Амплитуда') plt.grid(True) plt.xlabel('Время') plt.tight_layout() plt.show() # Вывод волатильности на каждом масштабе for i, vol in enumerate(vol_multiscale[::-1], start=1): print(f'Волатильность на уровне D{i}: {vol:.5f}') Рис. 5: Дискретное вейвлет-преобразование доходностей с многомасштабной структурой: верхний график показывает исходный ряд; второй график — долгосрочный тренд (approx); следующие пять графиков — детали уровней D5–D1, отражающие колебания на разных временных масштабах. Такая визуализация демонстрирует распределение волатильности по масштабам и вклад каждого уровня в структуру временного ряда Волатильность на уровне D1: 0.01498 Волатильность на уровне D2: 0.01334 Волатильность на уровне D3: 0.01525 Волатильность на уровне D4: 0.01345 Волатильность на уровне D5: 0.03566 Декомпозиция показывает, на каких масштабах сконцентрирована волатильность. Если энергия сосредоточена в D1-D2, доминирует краткосрочный шум. Если в D4-D5 — рынок демонстрирует среднесрочные колебания. Выделение краткосрочной vs долгосрочной волатильности Классические модели волатильности (GARCH, EWMA) дают единую оценку, усредняя все масштабы. Вейвлет-подход разделяет вклад разных компонент. Краткосрочная волатильность (D1-D2) отражает ликвидность и микроструктурные эффекты. Высокие значения указывают на широкий bid-ask spread, низкую глубину стакана, проскальзывание. Для HFT стратегий и маркет-мейкинга это ключевой параметр. Долгосрочная волатильность (D4-D6) связана с фундаментальной неопределенностью и макроэкономическими рисками. Рост этой компоненты предшествует крупным коррекциям и смене трендов. Для позиционных стратегий и управления портфелем долгосрочная волатильность определяет размер позиций и хеджирование. Соотношение краткосрочной и долгосрочной волатильности индицирует режим рынка: Если short_vol >> long_vol, наблюдается высокочастотный шум при стабильном тренде; Если long_vol растет при умеренной short_vol, то это значит что формируется крупное движение. Практическое применение Вейвлет-анализ часто используется в торговых системах для фильтрации шума, выявления рыночных режимов и генерации сигналов на основе многомасштабной структуры. Фильтрация шума Высокочастотный шум в ценовых данных искажает сигналы и провоцирует ложные входы. Удаление шумовых компонент через DWT улучшает качество индикаторов и снижает количество сделок. import numpy as np import matplotlib.pyplot as plt import pywt # Генерация сигнала с локальным всплеском волатильности np.random.seed(42) t = np.arange(0, 200) # Тренд + шум с переменной амплитудой base_trend = 0.05 * t volatility = 1 + 2 * np.exp(-0.5 * ((t - 100)/30)**2) noise = np.random.randn(len(t)) * volatility signal = base_trend + noise signal[95:105] += np.linspace(0, 5, 10) # Применяем вейвлет-фильтр def wavelet_denoise(signal, wavelet='db4', level=3, threshold_method='soft'): coeffs = pywt.wavedec(signal, wavelet, level=level) sigma = np.median(np.abs(coeffs[-1])) / 0.6745 threshold = sigma * np.sqrt(2 * np.log(len(signal))) coeffs_thresh = [coeffs[0]] for detail in coeffs[1:]: if threshold_method == 'soft': thresh_detail = pywt.threshold(detail, threshold, mode='soft') else: thresh_detail = pywt.threshold(detail, threshold, mode='hard') coeffs_thresh.append(thresh_detail) denoised = pywt.waverec(coeffs_thresh, wavelet) return denoised[:len(signal)] signal_noisy = signal signal_clean = wavelet_denoise(signal_noisy, level=4) # Визуализация plt.figure(figsize=(12, 5)) plt.plot(signal_noisy, color='gray', alpha=0.6, label='Шумный сигнал') plt.plot(signal_clean, color='steelblue', lw=2, label='Очищенный сигнал (вейвлет)') plt.title('Сравнение шумного и очищенного сигналов') plt.xlabel('Время') plt.ylabel('Амплитуда') plt.legend() plt.grid(True) plt.show() Рис. 6: Сравнение ряда с шумным сигналом и очищенного с помощью вейвлета db4 Представленный код выполняет удаление шума из временного ряда с помощью дискретного вейвлет-преобразования: Функция wavelet_denoise декомпозирует сигнал на приближение и детали; Затем рассчитывает порог на основе медианного абсолютного отклонения последних деталей; Затем обнуляет малые коэффициенты методом soft-thresholding; После чего восстанавливает сигнал через обратное вейвлет-преобразование. В результате получается очищенный сигнал, на котором сохранены тренд и ключевые колебания, включая локальный всплеск, а высокочастотный шум эффективно подавлен. В количественном анализе вейвлет-фильтрация часто применяется к ценовым рядам перед вычислением технических индикаторов или непосредственно к самим индикаторам для их сглаживания. Это позволяет уменьшить влияние краткосрочного шума, выделить устойчивые тренды и улучшить качество сигналов для последующего анализа или построения торговых стратегий. Детекция режимов рынка Распределение энергии по масштабам характеризует текущий режим рынка. Три основных режима: тренд, флэт, высокая волатильность. Тренд: доминирование энергии в approximation и старших уровнях деталей (D4-D5). Низкая краткосрочная волатильность, стабильное направленное движение. Флэт: равномерное распределение энергии по уровням. Отсутствие выраженных компонент, цена колеблется вокруг среднего. Высокая волатильность: концентрация энергии в младших уровнях (D1-D2). Резкие краткосрочные колебания, неопределенность направления. import numpy as np import matplotlib.pyplot as plt import matplotlib.patches as mpatches np.random.seed(42) n = 500 segments = [100, 50, 50, 50, 250] assert sum(segments) == n returns_data = np.zeros(n) regimes_true = [] # Тренд вверх returns_data[0:100] = np.linspace(0, 0.01, 100) + np.random.randn(100)*0.002 regimes_true += ['trend']*100 # Боковик спокойный returns_data[100:150] = np.random.randn(50)*0.003 regimes_true += ['ranging']*50 # Боковик с высокой волатильностью returns_data[150:200] = np.random.randn(50)*0.008 regimes_true += ['high_volatility']*50 # Боковик спокойный returns_data[200:300] = np.random.randn(100)*0.002 regimes_true += ['ranging']*100 # Тренд вниз returns_data[300:500] = np.linspace(0, -0.02, 200) + np.random.randn(200)*0.002 regimes_true += ['trend']*200 # Визуализация colors = {'trend': 'green', 'ranging': 'orange', 'high_volatility': 'red'} plt.figure(figsize=(14,5)) plt.plot(returns_data, color='black', alpha=0.7, label='Доходности') for i in range(n): plt.axvspan(i, i+1, color=colors[regimes_true[i]], alpha=0.3) # Создаем легенду для зон режимов legend_patches = [mpatches.Patch(color=colors[r], alpha=0.3, label=r.capitalize()) for r in colors] plt.legend(handles=[plt.Line2D([0], [0], color='black', lw=2, label='Доходности')] + legend_patches) plt.title('Определение рыночных режимов с помощью вейвлетов') plt.xlabel('Время') plt.ylabel('Доходность') plt.grid(True) plt.show() Рис. 7: Определение рыночных режимов с помощью вейвлетов Детекция режима используется для адаптации параметров стратегии. В трендовом режиме увеличиваются размеры позиций momentum стратегий. В высоковолатильном режиме сокращаются позиции или активируются стратегии на возврат к среднему (mean reversion). Построение торговых сигналов Многомасштабная декомпозиция генерирует сигналы через анализ согласованности компонент разных масштабов: Сигнал на покупку: краткосрочная компонента (D1-D2) направлена вверх, среднесрочная (D3-D4) также растет, долгосрочный тренд (approximation) положителен. Все масштабы согласованы — сильный сигнал; Сигнал на продажу: обратная ситуация, все компоненты направлены вниз; Отсутствие сигнала: компоненты разнонаправлены, нет согласованности масштабов. def wavelet_multiscale_signal(returns, wavelet='db6', level=4, lookback=5): """ Генерация торговых сигналов через согласованность масштабов """ signals = [] # Декомпозиция coeffs = pywt.wavedec(returns, wavelet, level=level) # Реконструкция масштабов short_term = pywt.waverec([None, coeffs[1], coeffs[2]] + [None]*(level-2), wavelet)[:len(returns)] mid_term = pywt.waverec([None, None, None, coeffs[3]] + [None]*(level-3), wavelet)[:len(returns)] long_term = pywt.waverec([coeffs[0]] + [None]*level, wavelet)[:len(returns)] for i in range(lookback, len(returns)): # Направление каждого масштаба short_dir = 1 if np.mean(short_term[i-lookback:i]) > 0 else -1 mid_dir = 1 if np.mean(mid_term[i-lookback:i]) > 0 else -1 long_dir = 1 if np.mean(long_term[i-lookback:i]) > 0 else -1 # Согласованность if short_dir == mid_dir == long_dir == 1: signal = 1 # buy elif short_dir == mid_dir == long_dir == -1: signal = -1 # sell else: signal = 0 # hold signals.append(signal) return signals # Генерация сигналов trade_signals = wavelet_multiscale_signal(returns_data) Подход фильтрует ложные движения: краткосрочный всплеск без поддержки старших масштабов не генерирует сигнал. Стратегия входит только при подтверждении на нескольких временных горизонтах. Параметры lookback и уровни декомпозиции подбираются под таймфрейм торговли. Для внутридневных стратегий используются level=3-4 и lookback=3-5. Для позиционных — level=5-6 и lookback=10-20. Заключение Вейвлет-анализ преодолевает фундаментальное ограничение Фурье-преобразования — потерю временной локализации. Вейвлеты адаптируют разрешение к масштабу явления: детальный анализ краткосрочных колебаний и одновременное отслеживание долгосрочных трендов. Многомасштабная декомпозиция разделяет волатильность на независимые компоненты, каждая из которых отражает процессы определенного временного горизонта. Практическое применение вейвлетов охватывает фильтрацию данных, детекцию режимов рынка и построение торговых сигналов. Вычислительная эффективность DWT делает метод пригодным для работы с потоковыми данными в реальном времени. Для алгоритмических систем это инструмент, который трансформирует сырые ценовые данные в структурированную многомасштабную информацию, напрямую используемую в логике принятия решений. ### Модель ETS для прогнозирования временных рядов Экспоненциальное сглаживание остается одним из надежных методов прогнозирования временных рядов в количественном анализе. Модель ETS (Error, Trend, Seasonality) представляет формализованную версию этого подхода через представление в пространстве состояний. Метод обеспечивает баланс между простотой реализации и качеством прогнозов для данных с выраженной трендовой и сезонной структурой.. ETS работает через рекурсивное обновление компонентов модели: уровня, тренда и сезонности. Каждое новое наблюдение корректирует оценки компонентов с весами, которые определяют скорость адаптации модели к изменениям. В отличие от методологий Box-Jenkins, ETS не требует ручной настройки порядка дифференцирования и сложной идентификации структуры. Математическая основа ETS ETS разделяет временной ряд на три базовых компонента: Уровень (level) отражает текущее значение ряда без учета тренда и сезонности; Тренд (trend) показывает направление изменения уровня во времени; Сезонность (seasonality) описывает повторяющиеся паттерны фиксированной длины. Модель обновляет компоненты на каждом шаге через сглаживающие параметры α, β, γ (значения от 0 до 1). Параметр α контролирует скорость адаптации уровня, β — тренда, γ — сезонных коэффициентов. Высокие значения означают быструю реакцию на новые данные, низкие — сохранение исторических паттернов. Типы комбинирования ETS поддерживает аддитивный и мультипликативный способы комбинирования компонентов: В аддитивном варианте компоненты суммируются: наблюдаемое значение = уровень + тренд + сезонность; Мультипликативный вариант использует произведение: наблюдаемое значение = уровень × тренд × сезонность. Выбор типа комбинирования зависит от характера данных. Аддитивная сезонность подходит для рядов с постоянной амплитудой колебаний. Мультипликативная — когда амплитуда сезонных колебаний растет пропорционально уровню ряда. Для финансовых данных с изменяющейся волатильностью предпочтительнее мультипликативный вариант. Пространство состояний ETS формализует экспоненциальное сглаживание через модель пространства состояний. Состояние системы включает текущие значения уровня, тренда и сезонных коэффициентов. Уравнение наблюдений связывает наблюдаемые значения с компонентами состояния. Уравнение перехода описывает эволюцию состояния во времени. Представление в пространстве состояний дает несколько преимуществ: Возможность применения фильтра Калмана для оценки компонентов; Естественный способ построения доверительных интервалов прогноза; Единый математический аппарат для всех вариантов модели. Ошибка прогноза в ETS может входить в модель аддитивно или мультипликативно. Аддитивная ошибка означает, что случайные отклонения не зависят от уровня ряда. Мультипликативная ошибка предполагает гетероскедастичность — дисперсия растет с уровнем. Эта особенность делает ETS гибче классического экспоненциального сглаживания. Классификация моделей ETS Номенклатура ETS использует трехбуквенный код для обозначения типа каждого компонента: Первая буква — тип ошибки (A для аддитивной, M для мультипликативной); Вторая буква — тип тренда (N отсутствует, A аддитивный, Ad затухающий аддитивный, M мультипликативный, Md затухающий мультипликативный); Третья буква — тип сезонности (N отсутствует, A аддитивная, M мультипликативная). Полное пространство ETS включает 30 теоретически возможных комбинаций. Из них 15 моделей имеют практический смысл и стабильные свойства. Нестабильные варианты исключены из стандартных имплементаций: мультипликативная ошибка с аддитивным трендом или сезонностью приводит к проблемам с отрицательными значениями. Наиболее распространенные спецификации: ETS(A,N,N) — простое экспоненциальное сглаживание без тренда и сезонности; ETS(A,A,N) — метод Хольта с линейным трендом; ETS(A,Ad,N) — затухающий тренд для стабилизации долгосрочных прогнозов; ETS(A,A,A) — метод Хольта-Винтерса с аддитивной сезонностью; ETS(A,A,M) — мультипликативная сезонность для растущих амплитуд; ETS(M,N,M) — мультипликативная модель для волатильных рядов. Выбор типа модели Решение между аддитивной и мультипликативной сезонностью принимается на основе визуального анализа данных. График временного ряда показывает характер изменения амплитуды сезонных колебаний. Постоянная амплитуда указывает на аддитивную сезонность. Растущая амплитуда — на мультипликативную. Затухающий тренд (damped trend) стабилизирует долгосрочные прогнозы. Параметр затухания φ (значение от 0 до 1) контролирует скорость выхода тренда на плато. Значения близкие к 1 дают медленное затухание, близкие к 0 — быстрое. Затухающий тренд предотвращает неограниченный рост или падение прогноза, что критично для горизонтов более 10-15 периодов. Для финансовых рядов с высокой волатильностью мультипликативная ошибка часто показывает лучшие результаты. Доверительные интервалы прогноза автоматически расширяются с ростом уровня ряда, что соответствует реальному поведению рисков. Аддитивная ошибка дает симметричные интервалы независимо от уровня. Автоматический подбор модели Информационные критерии Выбор оптимальной спецификации ETS происходит через перебор допустимых моделей и сравнение информационных критериев. AIC (Akaike Information Criterion) балансирует качество подгонки и сложность модели: AIC = -2×log(L) + 2×k где: L — функция правдоподобия, k — число параметров. Меньшее значение AIC указывает на лучшую модель. AICc (corrected AIC) вводит дополнительную штраф за параметры для малых выборок: AICc = AIC + 2×k×(k+1)/(n-k-1), где n — размер выборки. Для временных рядов длиной менее 100 наблюдений AICc предпочтительнее стандартного AIC. Коррекция предотвращает переобучение на коротких историях. BIC (Bayesian Information Criterion) применяет более жесткий штраф за сложность: BIC = -2×log(L) + k×log(n) BIC склонен выбирать более простые модели по сравнению с AIC. Для прогнозирования AIC и AICc обычно дают лучшие результаты данные отложенных выборок, BIC больше подходит для задач интерпретации структуры. Процедура оптимизации Автоматический подбор модели включает две стадии: Выбор структуры модели (наличие тренда, тип сезонности). Алгоритм перебирает все 15 допустимых комбинаций компонентов, для каждой оценивает параметры сглаживания и вычисляет информационный критерий. Модель с минимальным критерием становится финалистом. Оптимизация параметров сглаживания α, β, γ и начальных состояний. Процедура использует максимизацию правдоподобия через численную оптимизацию. Функция правдоподобия зависит от типа ошибки: гауссовское правдоподобие для аддитивной ошибки, преобразованное правдоподобие для мультипликативной. Начальные значения компонентов оценивают из первых наблюдений: Для уровня — среднее первых нескольких точек; Для тренда — наклон между первыми сезонными циклами; Для сезонных коэффициентов — отношение наблюдений к среднему внутри каждого сезона. Качественная инициализация ускоряет сходимость оптимизации. Практическая реализация на Python Пакет statsmodels содержит имплементацию ETS в модуле statsmodels.tsa.exponential_smoothing.ets. Класс ETSModel поддерживает все 15 допустимых спецификаций и автоматический выбор модели. Метод fit() оценивает параметры через максимизацию правдоподобия. import pandas as pd import numpy as np import yfinance as yf from statsmodels.tsa.exponential_smoothing.ets import ETSModel import matplotlib.pyplot as plt # Загрузка дневных данных ticker = yf.Ticker("TSM") data = ticker.history(start="2022-09-15", end="2025-09-15") # Проверка на MultiIndex и извлечение цен закрытия if isinstance(data.columns, pd.MultiIndex): prices = data['Close'].iloc[:, 0] else: prices = data['Close'] # Преобразование в Series с DatetimeIndex prices = pd.Series(prices.values, index=pd.DatetimeIndex(data.index)) prices = prices.asfreq('D') # Агрегация до недельных данных для выявления паттернов weekly_prices = prices.resample('W-FRI').last().dropna() print(f"Размер выборки: {len(weekly_prices)} недель") print(f"Период: {weekly_prices.index[0]} - {weekly_prices.index[-1]}") print(weekly_prices.tail()) Размер выборки: 157 недель Период: 2022-09-16 00:00:00-04:00 - 2025-09-12 00:00:00-04:00 Date 2025-08-15 00:00:00-04:00 238.128754 2025-08-22 00:00:00-04:00 232.257278 2025-08-29 00:00:00-04:00 230.143936 2025-09-05 00:00:00-04:00 242.644516 2025-09-12 00:00:00-04:00 258.514435 Код загружает котировки акций TSMC через yfinance и агрегирует данные до недельной частоты. Агрегация снижает шум дневных колебаний и делает структурные паттерны более заметными. Метод resample() с якорем 'W-FRI' группирует данные по неделям с окончанием в пятницу. Функция last() берет последнее значение в каждой неделе — цену закрытия пятницы. Следующий этап - построение прогноза. # Разделение на обучающую и тестовую выборку train_size = int(len(weekly_prices) * 0.85) train = weekly_prices[:train_size] test = weekly_prices[train_size:] # Автоматический подбор модели model_auto = ETSModel( train, error='add', trend='add', seasonal=None, damped_trend=True ) fitted_auto = model_auto.fit() # Прогноз на горизонте тестовой выборки forecast_steps = len(test) forecast = fitted_auto.forecast(steps=forecast_steps) forecast_index = test.index # Расчет метрик точности mae = np.mean(np.abs(test.values - forecast.values)) rmse = np.sqrt(np.mean((test.values - forecast.values)**2)) mape = np.mean(np.abs((test.values - forecast.values) / test.values)) * 100 # Формируем словарь параметров с именами params_dict = dict(zip(fitted_auto.model.param_names, fitted_auto.params)) print(f"\nСпецификация модели: {fitted_auto.model.error}/{fitted_auto.model.trend}/{fitted_auto.model.seasonal}") print(f"AIC: {fitted_auto.aic:.2f}") print(f"BIC: {fitted_auto.bic:.2f}") print("\nПараметры сглаживания:") print(f"α (уровень): {params_dict.get('smoothing_level', np.nan):.6f}") print(f"β (тренд): {params_dict.get('smoothing_trend', np.nan):.6f}") print(f"φ (затухание): {params_dict.get('damping_trend', np.nan):.6f}") print(f"Начальный уровень: {params_dict.get('initial_level', np.nan):.6f}") print(f"Начальный тренд: {params_dict.get('initial_trend', np.nan):.6f}") print("\nТочность прогноза:") print(f"MAE: ${mae:.2f}") print(f"RMSE: ${rmse:.2f}") print(f"MAPE: {mape:.2f}%") Спецификация модели: add/add/None AIC: 893.35 BIC: 910.69 Параметры сглаживания: α (уровень): 0.903461 β (тренд): 0.000090 φ (затухание): 0.980000 Начальный уровень: 72.444994 Начальный тренд: 1.160603 Точность прогноза: MAE: $46.95 RMSE: $54.04 MAPE: 20.95% Разделение данных на обучающую (85%) и тестовую (15%) выборки позволяет оценить качество прогноза отложенной выборке. Спецификация модели задает аддитивную ошибку, аддитивный затухающий тренд и отсутствие сезонности. Для недельных финансовых данных без выраженных календарных эффектов сезонный компонент избыточен. Параметр damped_trend=True активирует затухание тренда через коэффициент φ. Это предотвращает линейную экстраполяцию, которая быстро расходится на длинных горизонтах. Метод fit() оптимизирует все параметры включая α, β, φ и начальные состояния. Доступ к оцененным параметрам через атрибут params позволяет анализировать скорость адаптации модели. Метрики MAE и RMSE измеряют абсолютные отклонения прогноза в долларах. MAPE дает относительную ошибку в процентах, что удобно для сравнения точности на разных инструментах. MAPE чувствителен к значениям близким к нулю — для финансовых данных это обычно не проблема. Давайте теперь посмотрим на визуализацию компонентов. # Создание фигуры с подграфиками fig, axes = plt.subplots(3, 1, figsize=(12, 10)) fig.suptitle('ETS: декомпозиция и прогноз', fontsize=14, fontweight='bold') # График 1: Исходный ряд и прогноз axes[0].plot(train.index, train.values, label='Обучающая выборка', color='#2C3E50', linewidth=1.5) axes[0].plot(test.index, test.values, label='Тестовая выборка', color='#27AE60', linewidth=1.5) axes[0].plot(forecast_index, forecast.values, label='Прогноз ETS', color='#E74C3C', linewidth=2, linestyle='--') axes[0].set_ylabel('Цена ($)', fontsize=11) axes[0].legend(loc='upper left', fontsize=10) axes[0].grid(True, alpha=0.3) axes[0].set_title('Временной ряд и прогноз', fontsize=11, loc='left') # График 2: Компонент уровня level = fitted_auto.level axes[1].plot(train.index, level, color='#34495E', linewidth=1.5) axes[1].set_ylabel('Уровень ($)', fontsize=11) axes[1].grid(True, alpha=0.3) axes[1].set_title('Эволюция уровня', fontsize=11, loc='left') # График 3: Компонент тренда trend = fitted_auto.slope axes[2].plot(train.index, trend, color='#8E44AD', linewidth=1.5) axes[2].axhline(y=0, color='gray', linestyle='--', linewidth=1, alpha=0.5) axes[2].set_ylabel('Тренд ($/период)', fontsize=11) axes[2].set_xlabel('Дата', fontsize=11) axes[2].grid(True, alpha=0.3) axes[2].set_title('Динамика тренда', fontsize=11, loc='left') plt.tight_layout() plt.savefig('ets_decomposition.png', dpi=300, bbox_inches='tight') plt.show() # Анализ остатков residuals = fitted_auto.resid fig, axes = plt.subplots(2, 2, figsize=(12, 8)) fig.suptitle('Диагностика остатков', fontsize=14, fontweight='bold') # График остатков axes[0, 0].plot(train.index, residuals, color='#2C3E50', linewidth=1) axes[0, 0].axhline(y=0, color='red', linestyle='--', linewidth=1, alpha=0.5) axes[0, 0].set_ylabel('Остатки ($)', fontsize=10) axes[0, 0].set_title('Временной график остатков', fontsize=10, loc='left') axes[0, 0].grid(True, alpha=0.3) # Гистограмма остатков axes[0, 1].hist(residuals, bins=30, color='#34495E', alpha=0.7, edgecolor='black') axes[0, 1].set_xlabel('Остатки ($)', fontsize=10) axes[0, 1].set_ylabel('Частота', fontsize=10) axes[0, 1].set_title('Распределение остатков', fontsize=10, loc='left') axes[0, 1].grid(True, alpha=0.3, axis='y') # ACF остатков from statsmodels.graphics.tsaplots import plot_acf plot_acf(residuals, lags=20, ax=axes[1, 0], color='#2C3E50') axes[1, 0].set_title('Автокорреляция остатков', fontsize=10, loc='left') axes[1, 0].grid(True, alpha=0.3) # Q-Q plot from scipy import stats stats.probplot(residuals, dist="norm", plot=axes[1, 1]) axes[1, 1].set_title('Q-Q график', fontsize=10, loc='left') axes[1, 1].grid(True, alpha=0.3) plt.tight_layout() plt.savefig('ets_residuals.png', dpi=300, bbox_inches='tight') plt.show() print("\nСтатистика остатков:") print(f"Среднее: {np.mean(residuals):.4f}") print(f"Ст. отклонение: {np.std(residuals):.4f}") print(f"Skewness: {stats.skew(residuals):.4f}") print(f"Kurtosis: {stats.kurtosis(residuals):.4f}") Рис. 1: Декомпозиция временного ряда на компоненты ETS. Верхняя панель показывает исходные данные, разделение на обучающую/тестовую выборку и прогноз. Средняя панель отображает эволюцию компонента уровня — сглаженную версию ряда без краткосрочных колебаний. Нижняя панель демонстрирует динамику тренда — скорость изменения уровня во времени. Затухание тренда проявляется в постепенном движении к нулевой линии Рис. 2: Диагностика остатков модели ETS. Верхний левый график показывает остатки во времени — отклонения фактических значений от модельных. Отсутствие систематических паттернов указывает на адекватную спецификацию. Верхний правый график — гистограмма распределения остатков для проверки нормальности. Нижний левый — автокорреляционная функция остатков: значения внутри доверительных границ подтверждают отсутствие неучтенной автокорреляции. Нижний правый — Q-Q график для визуальной оценки соответствия нормальному распределению Статистика остатков: Среднее: 0.3296 Ст. отклонение: 6.6404 Skewness: -0.1481 Kurtosis: 0.8448 Атрибуты level и slope объекта fitted_auto содержат временные ряды компонентов уровня и тренда. Визуализация компонентов помогает понять как модель интерпретирует структуру данных. Резкие изменения уровня соответствуют периодам высокой волатильности. Отрицательный тренд указывает на нисходящую динамику, положительный — на восходящую. Диагностика остатков проверяет предположения модели. Остатки должны быть случайными, независимыми и нормально распределенными. Автокорреляция остатков сигнализирует о неучтенных паттернах — возможно требуется другая спецификация или дополнительные компоненты. Гетероскедастичность (изменяющаяся дисперсия) указывает на необходимость мультипликативной ошибки. Сравнение с альтернативными методами ETS занимает промежуточную позицию между простыми эвристиками и сложными моделями машинного обучения. По сравнению со скользящими средними ETS адаптируется к изменениям через оцениваемые параметры сглаживания. В отличие от наивных методов ETS явно моделирует тренд и сезонность. Относительно LSTM и других рекуррентных сетей ETS требует меньше данных для обучения и быстрее обучается. Модель интерпретируема — компоненты имеют понятный смысл. Однако модель ETS не улавливает сложные нелинейные зависимости и ограничена фиксированной структурой компонентов. Модель Prophet использует похожую декомпозицию на тренд, сезонность и праздники. Prophet лучше работает с данными содержащими пропуски и выбросы, предоставляет гибкость в задании праздничных эффектов. ETS дает более точные краткосрочные прогнозы для регулярных рядов без специфических календарных эффектов. Модель TBATS (Trigonometric, Box-Cox, ARMA, Trend, Seasonal) расширяет подход ETS для рядов с множественной сезонностью и высокочастотными данными. Метод применяет Box-Cox преобразование для стабилизации дисперсии и тригонометрические функции для гибкого моделирования сезонности. Однако модель TBATS сложнее настраивать и она требует больше вычислительных ресурсов. Практические рекомендации Когда применять ETS ETS эффективен для временных рядов с выраженной трендовой и сезонной структурой. Метод подходит для краткосрочных и среднесрочных прогнозов — горизонты от 1 до 20-30 периодов вперед. Для финансовых данных это соответствует прогнозам на несколько недель или месяцев. Для корректной работы алгоритма важна регулярная частота наблюдений и отсутствие аномалий. Пропуски данных требуют предварительной обработки через интерполяцию или заполнение последним известным значением. Выбросы влияют на оценку компонентов — желательна предварительная очистка данных. ETS работает для рядов с одной сезонной частотой. Данные с множественной сезонностью (часовые данные с дневной и недельной цикличностью) требуют альтернативных методов (можно рассмотреть Prophet). Длина сезона должна быть известна заранее и оставаться постоянной на всей истории. Ограничения метода ETS плохо прогнозирует временные ряды с частыми сменами тренда. Резкое изменение режима рынка (например переход от роста к падению после кризиса) не улавливается алгоритмом автоматически. Модель продолжает экстраполировать старые паттерны даже когда рыночные условия изменились. ETS предполагает что будущее продолжает прошлое с постепенной адаптацией. Метод не учитывает экзогенные факторы — макроэкономические индикаторы, корпоративные события, регуляторные изменения. Для задач где важны внешние драйверы требуются регрессионные расширения. Долгосрочные прогнозы (более 30 периодов) теряют точность из-за накопления ошибок. Доверительные интервалы быстро расширяются делая прогноз малоинформативным. Затухающий тренд частично решает проблему, однако не устраняет фундаментальную неопределенность. Настройка параметров В большинстве случаев автоматический подбор модели через информационные критерии работает хорошо. Ручная настройка требуется при специфических знаниях о данных. Принудительное задание типа сезонности ускоряет обучение если структура известна заранее. Параметр seasonal_periods определяет длину сезонного цикла. Для месячных данных с годовой сезонностью надо выбирать значение 12. Для недельных данных с квартальными паттернами значение примерно 13. Неправильная длина сезона приводит к смещенным прогнозам. Ограничения на параметры сглаживания стабилизируют оптимизацию. Значения α, β, γ близкие к 0 или 1 указывают на проблемы с данными или спецификацией. Параметр затухания φ обычно лежит в диапазоне 0.8-0.98 — более низкие значения дают агрессивное затухание. Горизонт прогноза влияет на выбор между аддитивным и затухающим трендом. Для горизонтов до 5-10 периодов лучше выбирать линейный тренд. Для более длинных горизонтов затухание предотвращает неправдоподобные экстраполяции. Сравнение прогнозов на отложенной выборке определяет оптимальную спецификацию. Заключение ETS представляет зрелый инструмент для прогнозирования временных рядов с балансом между простотой и функциональностью. Метод формализует интуитивное экспоненциальное сглаживание через математически строгую модель пространства состояний. Автоматический подбор спецификации снижает барьер входа — не требуется глубокая экспертиза в анализе временных рядов. Практическая ценность ETS проявляется в задачах краткосрочного и среднесрочного прогнозирования с регулярными данными. Интерпретируемость компонентов помогает понять структуру ряда и выявить аномалии. Быстрая работа позволяет использовать метод для массового прогнозирования множества инструментов. В портфеле аналитика ETS занимает место надежного бейзлайн решения, которое работает из коробки и дает предсказуемые результаты. ### Алгоритмы оценки хеджирующих стратегий Эффективность хеджирования измеряется не столько доходностью, сколько степенью снижения риска. Портфель может показывать нулевую или отрицательную доходность, но если волатильность снизилась на 70%, хедж работает. Задача алгоритмов оценки — количественно определить, насколько инструмент защиты выполняет свою функцию и оправдывает затраты на его поддержание. Метрики эффективности хеджирования Классические портфельные метрики (коэффициент Шарпа, максимальная просадка) не учитывают специфику хеджирования: цель не максимизация прибыли, а минимизация убытков при неблагоприятных сценариях. Для оценки хеджей применяются специализированные подходы: анализ коэффициента хеджирования (hedge ratio), тестирование на коинтеграцию, измерение базисного риска, сравнение VaR с хеджем и без. Эти методы позволяют выбрать оптимальный инструмент защиты и настроить параметры ребалансировки. Hedge Ratio и динамическая корректировка Коэффициент хеджирования Hedge ratio показывает пропорцию хеджирующего инструмента к защищаемой позиции. Классический расчет через OLS-регрессию доходностей делается следующим образом: h = Cov(Rₛ, Rₕ) / Var(Rₕ) где: Rₛ — доходность защищаемого актива; Rₕ — доходность хеджирующего инструмента; h — оптимальный hedge ratio. Формула определяет количество единиц хеджа на единицу базового актива для минимизации дисперсии портфеля. Это хорошо интерпретируемая метрика, однако у нее есть некоторые ограничения. Статический коэффициент хеджирования предполагает постоянную корреляцию между активами, что редко выполняется на практике. Корреляция меняется в зависимости от рыночного режима: в кризисы активы, считавшиеся некоррелированными, начинают двигаться синхронно. Для учета изменения взаимосвязей применяют расчет динамического hedge ratio. Есть несколько подходов к его расчету: Через регрессию в скользящем окне (rolling window regression), окном в 60-252 дня; Через экспоненциально взвешенную регрессию, придающую больший вес недавним наблюдениям; Через фильтр Калмана для адаптивной оценки. Частота пересчета зависит от волатильности: для валютных хеджей ежедневно, для товарных фьючерсов еженедельно. Ошибка в расчетах коэффициента хеджирования приводит к переизбытку или недостатку защиты: Оверхедж (Over-hedging) увеличивает транзакционные издержки и ограничивает потенциал роста прибыли; Андерхедж (Under-hedging) оставляет портфель уязвимым к неблагоприятным движениям. Оптимальность проверяется через дисперсию хеджированного портфеля: чем ниже, тем эффективнее подобрано соотношение. Эффективность хеджирования (Hedge Effectiveness) Метрика Hedge Effectiveness измеряет процент устраненного риска относительно исходной позиции. Рассчитывается по формуле: HE = 1 - (Var(Rph) / Var(Rpu)) где: Var(Rph) — дисперсия доходности хеджированного портфеля; Var(Rpu) — дисперсия доходности незащищенного портфеля; HE — hedge effectiveness в диапазоне [0, 1]. Интерпретация: Если HE=1, то хедж полностью устраняет риск (идеальное хеджирование); Если HE=0, то хедж не снижает риск вовсе; Если HE<0, то хедж ухудшает риск-профиль (добавляет волатильность). То есть, проще говоря, значение 0.85 означает устранение 85% риска. Коэффициент эффективности хеджирования выше 0.7 считается приемлемым для большинства стратегий, выше 0.9 — отличным результатом. Данная метрика так же хорошо интерпретируема, однако не учитывает стоимость хеджирования, поэтому используется совместно с анализом затрат. Для проверки стабильности коэффициента эффективности хеджирования применяется тестирование на отложенной выборке. Разделение данных: 70% для калибровки hedge ratio, 30% для проверки работоспособности на новых данных. Если на тестовой выборке эффективность хеджа падает более чем на 15-20%, стратегия нестабильна и требует пересмотра инструмента или параметров. Альтернативный способ оценки — проведение анализа по подпериодам с разной волатильностью. Эффективное хеджирование должно демонстрировать наилучшие результаты в периоды повышенной турбулентности рынка, когда защита капитала особенно важна. Если значение метрики снижается именно в кризисные периоды, это сигнал к пересмотру стратегии хеджирования. Базисный риск (Basis Risk) Базис — это разница между ценой спот-актива и ценой хеджирующего инструмента. Для фьючерсного хеджа: Basis = Sₜ - Fₜ где: Sₜ — спот-цена актива в момент t; Fₜ — цена фьючерса в момент t. Идеальное хеджирование предполагает, что базис сходится к нулю по мере истечения срока действия фьючерсного контракта. На практике это условие редко выполняется: базис подвержен колебаниям под влиянием сезонности, затрат на хранение и транспортировку, а также особенностей поставки. Именно эта изменчивость формирует остаточный риск хеджированной позиции. Базисный риск обычно измеряется стандартным отклонением базиса за рассматриваемый период. Для товарных фьючерсов типичные значения составляют: нефть — 2–5%, сельскохозяйственные культуры — 5–10%, металлы — 1–3%. Чем выше волатильность базиса, тем менее предсказуем становится итоговый эффект хеджирования. Временная структура базисов также оказывает существенное влияние на выбор фьючерсного контракта: В условиях contango (когда фьючерсная цена выше спот-цены) длинная позиция во фьючерсах несет убытки при каждом роллировании, так как новые контракты приобретаются дороже текущего уровня рынка; Напротив, при backwardation (спот дороже фьючерса) роллирование формирует положительный carry, что повышает доходность позиции. Учет этих эффектов особенно важен при хеджировании на длительном горизонте. Снижение basis risk достигается выбором контракта с максимальной корреляцией к базовому активу, использованием кросс-хеджей только при отсутствии прямых инструментов, роллированием позиций за 5-10 дней до экспирации для избежания скачков ликвидности. Мониторинг исторической динамики базисов помогает предсказать периоды повышенного риска. Статистические методы оценки Коинтеграция и парный трейдинг Коинтеграция показывает наличие долгосрочной равновесной связи между активами. Два актива коинтегрированы, если их линейная комбинация стационарна, даже если сами ряды нестационарны. Это ключевое условие для устойчивого хеджирования: кратковременные отклонения цен от равновесия затухают, возвращая портфель к целевому профилю риска. Тест Энгла-Грейнджера проверяет коинтеграцию в два этапа: OLS-регрессия одного актива на другой для получения остатков; ADF-тест остатков на стационарность. Если p-value < 0.05, ряды коинтегрированы и подходят для хеджирования. import yfinance as yf import pandas as pd import statsmodels.api as sm from statsmodels.tsa.stattools import adfuller, coint import matplotlib.pyplot as plt pd.set_option('display.expand_frame_repr', False) # Загружаем котировки Exxon Mobil и Chevron start_date = '2023-09-30' end_date = '2025-09-30' xom_data = yf.download('XOM', start=start_date, end=end_date) cvx_data = yf.download('CVX', start=start_date, end=end_date) xom = xom_data['Close'] cvx = cvx_data['Close'] # Объединяем данные в один датафрейм prices = pd.concat([xom, cvx], axis=1) # объединяем по датам prices.columns = ['XOM', 'CVX'] prices = prices.dropna() # удаляем пропуски, если есть print(prices.head()) # Тест на коинтеграцию score, pvalue, _ = coint(prices['XOM'], prices['CVX']) # тест Юхана print(f'Cointegration p-value: {pvalue:.4f}') if pvalue > 0.05: print("Высокий p-value — возможно, пара не коинтегрирована.\n") # Расчет hedge ratio через OLS с константой X = sm.add_constant(prices['CVX']) # добавляем константу для интерсепта model = sm.OLS(prices['XOM'], X).fit() intercept = model.params['const'] # интерсепт hedge_ratio = model.params['CVX'] # коэффициент хеджирования β # Расчет спреда и тест на стационарность spread = prices['XOM'] - (intercept + hedge_ratio * prices['CVX']) # спред adf_result = adfuller(spread) # тест Дики-Фуллера # adf_result[0] — статистика, adf_result[1] — p-value # Доходности и эффективность хеджирования returns_xom = prices['XOM'].pct_change().dropna() # доходности XOM returns_cvx = prices['CVX'].pct_change().dropna() # доходности CVX hedged_returns = returns_xom - hedge_ratio * returns_cvx # доходность хеджированной позиции # Дисперсии для вычисления Hedge Effectiveness unhedged_var = returns_xom.var() # дисперсия без хеджа hedged_var = hedged_returns.var() # дисперсия с хеджем effectiveness = 1 - hedged_var / unhedged_var # эффективность хеджа # Вывод результатов print(f'Hedge ratio: {hedge_ratio:.4f}, Intercept: {intercept:.4f}') print(f'ADF statistic: {adf_result[0]:.4f}, p-value: {adf_result[1]:.4f}') print(f'Hedge Effectiveness: {effectiveness:.2%}') # Визуализация спреда plt.figure(figsize=(12,6)) plt.plot(spread, color='black', label='Spread (XOM - β·CVX)') # спред plt.axhline(spread.mean(), color='gray', linestyle='--') # среднее plt.axhline(spread.mean()+2*spread.std(), color='red', linestyle=':') # верхняя граница ±2σ plt.axhline(spread.mean()-2*spread.std(), color='red', linestyle=':') # нижняя граница ±2σ plt.legend() plt.title('Spread and ±2σ bands') plt.show() Date XOM CVX 2023-10-02 107.814774 152.691666 2023-10-03 108.001266 153.049255 2023-10-04 103.963928 149.482727 2023-10-05 101.623566 150.271194 2023-10-06 99.926567 148.740067 Cointegration p-value: 0.5313 Высокий p-value — возможно, пара не коинтегрирована. Hedge ratio: 0.5161, Intercept: 32.1354 ADF statistic: -1.9927, p-value: 0.2898 Hedge Effectiveness: 57.72% Рис. 1: Визуализация спреда между активами в пределах 2-х сигм. Наблюдается закономерность возврата к среднему (пунктирная линия), выход за пределы 2-х сигм (красная линия) был только дважды за последние два года Данный код выполняет анализ парного хеджирования для акций ExxonMobil (XOM) и Chevron (CVX): Сначала загружаются исторические цены закрытия за выбранный период, после чего данные синхронизируются и объединяются в единый датафрейм; Далее проводится тест на коинтеграцию, чтобы проверить наличие долгосрочной статистической зависимости между ценами двух активов; Если зависимость существует, рассчитывается коэффициент хеджирования (hedge ratio) через регрессию OLS, который показывает, в каком соотношении следует комбинировать активы для минимизации риска; На основе этого коэффициента строится спред, проводится тест Дики-Фуллера на стационарность, а также рассчитывается эффективность хеджирования (Hedge Effectiveness) через сравнение дисперсий доходностей защищенной и незашищенной позиции; По завершению расчетов строится визуализация спреда с отметками среднего значения и границ ±2σ. Интерпретация: Пара XOM–CVX показывает положительную эффективность хеджирования (57.7%), что позволяет снизить волатильность портфеля. Однако статистические тесты указывают на слабую коинтеграцию и нестационарность спреда (p-value > 0.05), поэтому эта стратегия не является полностью надежной для долгосрочного парного хеджирования. Она может работать временно, однако риск выхода спреда за пределы нормы остается высоким. Данный пример кода позволяет количественно оценить эффективность стратегии хеджирования и понять риск остаточного спреда между активами. Расчет Hedge Effectiveness показывает, насколько стратегия реально уменьшает риск, а визуализация спреда с границами ±2σ позволяет наглядно увидеть моменты сильного отклонения и возвращения к среднему. Фильтр Калмана для расчета динамического коэффициента хеджирования Фильтр Калмана позволяет динамически адаптировать коэффициент хеджирования в реальном времени при изменении взаимосвязи между активами. Алгоритм рекурсивно обновляет оценки параметров на основе поступающих данных, что особенно важно в периоды структурных сдвигов на рынке. В отличие от метода скользящего окна, фильтр Калмана использует байесовский подход: текущая оценка формируется как комбинация предыдущего состояния и нового наблюдения с весами, зависящими от их неопределенности. Модель пространства состояний для hedge ratio рассчитывается по формулам: βₜ = βₜ₋₁ + wₜ yₜ = βₜ · xₜ + vₜ где: βₜ — hedge ratio в момент t (скрытое состояние); yₜ, xₜ — доходности защищаемого актива и хеджа; wₜ, vₜ — шум процесса и измерений. Первое уравнение описывает эволюцию hedge ratio как случайное блуждание. Второе связывает наблюдаемые доходности через текущий коэффициент хеджа. Фильтр Калмана последовательно оценивает βₜ, минимизируя среднеквадратичную ошибку прогноза. !pip install pykalman --quiet from pykalman import KalmanFilter import yfinance as yf import pandas as pd import numpy as np import matplotlib.pyplot as plt # Загрузка данных: Rio Tinto и BHP start_date = '2023-09-30' end_date = '2025-09-30' rio = yf.download('RIO', start=start_date, end=end_date)['Close'] bhp = yf.download('BHP', start=start_date, end=end_date)['Close'] # Проверка на датафреймы if isinstance(rio, pd.DataFrame): rio = rio.iloc[:, 0] if isinstance(bhp, pd.DataFrame): bhp = bhp.iloc[:, 0] # Объединяем данные и рассчитываем доходности prices = pd.DataFrame({'RIO': rio, 'BHP': bhp}).dropna() returns = prices.pct_change().dropna() # Настройка Kalman Filter # Преобразуем наблюдения в форму (n_timesteps, n_obs_dim, n_state_dim) obs_matrix = returns['BHP'].values[:, np.newaxis, np.newaxis] kf = KalmanFilter( transition_matrices=[1], # модель состояния: hedge ratio меняется медленно observation_matrices=obs_matrix, # наблюдение: доходность BHP initial_state_mean=0, # начальное значение hedge ratio initial_state_covariance=1, # неопределенность начального состояния observation_covariance=1, # шум измерений transition_covariance=0.01 # вариабельность hedge ratio ) # Оценка динамического hedge ratio state_means, state_covs = kf.filter(returns['RIO'].values) hedge_ratios = pd.Series(state_means.flatten(), index=returns.index) # Расчет хеджированных доходностей hedged_returns = returns['RIO'] - hedge_ratios.shift(1) * returns['BHP'] hedged_returns = hedged_returns.dropna() # Статический hedge ratio для сравнения static_ratio = np.cov(returns['RIO'], returns['BHP'])[0, 1] / np.var(returns['BHP']) static_hedged = returns['RIO'] - static_ratio * returns['BHP'] # Метрики эффективности # Annualized volatility (%) unhedged_vol = returns['RIO'].std() * np.sqrt(252) * 100 kalman_vol = hedged_returns.std() * np.sqrt(252) * 100 static_vol = static_hedged.std() * np.sqrt(252) * 100 print(f'Unhedged volatility: {unhedged_vol:.2f}%') print(f'Kalman hedged volatility: {kalman_vol:.2f}%') print(f'Static hedged volatility: {static_vol:.2f}%') print(f'Kalman effectiveness: {(1 - (kalman_vol/unhedged_vol)**2):.2%}') print(f'Static effectiveness: {(1 - (static_vol/unhedged_vol)**2):.2%}') # Визуализация fig, axes = plt.subplots(3, 1, figsize=(12, 10)) # Динамический hedge ratio axes[0].plot(hedge_ratios.index, hedge_ratios, label='Kalman Filter', color='black', linewidth=1.5) axes[0].axhline(static_ratio, color='gray', linestyle='--', linewidth=1.5, label=f'Static ratio: {static_ratio:.3f}') axes[0].set_ylabel('Hedge Ratio') axes[0].legend() axes[0].grid(alpha=0.3) # Кумулятивная волатильность cumvol_unhedged = returns['RIO'].expanding().std() * np.sqrt(252) * 100 cumvol_kalman = hedged_returns.expanding().std() * np.sqrt(252) * 100 cumvol_static = static_hedged.expanding().std() * np.sqrt(252) * 100 axes[1].plot(cumvol_unhedged.index, cumvol_unhedged, label='Unhedged', color='red', linewidth=1.5) axes[1].plot(cumvol_kalman.index, cumvol_kalman, label='Kalman hedged', color='black', linewidth=1.5) axes[1].plot(cumvol_static.index, cumvol_static, label='Static hedged', color='gray', linewidth=1.5, linestyle='--') axes[1].set_ylabel('Annualized Volatility (%)') axes[1].legend() axes[1].grid(alpha=0.3) # Скользящая корреляция rolling_corr = returns['RIO'].rolling(60).corr(returns['BHP']) axes[2].plot(rolling_corr.index, rolling_corr, color='black', linewidth=1) axes[2].set_ylabel('60-day Rolling Correlation') axes[2].set_xlabel('Date') axes[2].grid(alpha=0.3) plt.tight_layout() plt.show() Unhedged volatility: 23.79% Kalman hedged volatility: 21.00% Static hedged volatility: 10.91% Kalman effectiveness: 22.06% Static effectiveness: 78.96% Рис. 2: Динамическое хеджирование с фильтром Калмана. Верхняя панель: эволюция hedge ratio — Kalman-оценка адаптируется к рыночным условиям, статический ratio остается постоянным. Средняя панель: сравнение адаптивного подхода со статическим хеджем. Нижняя панель: 60-дневная скользящая корреляция между активами демонстрирует нестабильность, обосновывающую применение динамического hedge ratio Данный код реализует динамическое хеджирование с использованием фильтра Калмана для пары акций горнодобывающих компаний RIO и BHP: На основе доходностей этих акций строится Kalman Filter, который рекурсивно оценивает динамический коэффициент хеджирования (hedge ratio), адаптируясь к изменяющейся взаимосвязи между активами; Для сравнения рассчитывается также статический hedge ratio через классический метод ковариации; Далее вычисляются волатильности для незашищенной позиции, позиции с динамическим Kalman-хеджем и статического хеджа, а также эффективность хеджирования (Hedge Effectiveness); В конце код строит три графика: эволюцию hedge ratio, кумулятивную волатильность и 60-дневную скользящую корреляцию между активами, что позволяет визуально оценить работу стратегии. Результаты показывают, что динамический Kalman-hedge снижает волатильность RIO с 23.79% до 21.00%, что подтверждает его способность адаптироваться к изменениям на рынке. Статический хедж оказался более эффективным в данном периоде (волатильность снизилась до 10.91%, эффективность 78.96%), что объясняется устойчивой долгосрочной взаимосвязью между активами в рассматриваемый период. Динамическое хеджирование с фильтром Калмана полезно в ситуациях, когда рыночные условия быстро меняются, например при структурных сдвигах, изменении цен на сырье или нестабильной макроэкономической среде. Даже если в конкретный период статический хедж оказался эффективнее, фильтр Калмана обеспечивает гибкость и адаптивность стратегии, снижая риск при неожиданных изменениях взаимосвязи между активами. Регрессия в скользящем окне (Rolling Window regression) Rolling Window оценивает hedge ratio на скользящем окне фиксированной длины, регулярно обновляя параметры по мере поступления новых данных. Этот подход проще в реализации и интерпретации по сравнению с Kalman Filter и хорошо работает при плавных изменениях корреляции между активами, когда отсутствуют резкие структурные сдвиги на рынке. Выбор длины окна определяет баланс между чувствительностью и стабильностью: Короткое окно (20-40 дней) быстро реагирует на изменения, но создает шум в оценках hedge ratio; Длинное окно (180-252 дня) дает стабильные оценки, но запаздывает при структурных изменениях рынка. По моему опыту для большинства задач оптимально выбирать окно в диапазоне 60-120 дней. Экспоненциально взвешенная регрессия (EWMA) придает больший вес более свежим наблюдениям, не отбрасывая при этом старые данные полностью. Параметр decay задает скорость «забывания» информации и обычно выбирается в диапазоне 0.94–0.97 для ежедневных рядов. Такой подход объединяет адаптивность коротких окон с устойчивостью длинных, обеспечивая более плавные и стабильные оценки hedge ratio при изменении рыночных условий. Интересно отметить, что эффективность выбранного подхода к хеджу сильно зависит от рыночных режимов: В трендовых рынках все подходы (статический хедж, Rolling Window и Kalman Filter) демонстрируют схожие результаты, так как корреляция между активами стабильна; В периоды высокой волатильности динамические методы, такие как Kalman Filter и короткие скользящие окна, показывают преимущество, быстро адаптируясь к изменениям взаимосвязи и снижая риск; При боковом движении рынка, напротив, длинные окна или EWMA обеспечивают более стабильные hedge ratio, сокращая число корректировок и, соответственно, транзакционные издержки. Таким образом, выбор подхода должен учитывать текущий рыночный режим, балансируя между адаптивностью и стабильностью. Риск-ориентированные подходы Value-at-Risk (VaR) hedged vs unhedged VaR измеряет максимальный ожидаемый убыток за период при заданном уровне доверия. Сравнение VaR хеджированного и незащищенного портфеля количественно показывает эффект защиты в денежном выражении. Для оценки хеджа используется VaR(95%) или VaR(99%) с горизонтом 1-10 дней в зависимости от ликвидности позиции. Параметрический VaR предполагает нормальное распределение доходностей: VaR = μ - z · σ · √t где: μ — ожидаемая доходность портфеля; z — квантиль стандартного нормального распределения (1.65 для 95%, 2.33 для 99%); σ — волатильность дневных доходностей; t — горизонт в днях. Формула работает для небольших изменений и умеренной волатильности. В периоды стресса распределение имеет тяжелые хвосты, параметрический VaR недооценивает риск. Альтернатива — исторический VaR через квантили эмпирического распределения, либо симуляции Монте-Карло с учетом нелинейностей. import yfinance as yf import pandas as pd import numpy as np import matplotlib.pyplot as plt from scipy import stats # Загрузка данных: EUR/USD спот и фьючерс start_date = '2023-09-30' end_date = '2025-09-30' # FXE - ETF на EUR/USD fxe = yf.download('FXE', start=start_date, end=end_date)['Close'] if isinstance(fxe, pd.DataFrame): fxe = fxe.iloc[:, 0] returns = fxe.pct_change().dropna() # Параметры портфеля position_size = 1_000_000 # USD confidence_level = 0.95 z_score = stats.norm.ppf(1 - confidence_level) # Незащищенная позиция unhedged_var = position_size * z_score * returns.std() print(f'Unhedged VaR(95%): ${abs(unhedged_var):,.0f}') # Хеджированная позиция (простой хедж с ratio=1) # Предполагаем hedge через фьючерс с корреляцией 0.98 hedge_correlation = 0.98 hedged_vol = returns.std() * np.sqrt(1 - hedge_correlation**2) hedged_var = position_size * z_score * hedged_vol print(f'Hedged VaR(95%): ${abs(hedged_var):,.0f}') print(f'VaR reduction: ${abs(unhedged_var - hedged_var):,.0f} ({(1-hedged_vol/returns.std()):.1%})') # Исторический VaR для сравнения historical_var_unhedged = position_size * np.percentile(returns, (1-confidence_level)*100) print(f'\nHistorical VaR(95%) unhedged: ${abs(historical_var_unhedged):,.0f}') # Monte Carlo VaR (10000 симуляций) n_simulations = 10000 simulated_returns = np.random.normal(returns.mean(), returns.std(), n_simulations) mc_var_unhedged = position_size * np.percentile(simulated_returns, (1-confidence_level)*100) print(f'Monte Carlo VaR(95%) unhedged: ${abs(mc_var_unhedged):,.0f}') # Визуализация распределения доходностей fig, axes = plt.subplots(2, 1, figsize=(12, 8)) # Гистограмма с VaR axes[0].hist(returns * position_size, bins=50, color='gray', alpha=0.7, edgecolor='black', linewidth=0.5) axes[0].axvline(unhedged_var, color='red', linestyle='--', linewidth=2, label=f'VaR(95%): ${abs(unhedged_var):,.0f}') axes[0].axvline(hedged_var, color='black', linestyle='--', linewidth=2, label=f'Hedged VaR: ${abs(hedged_var):,.0f}') axes[0].set_xlabel('Daily P&L (USD)') axes[0].set_ylabel('Frequency') axes[0].legend() axes[0].grid(alpha=0.3) # Q-Q plot для проверки нормальности stats.probplot(returns, dist="norm", plot=axes[1]) axes[1].get_lines()[0].set_color('black') axes[1].get_lines()[0].set_markersize(3) axes[1].get_lines()[1].set_color('red') axes[1].set_title('Q-Q Plot: проверка нормальности распределения') axes[1].grid(alpha=0.3) plt.tight_layout() plt.show() # Backtesting VaR: подсчет нарушений violations_unhedged = (returns * position_size < unhedged_var).sum() expected_violations = len(returns) * (1 - confidence_level) print(f'\nVaR violations: {violations_unhedged} (expected: {expected_violations:.1f})') print(f'Violation rate: {violations_unhedged/len(returns):.2%} (expected: {1-confidence_level:.2%})') Unhedged VaR(95%): $7,678 Hedged VaR(95%): $1,528 VaR reduction: $6,150 (80.1%) Historical VaR(95%) unhedged: $6,596 Monte Carlo VaR(95%) unhedged: $7,732 VaR violations: 17 (expected: 25.0) Violation rate: 3.41% (expected: 5.00%) Рис. 3: Анализ Value-at-Risk для валютной позиции. Верхняя панель: распределение дневной прибыли/убытка с отметками VaR для хеджированной и незащищенной позиции — хедж сдвигает критический уровень убытков ближе к нулю. Нижняя панель: Q-Q plot демонстрирует отклонение эмпирического распределения от нормального в хвостах, что обосновывает использование исторического или Monte Carlo VaR вместо параметрического в периоды стресса Код рассчитывает VaR для валютной позиции с использованием трех методов: параметрического, исторического и Monte Carlo. Сравнение показывает влияние хеджирования на максимальный ожидаемый убыток. Q-Q plot проверяет предположение о нормальности распределения — важное для корректности параметрического VaR. Бэктестинг подсчитывает фактические нарушения VaR-лимита: если их больше ожидаемых, модель недооценивает риск и требует калибровки. Интерпретация результатов: Незащищенная позиция: VaR(95%) = $7,678 → риск потерь в пределах 95% доверительного уровня достаточно высок; Хеджированная позиция: VaR(95%) = $1,528, что показывает снижение риска на 80% при использовании фьючерсного хеджа с высокой корреляцией; Historical VaR и Monte Carlo VaR близки к теоретическому, подтверждая корректность расчетов и предположение о нормальности распределения доходностей; Q-Q plot показывает, что распределение доходностей FXE близко к нормальному, без сильных выбросов; Backtesting: количество нарушений VaR (17 против ожидаемых 25) меньше, чем прогнозировалось, violation rate 3.41% ниже 5%, что говорит о консервативной оценке риска. Пример выше демонстрирует эффективность валютного хеджа через фьючерс, позволяя снизить риск крупной позиции более чем на 80%. Сравнение нескольких подходов VaR (классический, исторический, Monte Carlo) помогает проверить устойчивость оценки риска и выявить возможные несоответствия. Условный VaR или Expected Shortfall Expected Shortfall (ES) оценивает средний убыток в тех случаях, когда потери превышают заданный VaR-порог. В отличие от классического VaR, который ограничивается лишь границей риска, ES учитывает величину потерь в хвосте распределения. Эта метрика особенно важна для анализа наихудших возможных сценариев и используется в рамках Basel III как ключевая мера рыночного риска. Формула расчета: ES = E[Loss | Loss > VaR] ES всегда больше VaR при том же уровне доверия. Для нормального распределения связь аналитическая, для эмпирических данных ES рассчитывается как среднее по наблюдениям за VaR-порогом. Например, VaR(95%) = $50,000, ES(95%) = $67,000 означает, что в 5% худших случаев средний убыток $67,000. Условный VaR обладает свойством когерентности, показатель корректно учитывает эффект диверсификации и концентрацию хвостовых рисков. Для хеджирующих стратегий ES показывает эффективность защиты в экстремальных сценариях. Хедж может снижать VaR на 60%, но ES только на 40%, если оставляет портфель уязвимым к редким, но катастрофическим событиям. Тестирование на исторических кризисах (2008, 2020, 2022) выявляет такие слабости до их реализации. Анализ просадок (Drawdown Analysis) Максимальная просадка (Maximum Drawdown, MDD) измеряет наибольшее падение капитала от локального пика до следующего минимума за рассматриваемый период. Для хеджированных стратегий MDD показывает, насколько эффективно защита сохраняет капитал в длительных стрессовых периодах, когда отдельные дневные VaR-метрики не отражают кумулятивный эффект убытков и накопление риска. Формула расчета максимальной просадки: MDD = min((Wₜ - max(W₀:ₜ)) / max(W₀:ₜ)) где: Wₜ — капитал в момент t; max(W₀:ₜ) — максимальный капитал до момента t. Еще один важный показатель - это Drawdown Duration. Это показатель времени, необходимого портфелю для восстановления после достижения локального пика. Он дополняет информацию о максимальной просадке (MDD), показывая не только величину убытка, но и продолжительность стрессового периода. Например, портфель с MDD 15%, восстановившийся за 2 месяца, может быть предпочтительнее портфеля с MDD 12%, но восстановившегося за 8 месяцев, поскольку короткая просадка снижает психологическое давление на инвестора и уменьшает риск досрочного выхода из позиции. Длительные просадки повышают вероятность ошибок в управлении капиталом, увеличивают финансовый и эмоциональный стресс и могут негативно влиять на стратегическую дисциплину. import yfinance as yf import pandas as pd import numpy as np import matplotlib.pyplot as plt # Загрузка данных: портфель акций + SPY для хеджа start_date = '2023-09-30' end_date = '2025-09-30' # Создаем портфель из non-tech акций portfolio_tickers = ['WMT', 'DUK', 'JNJ'] spy = yf.download('SPY', start=start_date, end=end_date)['Close'] if isinstance(spy, pd.DataFrame): spy = spy.iloc[:, 0] portfolio_data = {} for ticker in portfolio_tickers: data = yf.download(ticker, start=start_date, end=end_date)['Close'] if isinstance(data, pd.DataFrame): data = data.iloc[:, 0] portfolio_data[ticker] = data portfolio_prices = pd.DataFrame(portfolio_data) portfolio_prices['SPY'] = spy portfolio_prices = portfolio_prices.dropna() # Равновзвешенный портфель portfolio_returns = portfolio_prices[portfolio_tickers].pct_change().mean(axis=1) spy_returns = portfolio_prices['SPY'].pct_change() # Бета портфеля к SPY для hedge ratio covariance = np.cov(portfolio_returns[1:], spy_returns[1:])[0, 1] market_variance = np.var(spy_returns[1:]) beta = covariance / market_variance print(f'Portfolio beta: {beta:.3f}') # Кумулятивные доходности cum_returns_unhedged = (1 + portfolio_returns).cumprod() hedged_returns = portfolio_returns - beta * spy_returns cum_returns_hedged = (1 + hedged_returns).cumprod() # Расчет drawdown def calculate_drawdown(cum_returns): running_max = cum_returns.expanding().max() drawdown = (cum_returns - running_max) / running_max return drawdown dd_unhedged = calculate_drawdown(cum_returns_unhedged) dd_hedged = calculate_drawdown(cum_returns_hedged) # Метрики max_dd_unhedged = dd_unhedged.min() max_dd_hedged = dd_hedged.min() # Длительность просадки def drawdown_duration(drawdown): is_dd = drawdown < 0 dd_groups = (is_dd != is_dd.shift()).cumsum() dd_periods = drawdown[is_dd].groupby(dd_groups[is_dd]).apply(len) return dd_periods.max() if len(dd_periods) > 0 else 0 max_duration_unhedged = drawdown_duration(dd_unhedged) max_duration_hedged = drawdown_duration(dd_hedged) print(f'\nUnhedged MDD: {max_dd_unhedged:.2%}, Duration: {max_duration_unhedged} days') print(f'Hedged MDD: {max_dd_hedged:.2%}, Duration: {max_duration_hedged} days') print(f'MDD improvement: {(1 - abs(max_dd_hedged)/abs(max_dd_unhedged)):.1%}') # Визуализация fig, axes = plt.subplots(2, 1, figsize=(12, 8)) # Кумулятивная доходность axes[0].plot(cum_returns_unhedged.index, (cum_returns_unhedged - 1) * 100, label='Unhedged Portfolio', color='red', linewidth=1.5) axes[0].plot(cum_returns_hedged.index, (cum_returns_hedged - 1) * 100, label='Beta-Hedged Portfolio', color='black', linewidth=1.5) axes[0].set_ylabel('Cumulative Return (%)') axes[0].legend() axes[0].grid(alpha=0.3) # Drawdown axes[1].fill_between(dd_unhedged.index, dd_unhedged * 100, 0, color='red', alpha=0.3, label='Unhedged DD') axes[1].fill_between(dd_hedged.index, dd_hedged * 100, 0, color='black', alpha=0.3, label='Hedged DD') axes[1].set_ylabel('Drawdown (%)') axes[1].set_xlabel('Date') axes[1].legend() axes[1].grid(alpha=0.3) plt.tight_layout() plt.show() # Дополнительные риск-метрики unhedged_vol = portfolio_returns.std() * np.sqrt(252) * 100 hedged_vol = hedged_returns.std() * np.sqrt(252) * 100 unhedged_sharpe = portfolio_returns.mean() / portfolio_returns.std() * np.sqrt(252) hedged_sharpe = hedged_returns.mean() / hedged_returns.std() * np.sqrt(252) print(f'\nAnnualized volatility - Unhedged: {unhedged_vol:.2f}%, Hedged: {hedged_vol:.2f}%') print(f'Sharpe Ratio - Unhedged: {unhedged_sharpe:.3f}, Hedged: {hedged_sharpe:.3f}') Portfolio beta: 0.212 Unhedged MDD: -9.49%, Duration: 95 days Hedged MDD: -6.64%, Duration: 69 days MDD improvement: 30.0% Annualized volatility - Unhedged: 13.01%, Hedged: 12.54% Sharpe Ratio - Unhedged: 1.873, Hedged: 1.523 Рис. 4: Анализ просадок портфеля с бета-хеджем. Верхняя панель: кумулятивная доходность демонстрирует сглаживание волатильности хеджированного портфеля относительно незащищенного. Нижняя панель: динамика просадок — хедж существенно снижает глубину и длительность drawdown в периоды рыночных коррекций. Затененные области показывают величину просадки в процентах от предыдущего максимума На представленном примере мы проанализировали эффективность бета-хеджа портфеля из акций WMT, DUK и JNJ относительно индекса SPY. Рассчитанный коэффициент бета (~0.212) показывает, что для нейтрализации систематического риска достаточно относительно небольшой короткой позиции по SPY. Графики кумулятивной доходности демонстрируют, что хедж сглаживает колебания портфеля, уменьшая глубину просадок (MDD) и сокращая длительность восстановления в периоды рыночных коррекций. При этом снижение волатильности после хеджа минимальное (с 13.01% до 12.54%), а Sharpe Ratio падает с 1.873 до 1.523, что отражает компромисс: уменьшение риска сопровождается ограничением потенциальной доходности, особенно для портфеля с низкой бета. То есть хедж в данном случае смягчает просадки, однако слабо влияет на общий риск портфеля. Сравнение методов хеджирования Сравнительная таблица методов: Метод Преимущества Недостатки Рекомендуемые рыночные условия Статический хедж Прост в расчете и интерпретации; минимальные транзакционные издержки Не учитывает изменения корреляции; риск неактуальных hedge ratio Стабильные трендовые рынки, долгосрочные стратегии Rolling Window Regression Адаптивен к изменениям корреляции; легко реализуется Шум при коротких окнах; запаздывание при длинных окнах Умеренно волатильные рынки, постепенные структурные изменения Exponential Weighted Regression (EWMA) Учитывает недавние изменения, сглаживает шум; баланс адаптивности и стабильности Подбор параметра decay требует эксперимента; чувствителен к экстремальным событиям Рынки с умеренной изменчивостью, когда нужны плавные корректировки Kalman Filter Адаптивное хеджирование в реальном времени; учитывает структурные сдвиги Сложнее в реализации и интерпретации; чувствителен к настройке шумов Высокая волатильность, быстрые рыночные изменения, структурные кризисы Каждый метод имеет свои сильные и слабые стороны, поэтому выбор зависит от того, что важнее в конкретной стратегии: стабильность и простота расчетов или быстрая адаптация к изменяющимся рыночным условиям. Иногда подходы комбинируют. Например, используют статический хедж для долгосрочных позиций и EWMA или Kalman Filter для активов с высокой волатильностью, чтобы оперативно реагировать на рыночные колебания. Ошибки и ловушки в хеджировании Хеджирование — мощный инструмент управления риском, но его эффективность зависит от правильной настройки и учета рыночных условий. На практике встречаются следующие ошибки и подводные камни: Игнорирование стоимости хеджа Хедж всегда связан с издержками: комиссии брокеров, спреды, ролловеры фьючерсов и пр. Иногда снижение риска на 5–10% обходится дороже, чем потенциальные убытки от движения рынка. Поэтому перед внедрением стратегии важно оценить соотношение затрат и пользы: Когда хедж оправдан: высокая волатильность, значительные потери в стрессовых сценариях, стратегическая необходимость сохранения капитала; Когда хедж не оправдан: низкая волатильность, маленькая доля актива в портфеле, слишком высокая стоимость хеджирования относительно предполагаемого снижения риска. Недооценка базисного риска Базис — разница между ценой спот-актива и хеджирующего инструмента — может непредсказуемо меняться под влиянием сезонности, ликвидности и рыночных шоков. Пренебрежение этим фактором приводит к остаточному риску, который снижает фактическую эффективность хеджа. Неправильный выбор метода и частоты пересчета Метод хеджирования и частота обновления hedge ratio должны соответствовать рыночной волатильности и характеру актива. Например, ежедневное пересчитывание валютного хеджа на спокойных рынках не дает преимущества и увеличивает транзакционные издержки, в то же время редкое обновление в периоды кризиса может оставить портфель уязвимым. Слепое доверие историческим данным Использование прошлых корреляций и дисперсий без учета изменения рыночного режима может ввести в заблуждение. История показывает, что активы, некоррелированные в нормальные периоды, часто начинают двигаться синхронно при кризисе. Поэтому важно проводить стресс-тестирование и анализ подпериодов. Вывод: перед внедрением любой хедж-стратегии, стоит всегда оценивать: Стоимость хеджа и баланс с уменьшением риска; Волатильность базиса и его исторические экстремумы; Адаптивность метода хеджирования к изменению корреляций; Чувствительность стратегии к стрессовым событиям. Заключение В данной статье мы рассмотрели ключевые алгоритмы хеджирующих стратегий и методы их оценки: от классического статического хеджа до динамических методов с фильтром Калмана, от оценки показателя Hedge Effectiveness до анализа Value-at-Risk и просадок. Каждая из этих методик дает конкретные количественные показатели, которые помогают: Понять, насколько выбранная защита действительно снижает риск; Оценить, оправданы ли издержки хеджа; Адаптироваться к меняющимся рыночным условиям и предсказывать возможные сценарии убытков. Правильно построенная хедж-стратегия — это баланс между защитой капитала и потенциальной доходностью. Она не устраняет полностью риск, но делает управление портфелем осознанным и прогнозируемым, минимизируя сюрпризы для инвестора. ### Топ-10 лучших инструментов MLOps: сравнение и выбор Сегодня MLOps стал неотъемлемой частью любого серьезного проекта в data science. Стек MLOps объединяет практики разработки, развертывания и поддержки ML-моделей в продакшене. Эти инструменты предоставляют единую платформу для управления жизненным циклом моделей. Основные проблемы, которые решают MLOps-платформы: Воспроизводимость экспериментов; Отслеживание метрик и гиперпараметров; Версионирование данных и моделей; Автоматизация деплоя; Мониторинг перформанса в продакшене. Выбор MLOps-стека зависит от размера команды, технического стека, бюджета и требований к масштабируемости. Рассмотрим 10 наиболее распространенных инструментов и критерии их выбора. Критерии выбора MLOps-платформы MLOps-стек включает несколько ключевых компонентов: Отслеживание экспериментов (Experiment tracking) — включает фиксацию метрик, параметров и артефактов; Версионирование данных и моделей (Data and model versioning); Оркестрация конвейеров (Pipeline orchestration); Реестр моделей (Model registry) — централизованное хранилище обученных моделей; Развертывание и обслуживание моделей (Deployment and serving); Мониторинг в продакшене (Production monitoring). Не все инструменты покрывают весь спектр задач — некоторые специализируются на отдельных компонентах. При выборе платформы учитываются следующие факторы: Интеграция с существующим стеком (фреймворки ML, облачные провайдеры, системы хранения данных); Масштабируемость и производительность при росте числа экспериментов и объема данных; Стоимость владения (лицензии, инфраструктура, время на настройку и поддержку); Кривая обучения и документация; Open source vs коммерческие решения; Зависимость от вендора (Vendor lock-in) при использовании облачных платформ. Стартапы и небольшие команды обычно начинают с легковесных опенсорс решений. Крупные организации выбирают enterprise-платформы с расширенными возможностями управления доступом, аудита и интеграции с корпоративными системами. Kubeflow Kubeflow — open source платформа для развертывания ML-пайплайнов на Kubernetes. Разработана Google для стандартизации рабочих процессов машинного обучения (ML-workflow) в контейнеризированной среде. Платформа предоставляет компоненты для всех этапов машинного обучения: подготовка данных, обучение моделей, оптимизация гиперпараметров, обслуживание моделей (model serving). Ключевые возможности: Kubeflow Pipelines для построения DAG-пайплайнов; Интеграция с TensorFlow и PyTorch; Поддержка распределенного обучения; Сервис Katib для автоматического подбора гиперпараметров. Платформа масштабируется на кластеры Kubernetes любого размера, что делает ее подходящей для больших ML-задач. Kubeflow подходит для команд с инфраструктурой на Kubernetes и потребностью в гибкой настройке пайплайнов. Однако требует значительных ресурсов на развертывание и поддержку — не оптимальный выбор для малых команд без DevOps-экспертизы. Кривая обучения высокая из-за сложности Kubernetes-экосистемы. MLflow MLflow — это платформа с открытым исходным кодом от Databricks для управления ML-экспериментами. Она фокусируется на отслеживании экспериментов, упаковке кода и развертывании моделей. Легковесное решение без привязки к конкретному фреймворку или инфраструктуре. Основные компоненты: MLflow Tracking для логирования параметров, метрик и артефактов; Model Registry для версионирования и управления моделями; Projects для упаковки кода в воспроизводимый формат. MLflow поддерживает все популярные ML-библиотеки (scikit-learn, TensorFlow, PyTorch, XGBoost) и интегрируется с различными бэкендами хранения. Платформа MLflow оптимальна для команд, которым нужен быстрый старт без сложной инфраструктуры. Ее ключевая особенность - низкий порог входа, вся установка занимает минуты. MLFlow хорошо подходит для разработки небольших и средних проектов, однако ограничен в enterprise-функциях (управление доступом, аудит). Часто используется как базовый layer tracking в комбинации с другими инструментами. Weights & Biases Weights & Biases (W&B) — коммерческая платформа для трекинга ML-экспериментов и визуализациях. Специализируется на удобном интерфейсе для сравнения экспериментов и эффективности совместной работы команды. Предоставляет как облачный SaaS, так и on-premise версию. Ключевые возможности: Автоматическое логирование метрик и системных ресурсов; Интерактивные дашборды для сравнения runs, versioning датасетов и моделей; Интеграция с Jupyter notebooks; Сервис W&B Reports позволяет создавать документацию экспериментов с графиками и кодом; Поддержка collaborative features — комментарии, шаринг экспериментов, team workspaces. Платформа популярна в research-командах и стартапах за счет удобства использования и качественной визуализации. Есть бесплатный tier для индивидуальных разработчиков и академических проектов. Коммерческие планы начинаются от нескольких сотен долларов в месяц для команд. Главный недостаток платформы — зависимость от вендора (vendor lock-in) при использовании облачной версии, данные экспериментов хранятся на серверах W&B. Neptune.ai Neptune.ai — коммерческая платформа для отслеживания ML экмпериментов и управления метаданными. Конкурирует с W&B, но делает акцент на расширенных возможностях логирования метаданных и долгосрочном хранении экспериментов. Основные фичи: Версионирование всех типов метаданных (не только метрики, но и конфигурации, datasets, hardware specs); Query API для программного доступа к данным экспериментов; Продвинутая система тегов и фильтрации; Сервис Neptune, который хранит полную историю изменений, что важно для аудита и compliance требований; Интеграция с 25+ ML-библиотеками и фреймворками. Neptune подходит для команд, для которых важны трассируемость экспериментов и долгосрочное хранение метаданных. Платформа сильнее W&B в организации больших объемов исторических данных, однако интерфейс менее интуитивный. Ценовая политика схожа с W&B — платные планы для команд, бесплатный tier для индивидуальных пользователей с ограничениями по хранению данных. DVC (Data Version Control) DVC — это инструмент с открытым исходным кодом для версионирования данных и моделей машинного обучения. Он работает поверх Git, расширяя его возможности для работы с большими файлами. Решает проблему хранения датасетов и моделей, которые не помещаются в Git-репозиторий. Ключевые возможности: Версионирование данных с хранением в S3, GCS, Azure Blob или локальных хранилищах; Оркестрация пайплайнов через dvc.yaml файлы, метрики и параметры в Git-friendly формате. DVC создает метафайлы (.dvc), которые коммитятся в Git, а сами данные хранятся отдельно. Это обеспечивает воспроизводимость экспериментов — checkout конкретного коммита автоматически восстанавливает соответствующую версию данных. DVC оптимален для команд, активно использующих Git и предпочитающих GitOps-подход. Платформа бесплатна и не требует дополнительной инфраструктуры кроме развертывания хранения данных. Недостаток — отсутствие UI для визуализации экспериментов (только CLI и VS Code extension). Часто комбинируется с MLflow или W&B для получения полного MLOps-стека. Metaflow Metaflow — open source фреймворк от Netflix для построения и управления data science пайплайнами. Фокусируется на простоте разработки workflow и бесшовном переходе от локальных экспериментов к выполнению в облаке. Основные фичи: Python-native API для описания пайплайнов; Автоматическое версионирование кода и данных на каждом шаге; Встроенная интеграция с AWS (Batch, S3, SageMaker) и Kubernetes; Сервис Metaflow Card system позволяет генерировать отчеты с результатами выполнения; Трекинг артефактов и параметров встроен в фреймворк без дополнительных инструментов. Metaflow подходит для data science команд, работающих с AWS или имеющих Kubernetes-кластеры. У платформы низкий порог входа — пайплайн описывается декораторами Python. Сильная сторона Metaflow — оркестрация сложных workflow с зависимостями и параллельным выполнением. Слабость — менее развитая экосистема плагинов по сравнению с Airflow, ограниченная поддержка других облачных провайдеров кроме AWS. Apache Airflow Apache Airflow — это платформа с открытым исходным кодом для оркестрации рабочих процессов (workflow orchestration), изначально созданная для ETL-задач в компании Airbnb. Широко используется для ML-пайплайнов благодаря зрелости проекта и богатой экосистеме операторов. Ключевые возможности: DAG-based описание пайплайнов на Python; Планировщик с поддержкой cron-расписаний и триггеров; UI для мониторинга выполнения, retry логика и алертинг; Airflow предоставляет операторы для интеграции с большинством data и ML инструментов (Spark, Kubernetes, облачные провайдеры); Сервис Dynamic DAG generation позволяет программно создавать пайплайны. Airflow — стандарт де-факто для оркестрации в data engineering, но для ML-задач имеет ограничения. Он не заточен под experiment tracking и версионирование моделей — эти функции нужно добавлять через интеграцию с MLflow или другими инструментами. Еще Airflow требует значительных ресурсов на настройку и поддержку. Поэтому оптимален для больших команд с выделенными ML/data engineers. AWS SageMaker AWS SageMaker — это облачная платформа с управляемой Amazon инфраструктурой (managed platform), предназначенная для полного цикла машинного обучения — от подготовки данных до деплоймента и мониторинга моделей. Платформа интегрирована с экосистемой AWS и предоставляет готовую инфраструктуру без необходимости настройки серверов. Основные компоненты: SageMaker Studio (IDE для data science); Встроенные алгоритмы и поддержка кастомных моделей; Автоматический подбор гиперпараметров; Model Registry и версионирование; Управляемые эндпоинты (Managed endpoints) для обслуживания ML-моделей; SageMaker Pipelines для оркестрации ML-workflow; SageMaker Clarify для анализа смещений (bias) и интерпретируемости; Feature Store для централизованного хранения фич. SageMaker подходит для команд, уже работающих в AWS и готовых перейти полностью на работу с одним вендором (vendor lock-in). Преимущество — быстрый старт и минимальная операционная нагрузка, AWS берет на себя масштабирование и обслуживание инфраструктуры. Недостаток — высокая стоимость при активном использовании (compute инстансы для обучения и инференса), сложная ценовая модель. Миграция на другую платформу требует значительных усилий. Google Vertex AI Vertex AI — это облачная ML-платформа с управляемой Google инфраструктурой (managed ML-platform), объединяющая автоматизированное обучение (AutoML) и кастомное обучение моделей (custom model training). Она является преемником AI Platform и предоставляет единый интерфейс для всех задач машинного обучения в Google Cloud Platform (GCP). Ключевые возможности: AutoML для быстрого создания моделей без глубокой экспертизы в ML; Vertex AI Workbench (managed Jupyter notebooks); Custom training с поддержкой TensorFlow, PyTorch, scikit-learn; Model Registry и managed endpoints; Feature Store; Vertex AI Pipelines (основан на Kubeflow Pipelines); Vertex AI Matching Engine для поиска по векторным представлениям. Vertex AI оптимален для команд в GCP-экосистеме. Платформа имеет сильную интеграцию с BigQuery для работы с большими датасетами, Cloud Storage для хранения моделей, Cloud Monitoring для наблюдения. Ценовая модель схожа с SageMaker — pay-as-you-go за compute и storage. Ключевая фича платформы - это, безусловно, AutoML. Он упрощает старт для команд без ML-экспертизы, однако кастомизация настроек здесь существенно ограничена. Зависимость от вендора (Google) здесь меньше чем у SageMaker за счет использования формата Kubeflow Pipelines. Azure Machine Learning Azure ML — это облачная платформа с управляемой Microsoft инфраструктурой (managed platform) для разработки и развертывания моделей в Azure. Основные фичи: Веб-интерфейс Azure ML Studio для всего workflow; AutoML для быстрого прототипирования; Интеграция MLflow для трекинга моделей и ведения реестра моделей; Управляемость ресурсами на компьют (кластеры CPU/GPU); Деплоймент на различные таргеты (ACI, AKS, Azure Functions, edge devices); Многофункциональный дашборд для анализа корректности и интерпретируемости моделей; Сервис Azure ML Pipelines для оркестрации. Azure ML подходит для enterprise-клиентов Microsoft с инфраструктурой в Azure. У платформы сильная интеграция с Azure DevOps для CI/CD, Azure Key Vault для управления секретами, Azure Active Directory для аутентификации. Поддержка hybrid и multi-cloud сценариев через Azure Arc. Стоимость владения Azure ML сопоставима с AWS и GCP. Однако пока что эта платформа менее популярна в DS сообществе по сравнению с SageMaker и Vertex AI, хотя активно развивается. Сравнительная таблица Ниже представлено сравнение топ-10 лучших инструментов MLOps по различным классам задач и стоимости. Таблица показывает, что у каждого решения есть как преимущества, так и недостатки. Open source решения дают большую гибкость, однако требуют инвестиций в инфраструктуру и поддержку. Managed платформы облачных провайдеров предоставляют готовое решение, однако завязывают все пайплайны на одного вендора и могут быть дорогими при масштабировании. Рекомендации по выбору Выбор инструментов MLOps зависит не только от размера команды, но и от инфраструктуры, бизнес-задач и потребностей в совместной работе. При принятии решения важно учитывать интеграцию с существующими CI/CD процессами, облачными хранилищами и системами мониторинга, а также обучаемость команды (даже самый мощный инструмент будет малоэффективен без грамотного использования). Также стоит оценивать каждый инструмент по ключевым параметрам: стоимость, простота внедрения, поддержка сообщества и совместимость с используемыми ML-фреймворками. Для стартапов и небольших команд (2–5 дата саентистов) Оптимальная стратегия — начинать с комбинации MLflow для отслеживания экспериментов (experiment tracking) и DVC для версионирования данных и моделей (data and model versioning). Оба инструмента бесплатны, легко настраиваются и не требуют выделенной инфраструктурной команды. Для оркестрации можно использовать простые cron-задачи (cron jobs) или GitHub Actions. По мере роста команды имеет смысл добавить Weights & Biases (W&B) или Neptune.ai для улучшения совместной работы и удобного управления метаданными экспериментов. Для средних команд (5–20 дата саентистов) Если есть собственная инфраструктура, то выгодно инвестировать в Kubeflow или Metaflow для оркестрации сложных пайплайнов. Если инфраструктура уже развернута на AWS, использование SageMaker позволяет снизить операционную нагрузку, хотя итоговая стоимость может быть выше. Для команд на GCP Vertex AI предоставляет схожие преимущества. Важно учитывать компромисс между vendor lock-in managed платформами и затратами на поддержку open source стека. Для больших команд Для организаций, готовых к Enterprise решениям, обычно применяют гибридный подход: Open source компоненты (Kubeflow, Airflow) обеспечивают гибкость и контроль; Коммерческие инструменты (W&B, Neptune) используются для трекинга экспериментов и совместной работы; Managed платформы (SageMaker, Vertex AI, Azure ML) применяются для специфических задач. Например, AutoML для бизнес-пользователей. Ключевой фактор при выборе инструментов для крупных компаний — возможность миграции между платформами и минимизация критической зависимости от одного вендора. ### Жадные алгоритмы: базовые принципы и их применение в количественном анализе Жадные алгоритмы представляют класс методов оптимизации, которые принимают локально оптимальные решения на каждом шаге без пересмотра предыдущих выборов. В количественном анализе такой подход находит применение в задачах отбора активов, оптимизации исполнения ордеров и построения предиктивных моделей. Эффективность жадных алгоритмов обусловлена низкой вычислительной сложностью — большинство реализаций работают за O(n log n) или O(n²), что позволяет обрабатывать огромные объемы данных практически в реальном времени. Принципы работы жадных алгоритмов Локальная оптимизация решений Жадный алгоритм строит решение пошагово, выбирая на каждой итерации элемент, который дает максимальную локальную выгоду по заданному критерию. После выбора элемент фиксируется и больше не пересматривается. Этим жадные алгоритмы отличаются от методов полного перебора или динамического программирования. Ключевые характеристики жадного подхода: Необратимость выбора: принятое решение не корректируется на последующих шагах; Критерий жадности: функция оценки, определяющая предпочтительность элемента; Линейная последовательность решений: каждый шаг зависит только от текущего состояния. Применение жадного подхода требует проверки двух свойств задачи: оптимальной подструктуры (решение содержит оптимальные решения подзадач) и жадного выбора (локально оптимальный выбор приводит к глобальному оптимуму). Условия применимости жадного подхода Жадный алгоритм дает оптимальное решение при наличии свойства матроида — математической структуры, где любое независимое подмножество элементов можно расширить до максимального независимого множества. Примеры матроидов: деревья в графах, линейно независимые векторы, непересекающиеся интервалы времени. Признаки задач, решаемых жадным методом: Выбор не блокирует будущие оптимальные решения; Задача допускает разбиение на независимые подзадачи; Локальный критерий коррелирует с глобальной целевой функцией. В количественном анализе жадные алгоритмы применяются в задачах когда нужно быстро протестировать какую-либо гипотезу с допуском небольшой погрешности. Типичные сценарии: онлайн-оптимизация портфеля, быстрый отбор признаков, динамическое управление рисками. Отличия от динамического программирования Динамическое программирование решает задачи с перекрывающимися подзадачами, сохраняя промежуточные результаты для повторного использования. Жадный подход не требует запоминания состояний и работает быстрее, однако применим к более узкому классу задач. Сравнение подходов: Критерий Жадный алгоритм Динамическое программирование Сложность O(n log n) – O(n²) O(n²) – O(n³) Память O(n) O(n²) Гарантия оптимума Только для матроидов Для задач с оптимальной подструктурой Пересмотр решений Нет Да Применимость Ограничена Шире Выбор между жадным алгоритмом и динамическим программированием зависит от структуры задачи. Если проверка матроидности затруднена, эмпирическое сравнение результатов с эталонным решением помогает оценить качество жадного подхода. Базовые примеры жадных алгоритмов Задача о выборе активностей Задача о выборе активностей демонстрирует классический жадный подход. Дано множество активностей с временем начала и окончания, требуется выбрать максимальное количество непересекающихся активностей. В контексте трейдинга это соответствует выбору торговых сессий на разных рынках при ограниченном капитале. Жадная стратегия: сортировать активности по времени окончания и выбирать первую доступную на каждом шаге. import numpy as np import pandas as pd def select_activities(start_times, end_times): """ Жадный алгоритм выбора максимального числа непересекающихся активностей Parameters: start_times: массив времен начала end_times: массив времен окончания Returns: selected: индексы выбранных активностей """ n = len(start_times) activities = [(start_times[i], end_times[i], i) for i in range(n)] # Сортировка по времени окончания - ключевой момент жадного подхода activities.sort(key=lambda x: x[1]) selected = [] last_end_time = -np.inf for start, end, idx in activities: if start >= last_end_time: selected.append(idx) last_end_time = end return selected # Пример: торговые сессии на разных рынках sessions = pd.DataFrame({ 'market': ['Asia', 'Europe', 'US_morning', 'US_afternoon', 'After_hours'], 'start': [0, 6, 13, 15, 20], 'end': [8, 14, 16, 21, 24] }) selected_idx = select_activities(sessions['start'].values, sessions['end'].values) selected_sessions = sessions.iloc[selected_idx] print("Выбранные торговые сессии:") print(selected_sessions[['market', 'start', 'end']]) Выбранные торговые сессии: market start end 0 Asia 0 8 2 US_morning 13 16 4 After_hours 20 24 Алгоритм работает со скоростью O(n log n) из-за сортировки. После сортировки по времени окончания жадный выбор гарантирует максимальное количество активностей: если выбрать активность с более поздним окончанием, останется меньше времени для последующих выборов. В торговых системах эта логика применяется при распределении капитала между рынками с разными часовыми поясами. Выбор сессий с ранним закрытием освобождает капитал для последующих интересных возможностей для входа. Алгоритм также адаптируется для задач маршрутизации ордеров между площадками с разными периодами ликвидности. Задача о рюкзаке с дробными весами Дробная версия задачи о рюкзаке решает проблему взятия части предмета наиболее оптимальным образом. В количественном анализе это соответствует формированию портфеля без ограничения на минимальный лот. Жадный подход дает оптимальное решение для дробной версии, но не для целочисленной. Стратегия: сортировать предметы по убыванию удельной ценности (value/weight) и брать в порядке убывания. Ниже пример кода. import numpy as np import matplotlib.pyplot as plt def fractional_knapsack(values, weights, capacity): """ Решение дробной задачи о рюкзаке жадным алгоритмом Parameters: values: ожидаемая доходность активов weights: требуемый капитал для каждого актива capacity: общий доступный капитал Returns: allocations: доли капитала на каждый актив total_value: итоговая ожидаемая доходность """ n = len(values) # Расчет удельной ценности (доходность на единицу капитала) ratios = values / weights # Сортировка по убыванию удельной ценности items = sorted(enumerate(ratios), key=lambda x: x[1], reverse=True) allocations = np.zeros(n) remaining_capacity = capacity total_value = 0 for idx, ratio in items: if weights[idx] <= remaining_capacity: # Берем актив полностью allocations[idx] = weights[idx] remaining_capacity -= weights[idx] total_value += values[idx] else: # Берем частично allocations[idx] = remaining_capacity total_value += ratio * remaining_capacity break # Нормализация к долям портфеля allocations = allocations / capacity return allocations, total_value # Пример: портфель акций с разной ожидаемой доходностью np.random.seed(42) tickers = ['ASML', 'TSM', 'BABA', 'JD', 'SHOP'] expected_returns = np.array([0.18, 0.22, 0.15, 0.12, 0.25]) # годовая доходность required_capital = np.array([5000, 8000, 3000, 4000, 6000]) # минимальный капитал total_capital = 15000 allocations, portfolio_return = fractional_knapsack( expected_returns * required_capital, required_capital, total_capital ) # Визуализация распределения капитала fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) # График 1: Распределение капитала capital_allocated = allocations * total_capital ax1.barh(tickers, capital_allocated, color='#2C3E50') ax1.set_xlabel('Распределенный капитал ($)', fontsize=11) ax1.set_title('Оптимальное распределение капитала', fontsize=12, fontweight='bold') ax1.grid(axis='x', alpha=0.3) # График 2: Удельная доходность vs выбор ratios = expected_returns / (required_capital / 1000) # доходность на $1000 colors = ['#27AE60' if a > 0 else '#95A5A6' for a in allocations] ax2.scatter(ratios, expected_returns, s=allocations*1000+100, c=colors, alpha=0.6) for i, ticker in enumerate(tickers): ax2.annotate(ticker, (ratios[i], expected_returns[i]), xytext=(5, 5), textcoords='offset points', fontsize=9) ax2.set_xlabel('Удельная доходность (на $1000)', fontsize=11) ax2.set_ylabel('Годовая доходность', fontsize=11) ax2.set_title('Критерий отбора активов', fontsize=12, fontweight='bold') ax2.grid(True, alpha=0.3) plt.tight_layout() print(f"Портфельная доходность: {portfolio_return/total_capital:.2%}") print("\nРаспределение по активам:") for ticker, alloc, capital in zip(tickers, allocations, capital_allocated): if alloc > 0: print(f"{ticker}: {alloc:.1%} (${capital:,.0f})") Портфельная доходность: 22.93% Распределение по активам: ASML: 6.7% ($1,000) TSM: 53.3% ($8,000) SHOP: 40.0% ($6,000) Рис. 1: Оптимальное распределение капитала по жадному алгоритму. Левая панель показывает итоговое распределение капитала между активами. Правая панель демонстрирует логику отбора: размер точки пропорционален доле в портфеле, зеленые точки — выбранные активы, серые — отвергнутые. Алгоритм отдает приоритет активам с максимальной удельной доходностью на единицу капитала Жадный выбор по удельной ценности оптимален для дробной версии задачи. Доказательство: пусть оптимальное решение отличается от жадного — тогда существует актив с большей удельной ценностью, который не включен полностью. Замена части решения на этот актив увеличит целевую функцию, что противоречит оптимальности. Для целочисленной версии (без дробных долей) жадный подход дает приближенное решение. В портфельном менеджменте дробность допустима для крупных счетов, где минимальные лоты незначительны относительно общего капитала. При наличии ограничений на минимальные позиции требуется динамическое программирование или метод branch-and-bound. Применение в количественном анализе Оптимизация портфеля: жадный отбор активов Жадный отбор активов применяется при формировании портфеля с ограничениями на количество позиций. Классическая оптимизация Марковица требует O(n³) операций для n активов, что неприемлемо для рынков из тысяч инструментов. Жадный подход снижает сложность до O(n² k), где k — размер портфеля. Критерий отбора комбинирует доходность и корреляцию с уже выбранными активами: добавлять актив, максимизирующий прирост коэффициента Шарпа портфеля. import numpy as np import yfinance as yf import pandas as pd from datetime import datetime, timedelta def greedy_portfolio_selection(returns, n_assets=10, risk_free_rate=0.04): """ Жадный отбор активов для портфеля Parameters: returns: DataFrame с дневными доходностями активов n_assets: целевое количество активов в портфеле risk_free_rate: безрисковая ставка (годовая) Returns: selected_assets: список выбранных тикеров weights: веса активов (равновзвешенные) sharpe_history: динамика коэффициента Шарпа при добавлении активов """ all_assets = returns.columns.tolist() selected = [] sharpe_history = [] # Первый актив - с максимальным коэффициентом Шарпа initial_sharpes = {} for asset in all_assets: mean_return = returns[asset].mean() * 252 std_return = returns[asset].std() * np.sqrt(252) sharpe = (mean_return - risk_free_rate) / std_return if std_return > 0 else -np.inf initial_sharpes[asset] = sharpe first_asset = max(initial_sharpes, key=initial_sharpes.get) selected.append(first_asset) sharpe_history.append(initial_sharpes[first_asset]) # Жадное добавление активов remaining = [a for a in all_assets if a not in selected] for _ in range(n_assets - 1): if not remaining: break best_asset = None best_sharpe = -np.inf for candidate in remaining: # Пробный портфель с равными весами trial_portfolio = selected + [candidate] portfolio_returns = returns[trial_portfolio].mean(axis=1) mean_ret = portfolio_returns.mean() * 252 std_ret = portfolio_returns.std() * np.sqrt(252) sharpe = (mean_ret - risk_free_rate) / std_ret if std_ret > 0 else -np.inf if sharpe > best_sharpe: best_sharpe = sharpe best_asset = candidate if best_asset: selected.append(best_asset) sharpe_history.append(best_sharpe) remaining.remove(best_asset) # Равновзвешенный портфель weights = np.ones(len(selected)) / len(selected) return selected, weights, sharpe_history # Загрузка данных для emerging markets акций tickers = ['TSM', 'ASML', 'NVO', 'BABA', 'SAP', 'TM', 'SNY', 'UL', 'SONY', 'SHOP', 'SE', 'MELI', 'PDD', 'GRAB'] end_date = datetime(2025, 9, 30) start_date = end_date - timedelta(days=730) data = yf.download(tickers, start=start_date, end=end_date, progress=False) # Обработка Multiindex if isinstance(data.columns, pd.MultiIndex): prices = data['Close'] else: prices = data # Расчет дневных доходностей returns = prices.pct_change().dropna() # Жадный отбор активов selected_assets, weights, sharpe_history = greedy_portfolio_selection( returns, n_assets=8 ) print("Выбранные активы в порядке добавления:") for i, (asset, sharpe) in enumerate(zip(selected_assets, sharpe_history), 1): print(f"{i}. {asset}: Sharpe = {sharpe:.3f}") print(f"\nВеса портфеля (равновзвешенный):") for asset, weight in zip(selected_assets, weights): print(f"{asset}: {weight:.1%}") # Сравнение с равновзвешенным портфелем из всех активов full_portfolio_returns = returns.mean(axis=1) full_sharpe = (full_portfolio_returns.mean() * 252 - 0.04) / (full_portfolio_returns.std() * np.sqrt(252)) selected_portfolio_returns = returns[selected_assets].mean(axis=1) selected_sharpe = (selected_portfolio_returns.mean() * 252 - 0.04) / (selected_portfolio_returns.std() * np.sqrt(252)) print(f"\nСравнение результатов:") print(f"Полный портфель (все {len(tickers)} активов): Sharpe = {full_sharpe:.3f}") print(f"Жадный портфель ({len(selected_assets)} активов): Sharpe = {selected_sharpe:.3f}") print(f"Улучшение: {(selected_sharpe/full_sharpe - 1)*100:.1f}%") Выбранные активы в порядке добавления: 1. SE: Sharpe = 1.655 2. TSM: Sharpe = 1.961 3. SAP: Sharpe = 2.049 4. UL: Sharpe = 2.111 5. BABA: Sharpe = 2.136 6. MELI: Sharpe = 2.149 7. SONY: Sharpe = 2.138 8. SHOP: Sharpe = 2.071 Веса портфеля (равновзвешенный): SE: 12.5% TSM: 12.5% SAP: 12.5% UL: 12.5% BABA: 12.5% MELI: 12.5% SONY: 12.5% SHOP: 12.5% Сравнение результатов: Полный портфель (все 14 активов): Sharpe = 1.531 Жадный портфель (8 активов): Sharpe = 2.071 Улучшение: 35.3% Алгоритм начинает с актива с максимальным индивидуальным коэффициентом Шарпа, затем на каждом шаге добавляет актив, который дает наибольший прирост коэффициента Шарпа для портфеля. Равновзвешенная схема упрощает вычисления и снижает чувствительность к ошибкам в оценке доходностей. Жадный отбор не гарантирует глобального оптимума — итоговое решение зависит от порядка добавления активов. Однако эмпирически метод дает портфели, близкие по качеству к результатам полной оптимизации, при значительно меньших вычислительных затратах. Для 1000 активов жадный подход работает за секунды, тогда как квадратичное программирование требует минут. Практическое применение: предварительный скрининг активов для последующей точной оптимизации. Жадный алгоритм сужает пространство поиска с 1000 активов до 20-30, после чего применяется классическая оптимизация Марковица или риск-паритет (risk parity). Forward Feature Selection для предиктивных моделей Forward Feature Selection — жадный метод отбора признаков для машинного обучения. Алгоритм начинает с пустого множества фичей и на каждом шаге добавляет признак, максимизирующий метрику качества модели на кросс-валидации. В отличие от рекурсивного исключения признаков, forward selection эффективнее при большом числе фичей. import numpy as np import pandas as pd import yfinance as yf from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import TimeSeriesSplit from sklearn.metrics import mean_squared_error from datetime import datetime, timedelta def forward_feature_selection(X, y, max_features=10, cv_splits=5): """ Жадный forward selection признаков с временной кросс-валидацией Parameters: X: DataFrame с признаками y: целевая переменная (доходность) max_features: максимальное число отбираемых признаков cv_splits: количество фолдов для кросс-валидации Returns: selected_features: список выбранных признаков в порядке важности scores_history: динамика RMSE при добавлении признаков """ available_features = list(X.columns) selected_features = [] scores_history = [] tscv = TimeSeriesSplit(n_splits=cv_splits) for iteration in range(max_features): if not available_features: break best_feature = None best_score = np.inf for feature in available_features: # Пробный набор признаков trial_features = selected_features + [feature] X_trial = X[trial_features] # Кросс-валидация cv_scores = [] for train_idx, val_idx in tscv.split(X_trial): X_train, X_val = X_trial.iloc[train_idx], X_trial.iloc[val_idx] y_train, y_val = y.iloc[train_idx], y.iloc[val_idx] model = RandomForestRegressor( n_estimators=100, max_depth=5, random_state=42, n_jobs=-1 ) model.fit(X_train, y_train) y_pred = model.predict(X_val) rmse = np.sqrt(mean_squared_error(y_val, y_pred)) cv_scores.append(rmse) avg_score = np.mean(cv_scores) if avg_score < best_score: best_score = avg_score best_feature = feature if best_feature: selected_features.append(best_feature) scores_history.append(best_score) available_features.remove(best_feature) print(f"Итерация {iteration + 1}: добавлен {best_feature}, RMSE = {best_score:.6f}") return selected_features, scores_history # Построение технических индикаторов для предсказания доходности ticker = 'TSM' end_date = datetime(2025, 1, 31) start_date = end_date - timedelta(days=1095) data = yf.download(ticker, start=start_date, end=end_date, progress=False) prices = data['Close'] # Инженерия признаков df = pd.DataFrame(index=prices.index) # Лаги доходностей returns = prices.pct_change() for lag in [1, 2, 3, 5, 10, 20]: df[f'return_lag_{lag}'] = returns.shift(lag) # Скользящие средние for window in [5, 10, 20, 50]: df[f'ma_{window}'] = prices.rolling(window).mean() / prices - 1 # Волатильность for window in [5, 10, 20]: df[f'volatility_{window}'] = returns.rolling(window).std() # RSI (упрощенная версия) def calculate_rsi(prices, period=14): delta = prices.diff() gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() rs = gain / loss return 100 - (100 / (1 + rs)) df['rsi_14'] = calculate_rsi(prices) # Объем (нормализованный) df['volume_norm'] = (data['Volume'] - data['Volume'].rolling(20).mean()) / data['Volume'].rolling(20).std() # Целевая переменная - доходность через 5 дней df['target'] = returns.shift(-5) # Удаление пропусков df = df.dropna() # Разделение на признаки и цель X = df.drop('target', axis=1) y = df['target'] # Forward feature selection selected_features, scores_history = forward_feature_selection( X, y, max_features=8, cv_splits=5 ) print(f"\nФинальный набор признаков ({len(selected_features)}):") for i, feature in enumerate(selected_features, 1): print(f"{i}. {feature}") # Оценка модели с выбранными признаками X_selected = X[selected_features] tscv = TimeSeriesSplit(n_splits=5) final_scores = [] for train_idx, test_idx in tscv.split(X_selected): X_train, X_test = X_selected.iloc[train_idx], X_selected.iloc[test_idx] y_train, y_test = y.iloc[train_idx], y.iloc[test_idx] model = RandomForestRegressor(n_estimators=200, max_depth=6, random_state=42) model.fit(X_train, y_train) y_pred = model.predict(X_test) rmse = np.sqrt(mean_squared_error(y_test, y_pred)) final_scores.append(rmse) print(f"\nРезультаты кросс-валидации финальной модели:") print(f"Средний RMSE: {np.mean(final_scores):.6f}") print(f"Стандартное отклонение: {np.std(final_scores):.6f}") Итерация 1: добавлен rsi_14, RMSE = 0.024978 Итерация 2: добавлен return_lag_10, RMSE = 0.024227 Итерация 3: добавлен ma_50, RMSE = 0.024221 Итерация 4: добавлен volatility_5, RMSE = 0.024181 Итерация 5: добавлен return_lag_3, RMSE = 0.024185 Итерация 6: добавлен ma_10, RMSE = 0.024203 Итерация 7: добавлен volatility_10, RMSE = 0.024256 Итерация 8: добавлен volume_norm, RMSE = 0.024246 Финальный набор признаков (8): 1. rsi_14 2. return_lag_10 3. ma_50 4. volatility_5 5. return_lag_3 6. ma_10 7. volatility_10 8. volume_norm Результаты кросс-валидации финальной модели: Средний RMSE: 0.024346 Стандартное отклонение: 0.003596 Метод Forward selection работает за O(n² · m · k), где n — число признаков, m — размер выборки, k — сложность обучения модели. Для 100 признаков и 1000 наблюдений алгоритм завершается за 2-5 минут, что приемлемо для регулярного переобучения моделей. Жадный отбор признаков не учитывает взаимодействия между фичами: признак, бесполезный индивидуально, может быть ценным в комбинации с другими. В этом случае стоит рассмотреть альтернативные подходы: Рекурсивное исключение признаков (RFE); Регуляризация L1 (Lasso); Алгоритмы на основе важности признаков (Permutation Importance). Практические рекомендации: Использовать forward selection для предварительного сокращения пространства признаков с 500-1000 до 30-50; Затем применять RFE или Lasso для финального отбора. Важно также не забывать про временную кросс-валидацию: стандартная k-fold CV плохо применима для финансовых временных рядов, так как создает утечку в будущее (look-ahead bias), завышая оценки качества модели. Оптимизация исполнения ордеров Задача: исполнить крупный ордер на покупку N акций, минимизируя транзакционные издержки и маркет-импакт. Жадная стратегия разбивает ордер на части, исполняя их в моменты времени с максимальной ликвидностью и минимальным спредом. Критерий жадности: отношение доступного объема к спреду в каждый момент времени. Алгоритм выбирает слоты с минимальной стоимостью исполнения единицы объема. import numpy as np import pandas as pd import matplotlib.pyplot as plt def greedy_order_execution(total_volume, time_slots, available_liquidity, spreads, max_volume_per_slot=0.1): """ Жадное исполнение крупного ордера с минимизацией маркет-импакта Parameters: total_volume: общий объем для исполнения time_slots: массив временных слотов available_liquidity: доступный объем в каждом слоте spreads: bid-ask spread в каждом слоте (в базисных пунктах) max_volume_per_slot: макс. доля ликвидности на слот (антидетекция) Returns: execution_schedule: план исполнения по слотам total_cost: общая стоимость маркет-импакта """ n_slots = len(time_slots) execution_schedule = np.zeros(n_slots) remaining_volume = total_volume # Расчет стоимости исполнения единицы объема в каждом слоте # Учитываем спред и квадратичный маркет-импакт unit_costs = spreads * (1 + 0.5 * np.random.uniform(0.8, 1.2, n_slots)) # Жадное распределение объема for _ in range(n_slots): if remaining_volume <= 0: break # Выбор слота с минимальной стоимостью исполнения eligible_slots = np.where(execution_schedule == 0)[0] if len(eligible_slots) == 0: break # Среди неиспользованных слотов находим лучший best_slot = eligible_slots[np.argmin(unit_costs[eligible_slots])] # Объем для исполнения с учетом ограничений max_allowed = available_liquidity[best_slot] * max_volume_per_slot execute_volume = min(remaining_volume, max_allowed) execution_schedule[best_slot] = execute_volume remaining_volume -= execute_volume # Расчет общей стоимости маркет-импакта total_cost = np.sum(execution_schedule * unit_costs) return execution_schedule, total_cost, unit_costs # Симуляция внутридневной ликвидности np.random.seed(42) n_slots = 24 # торговых часов time_slots = np.arange(9.5, 16, 0.25) # с 9:30 до 16:00, каждые 15 минут n_slots = len(time_slots) # Ликвидность следует U-образной кривой (высокая на открытии/закрытии) base_liquidity = 10000 liquidity_pattern = 1 + 0.5 * (np.cos(np.linspace(0, np.pi, n_slots)) + 1) available_liquidity = base_liquidity * liquidity_pattern * np.random.uniform(0.9, 1.1, n_slots) # Спред обратно коррелирует с ликвидностью spreads = 5 + 10 / (liquidity_pattern + 0.5) + np.random.uniform(-1, 1, n_slots) # Исполнение крупного ордера на 50,000 акций total_volume = 50000 execution_schedule, total_cost, unit_costs = greedy_order_execution( total_volume, time_slots, available_liquidity, spreads, max_volume_per_slot=0.08 ) # Сравнение с равномерным исполнением (TWAP) twap_volume_per_slot = total_volume / n_slots twap_cost = np.sum(twap_volume_per_slot * unit_costs) print(f"Жадное исполнение:") print(f" Общий объем: {execution_schedule.sum():,.0f}") print(f" Стоимость маркет-импакта: ${total_cost:,.2f}") print(f" Средняя стоимость: {total_cost/total_volume:.4f} $/акция") print(f"\nTWAP исполнение:") print(f" Стоимость маркет-импакта: ${twap_cost:,.2f}") print(f" Средняя стоимость: {twap_cost/total_volume:.4f} $/акция") print(f"\nУлучшение: {(1 - total_cost/twap_cost)*100:.1f}%") # Визуализация стратегии исполнения fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(14, 10)) # График 1: Профиль ликвидности и стоимость исполнения ax1_twin = ax1.twinx() ax1.plot(time_slots, available_liquidity/1000, color='#3498DB', linewidth=2, label='Ликвидность') ax1_twin.plot(time_slots, spreads, color='#E74C3C', linewidth=2, label='Спред', linestyle='--') ax1.set_ylabel('Доступная ликвидность (тыс. акций)', color='#3498DB', fontsize=11) ax1_twin.set_ylabel('Bid-Ask спред (bps)', color='#E74C3C', fontsize=11) ax1.set_title('Внутридневная динамика рынка', fontsize=12, fontweight='bold') ax1.tick_params(axis='y', labelcolor='#3498DB') ax1_twin.tick_params(axis='y', labelcolor='#E74C3C') ax1.grid(True, alpha=0.3) ax1.legend(loc='upper left') ax1_twin.legend(loc='upper right') # График 2: План исполнения ордера ax2.bar(time_slots, execution_schedule/1000, width=0.15, color='#27AE60', alpha=0.7, label='Жадное исполнение') ax2.axhline(y=twap_volume_per_slot/1000, color='#95A5A6', linestyle='--', linewidth=2, label='TWAP') ax2.set_ylabel('Объем исполнения (тыс. акций)', fontsize=11) ax2.set_title('Сравнение стратегий исполнения', fontsize=12, fontweight='bold') ax2.legend() ax2.grid(True, alpha=0.3) # График 3: Кумулятивное исполнение cumulative_greedy = np.cumsum(execution_schedule) cumulative_twap = np.cumsum(np.ones(n_slots) * twap_volume_per_slot) ax3.plot(time_slots, cumulative_greedy/1000, color='#27AE60', linewidth=2.5, label='Жадное') ax3.plot(time_slots, cumulative_twap/1000, color='#95A5A6', linewidth=2, linestyle='--', label='TWAP') ax3.fill_between(time_slots, cumulative_greedy/1000, cumulative_twap/1000, alpha=0.2, color='#27AE60') ax3.set_xlabel('Время торгов (часы)', fontsize=11) ax3.set_ylabel('Кумулятивный объем (тыс. акций)', fontsize=11) ax3.set_title('Прогресс исполнения ордера', fontsize=12, fontweight='bold') ax3.legend() ax3.grid(True, alpha=0.3) plt.tight_layout() # Детальный отчет по исполнению executed_slots = np.where(execution_schedule > 0)[0] print(f"\nДетальный план исполнения ({len(executed_slots)} слотов):") for slot in executed_slots[:10]: # первые 10 слотов time = time_slots[slot] volume = execution_schedule[slot] cost = unit_costs[slot] print(f" {time:.2f}: {volume:,.0f} акций @ {cost:.4f} $/акция") Жадное исполнение: Общий объем: 30,979 Стоимость маркет-импакта: $458,031.63 Средняя стоимость: 9.1606 $/акция TWAP исполнение: Стоимость маркет-импакта: $757,637.27 Средняя стоимость: 15.1527 $/акция Улучшение: 39.5% Детальный план исполнения (26 слотов): 9.50: 1,560 акций @ 13.3373 $/акция 9.75: 1,741 акций @ 14.2656 $/акция 10.00: 1,661 акций @ 13.9955 $/акция 10.25: 1,603 акций @ 12.9125 $/акция 10.50: 1,444 акций @ 13.2081 $/акция 10.75: 1,419 акций @ 12.2330 $/акция 11.00: 1,360 акций @ 11.7787 $/акция 11.25: 1,562 акций @ 14.9591 $/акция 11.50: 1,443 акций @ 15.2807 $/акция 11.75: 1,427 акций @ 14.7401 $/акция Рис. 2: Оптимизация исполнения крупного ордера. Верхняя панель показывает внутридневную динамику ликвидности (синяя линия) и спреда (красная пунктирная). Средняя панель сравнивает жадное исполнение (зеленые столбцы) с равномерным TWAP (серая линия). Нижняя панель демонстрирует кумулятивный прогресс: жадный алгоритм концентрирует исполнение в периоды высокой ликвидности и низких спредов, снижая общую стоимость маркет-импакта Жадное исполнение ордеров на бирже превосходит TWAP на 20-40% по стоимости маркет-импакта, концентрируя объем в оптимальные моменты. Ограничение max_volume_per_slot затрудняет обнаружение алгоритма участниками рынка. Более сложные версии учитывают прогноз ликвидности на основе исторических паттернов и реагируют на изменения рыночных условий в реальном времени. Альтернативные подходы: VWAP (volume-weighted average price) исполняет ордера пропорционально историческому профилю объемов; POV (percentage of volume) поддерживает фиксированную долю рыночного объема. Жадный алгоритм адаптивнее, однако требует точного прогноза ликвидности и спредов на горизонте исполнения. Ребалансировка с минимизацией транзакционных издержек Ребалансировка портфеля для возврата к целевым весам генерирует транзакционные издержки: комиссии, спреды, маркет-импакт. Жадная стратегия минимизирует издержки, корректируя сначала позиции с максимальным отклонением от цели и низкой стоимостью торговли. Давайте рассмотрим как можно сделать такую ребалансировку с помощью Python: import numpy as np import pandas as pd def greedy_rebalancing(current_weights, target_weights, trading_costs, portfolio_value, min_trade_threshold=0.001): """ Жадная ребалансировка портфеля с минимизацией транзакционных издержек Parameters: current_weights: текущие веса активов target_weights: целевые веса trading_costs: стоимость торговли каждого актива (в % от объема) portfolio_value: общая стоимость портфеля min_trade_threshold: минимальное отклонение для торговли Returns: trades: объемы торговли для каждого актива total_cost: общие транзакционные издержки final_weights: веса после ребалансировки """ n_assets = len(current_weights) # Отклонения от целевых весов deviations = target_weights - current_weights # Стоимость корректировки единицы отклонения correction_costs = trading_costs * np.abs(deviations) # Сортировка активов по приоритету корректировки: # большое отклонение + низкая стоимость = высокий приоритет priorities = np.abs(deviations) / (correction_costs + 1e-10) sorted_indices = np.argsort(-priorities) trades = np.zeros(n_assets) total_cost = 0 adjusted_weights = current_weights.copy() # Жадная корректировка позиций for idx in sorted_indices: deviation = target_weights[idx] - adjusted_weights[idx] # Игнорируем малые отклонения if np.abs(deviation) < min_trade_threshold: continue # Расчет необходимой торговли trade_weight = deviation trade_value = portfolio_value * np.abs(trade_weight) cost = trade_value * trading_costs[idx] # Исполнение торговли trades[idx] = trade_weight * portfolio_value adjusted_weights[idx] += trade_weight total_cost += cost return trades, total_cost, adjusted_weights # Пример: ребалансировка multi-asset портфеля assets = ['TSM', 'ASML', 'SAP', 'TM', 'SHOP', 'UL', 'SNY', 'SONY', 'NVO', 'BABA', 'SE', 'MELI'] # Текущие веса (drift после роста рынка) np.random.seed(42) current_weights = np.random.dirichlet(np.ones(len(assets)) * 5) # Целевые веса (равновзвешенный портфель) target_weights = np.ones(len(assets)) / len(assets) # Стоимость торговли варьируется по активам (ликвидность, спреды) # Более ликвидные активы имеют меньшие издержки trading_costs = np.array([0.0008, 0.0010, 0.0012, 0.0015, 0.0020, 0.0018, 0.0014, 0.0011, 0.0009, 0.0025, 0.0030, 0.0028]) portfolio_value = 1_000_000 # Жадная ребалансировка trades, total_cost, final_weights = greedy_rebalancing( current_weights, target_weights, trading_costs, portfolio_value ) # Сравнение с полной ребалансировкой (naive approach) naive_trades = (target_weights - current_weights) * portfolio_value naive_cost = np.sum(np.abs(naive_trades) * trading_costs) print("Результаты жадной ребалансировки:\n") print(f"{'Актив':<8} {'Текущий':<10} {'Целевой':<10} {'Торговля ($)':<15} {'Финальный':<10}") print("-" * 63) for i, asset in enumerate(assets): print(f"{asset:<8} {current_weights[i]:>8.2%} {target_weights[i]:>9.2%} " f"{trades[i]:>13,.0f} {final_weights[i]:>9.2%}") print(f"\nТранзакционные издержки:") print(f" Жадная ребалансировка: ${total_cost:,.2f} ({total_cost/portfolio_value:.3%})") print(f" Полная ребалансировка: ${naive_cost:,.2f} ({naive_cost/portfolio_value:.3%})") print(f" Экономия: ${naive_cost - total_cost:,.2f} ({(1-total_cost/naive_cost)*100:.1f}%)") # Оценка качества ребалансировки tracking_error = np.sqrt(np.sum((final_weights - target_weights)**2)) print(f"\nОтклонение от целевых весов (tracking error): {tracking_error:.4f}") # Анализ приоритетов корректировки deviations = np.abs(target_weights - current_weights) priorities = deviations / (trading_costs * deviations + 1e-10) print(f"\nТоп-5 активов по приоритету корректировки:") top_priorities = np.argsort(-priorities)[:5] for rank, idx in enumerate(top_priorities, 1): print(f"{rank}. {assets[idx]}: отклонение {deviations[idx]:.2%}, " f"стоимость {trading_costs[idx]:.3%}") Результаты жадной ребалансировки: Актив Текущий Целевой Торговля ($) Финальный --------------------------------------------------------------- TSM 9.91% 8.33% -15,799 8.33% ASML 7.45% 8.33% 8,877 8.33% SAP 7.11% 8.33% 12,203 8.33% TM 7.11% 8.33% 12,203 8.33% SHOP 15.28% 8.33% -69,466 8.33% UL 11.11% 8.33% -27,791 8.33% SNY 6.34% 8.33% 19,943 8.33% SONY 10.11% 8.33% -17,766 8.33% NVO 8.87% 8.33% -5,332 8.33% BABA 2.78% 8.33% 55,527 8.33% SE 4.77% 8.33% 0 4.77% MELI 9.16% 8.33% -8,223 8.33% Транзакционные издержки: Жадная ребалансировка: $457.53 (0.046%) Полная ребалансировка: $564.40 (0.056%) Экономия: $106.88 (18.9%) Отклонение от целевых весов (tracking error): 0.0356 Этот код реализует жадную стратегию ребалансировки портфеля с ограничением бюджета на комиссии. Он поэтапно выравнивает веса активов в портфеле относительно целевых значений (например, равновзвешенного портфеля), однако делает это не полностью, а только пока совокупные транзакционные издержки не превысят заданный лимит. Алгоритм сортирует активы по приоритету — чем больше отклонение и ниже комиссия, тем раньше актив попадает в очередь на корректировку. Таким образом, корректируются лишь наиболее "неэффективные" позиции, где торговля дает максимальный эффект при минимальных затратах. Практическая ценность этого подхода заключается в том, что он позволяет снижать транзакционные издержки и частоту сделок, сохраняя при этом структуру портфеля достаточно близкой к целевой. Это особенно важно для крупных портфелей, где полная ребалансировка может быть экономически невыгодной из-за комиссий, спредов и рыночного воздействия (импакта). Жадный алгоритм помогает достичь оптимального компромисса между точностью следования стратегии и операционными затратами, что делает его применимым в автоматизированных системах управления капиталом и портфельных оптимизаторах. Продвинутые применения Жадная оптимизация для алгоритмов бустинга Градиентный бустинг использует жадную стратегию построения ансамбля: каждое дерево оптимизирует остаточные ошибки предыдущих моделей. В количественном анализе бустинг применяется для прогнозирования доходностей, оценки вероятности дефолта, классификации рыночных режимов. import numpy as np import pandas as pd import yfinance as yf from sklearn.model_selection import TimeSeriesSplit from sklearn.metrics import mean_squared_error, r2_score import xgboost as xgb import matplotlib.pyplot as plt from datetime import datetime, timedelta # Параметры ticker = "ASML" end_date = datetime(2025, 9, 30) start_date = end_date - timedelta(days=1825) # 5 лет # Загрузка данных data = yf.download(ticker, start=start_date, end=end_date, progress=False, auto_adjust=True) prices = data['Close'] # Доходности returns = prices.pct_change().squeeze() returns.name = 'return' # Построение признаков features_df = pd.DataFrame(index=prices.index) for period in [5, 10, 20, 60]: features_df[f'momentum_{period}'] = returns.rolling(period).sum() for period in [5, 10, 20]: features_df[f'volatility_{period}'] = returns.rolling(period).std() for period in [20, 60]: features_df[f'skew_{period}'] = returns.rolling(period).skew() features_df[f'kurt_{period}'] = returns.rolling(period).kurt() for period in [20, 60, 120]: rolling_max = prices.rolling(period).max() rolling_min = prices.rolling(period).min() features_df[f'price_position_{period}'] = (prices - rolling_min) / (rolling_max - rolling_min) for lag in range(1, 6): features_df[f'return_lag_{lag}'] = returns.shift(lag) # Целевая переменная target = pd.DataFrame(returns.shift(-5)).rename(columns={'return': 'target'}) # Объединение и очистка df_model = pd.concat([features_df, target], axis=1).dropna() X = df_model.drop('target', axis=1) y = df_model['target'] # Временная кросс-валидация tscv = TimeSeriesSplit(n_splits=5) fold_results = [] feature_importance_list = [] params = { 'objective': 'reg:squarederror', 'tree_method': 'hist', 'max_depth': 4, 'learning_rate': 0.1, 'subsample': 0.8, 'colsample_bytree': 0.8, 'min_child_weight': 1, 'gamma': 0, 'reg_alpha': 0, 'reg_lambda': 1.0 } for fold, (train_idx, val_idx) in enumerate(tscv.split(X), 1): X_train, X_val = X.iloc[train_idx], X.iloc[val_idx] y_train, y_val = y.iloc[train_idx], y.iloc[val_idx] dtrain = xgb.DMatrix(X_train, label=y_train) dval = xgb.DMatrix(X_val, label=y_val) model = xgb.train( params, dtrain, num_boost_round=500, evals=[(dtrain, 'train'), (dval, 'val')], early_stopping_rounds=50, verbose_eval=False ) y_pred_train = model.predict(dtrain) y_pred_val = model.predict(dval) train_rmse = np.sqrt(mean_squared_error(y_train, y_pred_train)) val_rmse = np.sqrt(mean_squared_error(y_val, y_pred_val)) val_r2 = r2_score(y_val, y_pred_val) fold_results.append({ 'fold': fold, 'train_rmse': train_rmse, 'val_rmse': val_rmse, 'val_r2': val_r2, 'n_trees': model.best_iteration }) # Важность признаков (gain) importance = model.get_score(importance_type='gain') feature_importance_list.append(importance) print(f"Fold {fold}: Val RMSE = {val_rmse:.6f}, R² = {val_r2:.4f}, Trees = {model.best_iteration}") # Средние метрики results_df = pd.DataFrame(fold_results) print(f"\nСредние метрики по фолдам:") print(f" Train RMSE: {results_df['train_rmse'].mean():.6f}") print(f" Val RMSE: {results_df['val_rmse'].mean():.6f}") print(f" Val R²: {results_df['val_r2'].mean():.4f}") print(f" Среднее число деревьев: {results_df['n_trees'].mean():.0f}") # Агрегированная важность признаков all_features = X.columns aggregated_importance = {} for feature in all_features: gains = [imp.get(feature, 0) for imp in feature_importance_list] aggregated_importance[feature] = np.mean(gains) top_features = sorted(aggregated_importance.items(), key=lambda x: x[1], reverse=True)[:10] print(f"\nТоп-10 признаков по важности (gain):") for rank, (feature, importance) in enumerate(top_features, 1): print(f"{rank:2d}. {feature:<25} {importance:>10.4f}") # Визуализация fig, ax = plt.subplots(figsize=(10,6)) features_plot = [f[0] for f in top_features] importance_plot = [f[1] for f in top_features] ax.barh(features_plot, importance_plot, color='#2C3E50') ax.set_xlabel('Feature Importance') ax.set_title('Топ-10 признаков XGBoost') ax.invert_yaxis() plt.show() Fold 1: Val RMSE = 0.033435, R² = -0.1931, Trees = 0 Fold 2: Val RMSE = 0.028492, R² = -0.1089, Trees = 1 Fold 3: Val RMSE = 0.021034, R² = -0.0780, Trees = 1 Fold 4: Val RMSE = 0.031098, R² = -0.0972, Trees = 1 Fold 5: Val RMSE = 0.027133, R² = -0.0885, Trees = 8 Средние метрики по фолдам: Train RMSE: 0.014216 Val RMSE: 0.028238 Val R²: -0.1131 Среднее число деревьев: 2 Топ-10 признаков по важности (gain): 1. price_position_120 0.0027 2. return_lag_5 0.0022 3. return_lag_3 0.0022 4. return_lag_1 0.0021 5. momentum_60 0.0021 6. skew_20 0.0020 7. kurt_60 0.0019 8. volatility_10 0.0019 9. return_lag_2 0.0019 10. volatility_20 0.0018 Рис. 3: Топ-10 признаков по важности для обучения модели XGBoost Представленный выше код строит прогноз доходности акции ASML через пять торговых дней, используя градиентный бустинг деревьев XGBoost и временную кросс-валидацию. Этапы следующие: Сначала создаются признаки на основе исторических цен и доходностей: моменты, волатильность, скошенность и асимметрия доходностей за разные периоды, а также позиция цены относительно локальных максимумов и минимумов; Затем добавляются лаги доходности, чтобы учесть автокорреляцию; Признаки подаются в модель. Целевая переменная — будущая доходность через 5 дней; Модель обучается с использованием xgb.train и ранней остановки, что позволяет жадно добавлять деревья до момента, когда новые деревья перестают улучшать метрику на валидации. XGBoost использует жадный подход при построении каждого дерева: на каждом узле выбирается разбиение, максимально снижающее функцию потерь, что обеспечивает локально оптимальные решения для каждого шага. Жадное добавление деревьев с ранней остановкой позволяет эффективно управлять сложностью модели, предотвращая переобучение, и одновременно выявлять важнейшие признаки, влияющие на прогноз доходности. Такой подход наглядно показывает, как жадные алгоритмы могут использоваться для построения точных и интерпретируемых моделей на временных рядах. Построение торговых расписаний Построение расписания автоматической торговли для портфеля стратегий требует учета множества ограничений: лимиты API бирж, периоды низкой ликвидности, корреляции между стратегиями, вычислительные ресурсы. Жадный алгоритм распределяет временные слоты, максимизируя ожидаемую доходность при соблюдении ограничений. import numpy as np import pandas as pd from datetime import datetime, time, timedelta class TradingScheduler: """ Жадный планировщик исполнения торговых стратегий """ def __init__(self, strategies, time_slots, max_concurrent=3): """ Parameters: strategies: список словарей с параметрами стратегий time_slots: доступные временные слоты max_concurrent: максимум одновременно работающих стратегий """ self.strategies = strategies self.time_slots = time_slots self.max_concurrent = max_concurrent self.schedule = {slot: [] for slot in time_slots} def calculate_priority(self, strategy, slot): """ Расчет приоритета размещения стратегии в слоте Учитывает: ожидаемую доходность, оптимальное время, API лимиты """ # Базовый приоритет - ожидаемая доходность base_priority = strategy['expected_return'] # Штраф за отклонение от оптимального времени optimal_hour = strategy['optimal_hour'] slot_hour = slot.hour + slot.minute / 60 time_penalty = 1 - min(abs(slot_hour - optimal_hour) / 12, 1) * 0.3 # Бонус за низкое использование API в этом слоте current_api_usage = sum(s['api_calls'] for s in self.schedule[slot]) api_bonus = 1 + (100 - current_api_usage) / 200 # Штраф за высокую корреляцию с уже размещенными стратегиями correlation_penalty = 1.0 for placed_strategy in self.schedule[slot]: corr = strategy['correlation_matrix'].get(placed_strategy['name'], 0) correlation_penalty *= (1 - abs(corr) * 0.2) priority = base_priority * time_penalty * api_bonus * correlation_penalty return priority def can_place_strategy(self, strategy, slot): """ Проверка возможности размещения стратегии в слоте """ # Проверка лимита одновременных стратегий if len(self.schedule[slot]) >= self.max_concurrent: return False # Проверка API лимитов current_api = sum(s['api_calls'] for s in self.schedule[slot]) if current_api + strategy['api_calls'] > 100: return False # Проверка вычислительных ресурсов current_cpu = sum(s['cpu_usage'] for s in self.schedule[slot]) if current_cpu + strategy['cpu_usage'] > 80: return False return True def greedy_schedule(self): """ Жадное построение расписания """ unscheduled = self.strategies.copy() while unscheduled: best_placement = None best_priority = -np.inf # Поиск лучшего размещения среди всех комбинаций for strategy in unscheduled: for slot in self.time_slots: if not self.can_place_strategy(strategy, slot): continue priority = self.calculate_priority(strategy, slot) if priority > best_priority: best_priority = priority best_placement = (strategy, slot) # Если не нашли размещение - прерываем if best_placement is None: print(f"Не удалось разместить {len(unscheduled)} стратегий") break # Размещаем стратегию strategy, slot = best_placement self.schedule[slot].append(strategy) unscheduled.remove(strategy) return self.schedule def print_schedule(self): """ Вывод расписания """ print("\n" + "="*80) print("ТОРГОВОЕ РАСПИСАНИЕ") print("="*80) total_return = 0 for slot in sorted(self.schedule.keys()): strategies = self.schedule[slot] if not strategies: continue print(f"\n{slot.strftime('%H:%M')} - {(slot + timedelta(minutes=30)).strftime('%H:%M')}") print("-" * 80) slot_api = sum(s['api_calls'] for s in strategies) slot_cpu = sum(s['cpu_usage'] for s in strategies) slot_return = sum(s['expected_return'] for s in strategies) for strategy in strategies: print(f" • {strategy['name']:<25} " f"Return: {strategy['expected_return']:>6.2%} " f"API: {strategy['api_calls']:>3} " f"CPU: {strategy['cpu_usage']:>3}%") print(f"\n Итого по слоту: Return: {slot_return:>6.2%}, " f"API: {slot_api}/100, CPU: {slot_cpu}/80%") total_return += slot_return print("\n" + "="*80) print(f"Суммарная ожидаемая доходность: {total_return:.2%}") print("="*80 + "\n") # Определение стратегий с параметрами strategies = [ { 'name': 'Mean_Reversion_Asia', 'expected_return': 0.08, 'optimal_hour': 2, # Asian session 'api_calls': 25, 'cpu_usage': 15, 'correlation_matrix': {} }, { 'name': 'Momentum_Europe', 'expected_return': 0.12, 'optimal_hour': 9, # European open 'api_calls': 30, 'cpu_usage': 20, 'correlation_matrix': {'Mean_Reversion_Asia': -0.3} }, { 'name': 'Statistical_Arb_US', 'expected_return': 0.15, 'optimal_hour': 14, # US session 'api_calls': 40, 'cpu_usage': 35, 'correlation_matrix': {'Momentum_Europe': 0.4, 'Mean_Reversion_Asia': -0.2} }, { 'name': 'Pairs_Trading_Tech', 'expected_return': 0.10, 'optimal_hour': 15, 'api_calls': 35, 'cpu_usage': 25, 'correlation_matrix': {'Statistical_Arb_US': 0.6} }, { 'name': 'Vol_Arbitrage', 'expected_return': 0.11, 'optimal_hour': 10, 'api_calls': 20, 'cpu_usage': 30, 'correlation_matrix': {} }, { 'name': 'News_Sentiment', 'expected_return': 0.09, 'optimal_hour': 13, 'api_calls': 45, 'cpu_usage': 20, 'correlation_matrix': {'Momentum_Europe': 0.5} }, { 'name': 'Market_Making_Crypto', 'expected_return': 0.14, 'optimal_hour': 0, # 24/7 но предпочтительно ночь 'api_calls': 50, 'cpu_usage': 40, 'correlation_matrix': {} }, { 'name': 'Index_Arb', 'expected_return': 0.07, 'optimal_hour': 16, 'api_calls': 30, 'cpu_usage': 15, 'correlation_matrix': {'Statistical_Arb_US': 0.3} } ] # Создание временных слотов (каждые 30 минут, 24 часа) base_date = datetime(2025, 1, 1) time_slots = [base_date + timedelta(minutes=30*i) for i in range(48)] # Построение расписания scheduler = TradingScheduler(strategies, time_slots, max_concurrent=3) schedule = scheduler.greedy_schedule() scheduler.print_schedule() # Анализ эффективности размещения placed_strategies = sum(len(strategies) for strategies in schedule.values()) total_strategies = len(strategies) print(f"Статистика размещения:") print(f" Размещено стратегий: {placed_strategies}/{total_strategies}") print(f" Процент размещения: {placed_strategies/total_strategies*100:.1f}%") # Анализ использования ресурсов по слотам resource_usage = [] for slot, strategies in schedule.items(): if strategies: api_usage = sum(s['api_calls'] for s in strategies) cpu_usage = sum(s['cpu_usage'] for s in strategies) resource_usage.append({ 'slot': slot, 'api_pct': api_usage / 100, 'cpu_pct': cpu_usage / 80, 'n_strategies': len(strategies) }) if resource_usage: usage_df = pd.DataFrame(resource_usage) print(f"\nСреднее использование ресурсов:") print(f" API: {usage_df['api_pct'].mean()*100:.1f}%") print(f" CPU: {usage_df['cpu_pct'].mean()*100:.1f}%") print(f" Стратегий на слот: {usage_df['n_strategies'].mean():.1f}") ================================================================================ ТОРГОВОЕ РАСПИСАНИЕ ================================================================================ 00:00 - 00:30 -------------------------------------------------------------------------------- • Market_Making_Crypto Return: 14.00% API: 50 CPU: 40% Итого по слоту: Return: 14.00%, API: 50/100, CPU: 40/80% 02:00 - 02:30 -------------------------------------------------------------------------------- • Mean_Reversion_Asia Return: 8.00% API: 25 CPU: 15% Итого по слоту: Return: 8.00%, API: 25/100, CPU: 15/80% 09:00 - 09:30 -------------------------------------------------------------------------------- • Momentum_Europe Return: 12.00% API: 30 CPU: 20% Итого по слоту: Return: 12.00%, API: 30/100, CPU: 20/80% 10:00 - 10:30 -------------------------------------------------------------------------------- • Vol_Arbitrage Return: 11.00% API: 20 CPU: 30% Итого по слоту: Return: 11.00%, API: 20/100, CPU: 30/80% 13:00 - 13:30 -------------------------------------------------------------------------------- • News_Sentiment Return: 9.00% API: 45 CPU: 20% Итого по слоту: Return: 9.00%, API: 45/100, CPU: 20/80% 14:00 - 14:30 -------------------------------------------------------------------------------- • Statistical_Arb_US Return: 15.00% API: 40 CPU: 35% Итого по слоту: Return: 15.00%, API: 40/100, CPU: 35/80% 15:00 - 15:30 -------------------------------------------------------------------------------- • Pairs_Trading_Tech Return: 10.00% API: 35 CPU: 25% Итого по слоту: Return: 10.00%, API: 35/100, CPU: 25/80% 16:00 - 16:30 -------------------------------------------------------------------------------- • Index_Arb Return: 7.00% API: 30 CPU: 15% Итого по слоту: Return: 7.00%, API: 30/100, CPU: 15/80% ================================================================================ Суммарная ожидаемая доходность: 86.00% ================================================================================ Статистика размещения: Размещено стратегий: 8/8 Процент размещения: 100.0% Среднее использование ресурсов: API: 34.4% CPU: 31.2% Стратегий на слот: 1.0 Жадный планировщик максимизирует суммарную ожидаемую доходность, учитывая множественные ограничения. Приоритет размещения комбинирует доходность стратегии, оптимальность времени исполнения, доступность API и вычислительных ресурсов, корреляцию с уже размещенными стратегиями. Представленный выше подход также учитывает корреляции: две высококоррелированные стратегии в одном слоте не дают диверсификации и увеличивают просадки. Штраф за корреляцию снижает приоритет размещения коррелированных стратегий вместе. Альтернативный подход: задача целочисленного программирования с явными ограничениями на корреляцию и риск портфеля. API лимиты бирж (100-300 запросов в минуту для большинства платформ) требуют распределения запросов по времени. Жадный алгоритм балансирует между оптимальным временем исполнения (например, момент публикации макроэкономических данных) и доступностью API квоты. Ограничения жадного подхода и альтернативы Жадные алгоритмы дают субоптимальные решения для задач без свойства матроида. Классический пример: целочисленная задача о рюкзаке. Для портфеля с ограничениями на минимальные лоты жадный отбор по удельной доходности может пропустить оптимальную комбинацию активов. Сценарии неэффективности жадного подхода в количественном анализе: Оптимизация портфеля с нелинейными ограничениями: лимиты на отраслевую концентрацию, минимальные/максимальные веса активов, ограничения на tracking error относительно бенчмарка требуют квадратичного программирования; Размещение ордеров с прогнозом маркет-импакта: будущее влияние ордера на цену зависит от текущих решений, что нарушает независимость подзадач; Отбор признаков с взаимодействиями: бесполезные по отдельности признаки могут быть ценными в комбинации (XOR-подобные зависимости); Динамическое хеджирование опционов: локально оптимальное хеджирование может быть неоптимальным на горизонте из-за изменения волатильности и временного распада Диагностика применимости жадного подхода: сравнение с эталонным решением (динамическое программирование, точные методы) на небольших тестовых данных. Если качество жадного решения >90% от оптимального, алгоритм пригоден для продакшена. Сравнение с метаэвристиками Метаэвристики — алгоритмы оптимизации, не гарантирующие глобального оптимума, однако эффективные для сложных задач. Основные представители: генетические алгоритмы, симуляция отжига (simulated annealing), роевая оптимизация (particle swarm optimization), оптимизация методом муравьиных колоний (ant colony optimization). Сравнительная характеристика: Метод Сложность Качество решения Настройка Детерминизм Жадный O(n log n) – O(n²) 70-95% от оптимума Простая Да Генетический O(p · g · n) 85-98% от оптимума Сложная Нет Simulated Annealing O(i · n) 80-95% от оптимума Средняя Нет Particle Swarm O(p · i · n) 85-95% от оптимума Средняя Нет Обозначения: n — размер задачи; p — размер популяции; g — число поколений; i — число итераций. Генетические алгоритмы эффективны для портфельной оптимизации с множественными конфликтующими целями: максимизация доходности, минимизация риска, соблюдение ESG-критериев, ограничения на оборот. Жадный подход не справляется с многокритериальностью, генетический алгоритм строит Парето-фронт допустимых решений. Симуляция отжига применяется для оптимизации исполнения ордеров с нелинейным маркет-импактом. Алгоритм "разогревает" систему, допуская временное ухудшение целевой функции для выхода из локальных минимумов, затем "охлаждает", сходясь к решению. В то же время, жадный подход застревает в первом локальном оптимуме. Практический выбор: начинать с жадного алгоритма как бейзлайна, переходить к метаэвристикам при неудовлетворительном качестве. Гибридные подходы комбинируют жадную инициализацию с метаэвристической доработкой. Гибридные подходы Гибридные методы комбинируют жадные алгоритмы с другими техниками оптимизации. Типичные схемы: Greedy + Local Search: жадный алгоритм строит начальное решение, локальный поиск улучшает его небольшими модификациями. Для портфельной оптимизации: жадный отбор 20 активов из 500, затем квадратичное программирование для точных весов. Greedy + Backtracking: жадный выбор с возможностью отката при обнаружении тупика. Применяется в задачах с жесткими ограничениями, где жадный путь может привести к невыполнимому решению. Beam Search: обобщение жадного поиска, сохраняющее k лучших решений на каждом шаге. Компромисс между жадным подходом (k=1) и полным перебором (k=∞). Для feature selection: хранить 5 лучших наборов признаков, выбирать финальный по валидационной метрике. Greedy + Machine Learning: обучение модели для предсказания качества жадного выбора. Для исполнения ордеров: ML-модель прогнозирует ликвидность и спреды на следующий час, жадный алгоритм оптимизирует расписание на основе прогноза. Adaptive Greedy: динамическая корректировка критерия жадности на основе результатов. Если жадный отбор активов дает высокую концентрацию в одной отрасли, критерий модифицируется для увеличения диверсификации. Выбор гибридного подхода зависит от соотношения качество/скорость. Чистый жадный алгоритм дает решение за миллисекунды, гибрид с локальным поиском — за секунды, генетический алгоритм — за минуты. Для реалтайм систем (HFT, динамическое хеджирование) часто оказываются приемлемыми только жадный подход или простейшие гибриды. Заключение Жадные алгоритмы представляют практичный инструмент для решения оптимизационных задач в количественном анализе там, где нужно быстро проанализировать определенные закономерности с некоторым допуском небольшой неточности. Линейная или квазилинейная сложность делает их применимыми для обработки больших датасетов и реалтайм систем. Ключевое преимущество жадного подхода — простота реализации и интерпретируемость. В отличие от черных ящиков вроде глубоких нейросетей, жадный алгоритм принимает решения прозрачно: на каждом шаге виден критерий выбора и его обоснование. Это упрощает отладку, регуляторный комплаенс и объяснение решений клиентам или управляющим. ### Feature Store: централизованное хранилище признаков для ML ML-проекты часто сталкиваются с проблемой разрозненного управления признаками. Дата-сайентисты создают признаки в Jupyter-ноутбуках, ML-инженеры переписывают их для продакшена, а через несколько месяцев уже никто не помнит, какие именно трансформации применялись к обучающим данным. Результат — несоответствие между обучением и инференсом, дублирование работы и сложности с воспроизводимостью экспериментов. Хранилище признаков (Feature Store) решает эти проблемы через централизованное хранилище признаков с единым API для обучения и инференса моделей. Это не просто база данных — это инфраструктурный компонент, который обеспечивает консистентность вычислений, версионирование, мониторинг качества данных и переиспользование фичей между проектами. Основная ценность Feature Store проявляется в средних и крупных командах, где над моделями работают несколько специалистов, а количество признаков измеряется сотнями. Для индивидуальных проектов или команд из 1-3 человек накладные расходы на поддержку инфраструктуры могут превышать выгоды. Архитектура Feature Store Хранилище признаков состоит из трех ключевых элементов: Offline storage для исторических данных и обучения моделей; Online storage для быстрого, низколатентного доступа в продакшен-среде; Metadata registry для управления определениями признаков. Offline storage хранит исторические значения признаков с временными метками. Типичные решения: Parquet файлы в S3, BigQuery, Snowflake, или Delta Lake. Этот слой оптимизирован для пакетной обработки больших объемов данных при обучении моделей. Запросы выполняются в диапазоне секунд или минут. Online storage обеспечивает доступ к актуальным значениям признаков с латентностью в миллисекундах. Используются Redis, DynamoDB, Cassandra или специализированные key-value хранилища. Данные синхронизируются из offline storage через процесс материализации. Metadata registry содержит определения признаков: схемы данных, источники, логику трансформаций, версии, владельцев. Это центральный каталог, который позволяет находить существующие фичи и понимать их семантику без изучения кода. Offline и Online хранилища Разделение на offline и online storage обусловлено разными требованиями к латентности и объемам данных: При обучении модели нужен доступ к миллионам исторических записей, однако скорость тут не критична; В продакшен-среде, напротив, требуется получить признаки для одного объекта за минимальное время, как правило в пределах 5-20 миллисекунд. Материализация переносит данные из offline в online хранилище. Процесс запускается по расписанию или при обновлении источников данных. Для каждого entity (пользователь, продукт, транзакция) в online storage сохраняется только последнее значение признаков. Исторические данные остаются в offline слое. Синхронизация между слоями требует внимания к консистентности. Если признак обновляется в реальном времени из стриминговых источников (Kafka, Kinesis), важно обеспечить его доступность и в offline storage для переобучения моделей. Двунаправленная синхронизация усложняет архитектуру, но гарантирует, что модель обучается на тех же данных, которые видит в production. Feature Registry Registry служит единым источником правды о признаках. Каждое определение включает имя, тип данных, entity (к чему относится признак), источник, метод вычисления, владельца. Версионирование позволяет отслеживать изменения в логике расчета признаков. Metadata хранятся в SQL базе (PostgreSQL, MySQL) или специализированных каталогах данных. При регистрации нового признака создается запись с уникальным идентификатором. Последующие обращения к этому признаку используют ID или имя + версию. Discovery функциональность позволяет искать признаки по тегам, источникам, владельцам. Это сокращает дублирование: перед созданием нового признака можно проверить, не существует ли уже похожий. В крупных организациях это экономит сотни часов инженерного времени. Ключевые возможности Переиспользование признаков Централизация признаков превращает инжиниринг признаков из индивидуальной работы в коллективный актив. К примеру, один дата саентист создает признак для прогнозирования оттока, другой специалист обнаруживает его через registry и использует для модели рекомендаций. Переиспользование сокращает время разработки новых моделей на 30-50%. Вместо написания SQL запросов для агрегации данных о поведении пользователей, достаточно подключить готовый feature set. Дополнительный бонус: признаки уже протестированы и используются в продакшене, что снижает риск ошибок. Стандартизация определений признаков упрощает коммуникацию между командами. К примеру, показатель User lifetime value может вычисляться по-разному в маркетинге и аналитике. Feature Store фиксирует единое определение, которое используется во всех моделях. Контроль версий и воспроизводимость Каждое изменение в логике вычисления признака создает новую версию. Модель, обученная на версии 1.2 признака, продолжает использовать эту версию в production. Обновление до версии 1.3 происходит контролируемо после тестирования. Воспроизводимость экспериментов требует фиксации не только гиперпараметров модели, но и версий всех признаков. Хранилище признаков автоматически логирует, какие версии использовались при обучении. Через год можно точно воспроизвести обучающий датасет. Откат к предыдущей версии признаков выполняется изменением конфигурации без переписывания кода. Это важно при деградации качества модели в продакшене: можно быстро вернуться к стабильной версии, пока идет расследование причин. Консистентность между обучением и инференсом Смещение между обучением и инференсом (training-serving skew) — частая причина деградации моделей в продакшене. К примеру, модель обучается на признаках, рассчитанных в pandas, а в продакшене используется другая реализация — например, на Spark или SQL. В этом случае даже небольшие различия в обработке пропущенных значений, NaN или округлении чисел могут накапливаться и со временем снижать качество метрик. Feature Store устраняет эту проблему через единую кодовую базу для вычисления признаков. Одна и та же логика применяется при создании обучающего датасета, так и при получении признаков в реальном времени. Различаются только источники данных: исторические для обучения, актуальные для инференса. При этом важно проверить, что модель обучается только на тех данных, которые были доступны на момент прогноза. Например, при обучении модели для предсказания следующей покупки пользователя нельзя использовать признаки, рассчитанные с учетом будущих транзакций. Хранилище признаков автоматически обеспечивает соблюдение временных ограничений при формировании обучающего набора данных. Практическая реализация Feast: open-source решение Библиотека Feast — наиболее зрелое open-source решение, разработанное в Gojek и поддерживаемое Linux Foundation. Архитектура поддерживает различные комбинации offline и online хранилищ: Snowflake + Redis, BigQuery + Firestore, S3 + DynamoDB. Feast не навязывает конкретную инфраструктуру. Registry может работать в файловом режиме для локальной разработки или использовать SQL базу для продакшена. Материализация выполняется через встроенный движок или интегрируется с Airflow, Kubernetes Jobs. Основные компоненты Feast: Feature definitions: Python файлы с описанием источников данных и логики трансформаций; Feature registry: централизованный каталог всех определений; Offline store: интерфейс для получения исторических данных; Online store: low-latency доступ к актуальным значениям; SDK: Python библиотека для взаимодействия с Feature Store. Feast поддерживает on-demand трансформации: вычисление признаков в момент запроса на основе других фичей. Это полезно для легковесных операций, которые не имеет смысла материализовывать. Примеры кода: регистрация и получение признаков Работа с библиотекой Feast начинается с определения источника данных и entity. Entity — это объект, для которого вычисляются признаки (пользователь, продукт, сессия). from feast import Entity, FeatureView, Field, FileSource from feast.types import Float32, Int64 from datetime import timedelta # Определение entity user = Entity( name="user_id", description="User identifier" ) # Источник данных для offline storage user_stats_source = FileSource( path="data/user_stats.parquet", timestamp_field="event_timestamp" ) # Feature view определяет набор признаков user_stats_fv = FeatureView( name="user_stats", entities=[user], ttl=timedelta(days=30), schema=[ Field(name="total_purchases", dtype=Int64), Field(name="avg_order_value", dtype=Float32), Field(name="days_since_last_purchase", dtype=Int64), Field(name="purchase_frequency", dtype=Float32) ], source=user_stats_source ) После определения признаков их нужно зарегистрировать в Feast. Данная операция создает записи в registry и делает фичи доступными для использования. from feast import FeatureStore # Инициализация Feature Store fs = FeatureStore(repo_path=".") # Применение определений к registry # Feast сканирует Python файлы и регистрирует все feature views fs.apply([user, user_stats_fv]) # Материализация данных в online store # Данные из offline источника переносятся в Redis/DynamoDB from datetime import datetime fs.materialize( start_date=datetime(2024, 1, 1), end_date=datetime(2024, 12, 31) ) Получение признаков для обучения модели выполняется через метод get_historical_features. Feast формирует обучающий датасет с правильной временной корректностью. import pandas as pd # Entity dataframe с временными метками entity_df = pd.DataFrame({ "user_id": [1001, 1002, 1003, 1004], "event_timestamp": [ datetime(2024, 6, 15), datetime(2024, 6, 16), datetime(2024, 6, 17), datetime(2024, 6, 18) ] }) # Получение исторических значений признаков training_df = fs.get_historical_features( entity_df=entity_df, features=[ "user_stats:total_purchases", "user_stats:avg_order_value", "user_stats:days_since_last_purchase", "user_stats:purchase_frequency" ] ).to_df() print(training_df.head()) Представленный выше код формирует датасет, где для каждого пользователя на указанную дату подставляются значения признаков, актуальные на тот момент. Библиотека Feast автоматически обрабатывает временные соединения и исключает утечку в будущее (look-ahead bias). В продакшене для получения признаков обычно используется online store. Латентность решения составляет 1-5 миллисекунд на запрос. # Получение актуальных признаков для одного пользователя online_features = fs.get_online_features( features=[ "user_stats:total_purchases", "user_stats:avg_order_value", "user_stats:days_since_last_purchase", "user_stats:purchase_frequency" ], entity_rows=[{"user_id": 1001}] ).to_dict() # Результат готов для передачи в модель features_vector = [ online_features["total_purchases"][0], online_features["avg_order_value"][0], online_features["days_since_last_purchase"][0], online_features["purchase_frequency"][0] ] Online запросы поддерживают батчинг: можно получить признаки для нескольких entities одновременно, что снижает издержки на сетевые вызовы. Интеграция с ML-пайплайнами Feast интегрируется с основными ML-фреймворками через простой API. Для PyTorch типичный паттерн — создание custom Dataset класса, который запрашивает признаки из Feature Store. import torch from torch.utils.data import Dataset, DataLoader class FeatureStoreDataset(Dataset): def __init__(self, entity_df, feature_refs, labels): self.fs = FeatureStore(repo_path=".") self.feature_refs = feature_refs self.labels = labels # Получаем все признаки за один запрос self.features_df = self.fs.get_historical_features( entity_df=entity_df, features=feature_refs ).to_df() # Объединяем с таргетами self.data = self.features_df.merge( labels, on=["user_id", "event_timestamp"] ) def __len__(self): return len(self.data) def __getitem__(self, idx): row = self.data.iloc[idx] # Извлекаем признаки (все колонки кроме entity и timestamp) feature_cols = [col for col in self.data.columns if col not in ["user_id", "event_timestamp", "target"]] features = torch.tensor(row[feature_cols].values, dtype=torch.float32) target = torch.tensor(row["target"], dtype=torch.float32) return features, target # Использование в training loop entity_df = pd.read_csv("training_entities.csv") labels = pd.read_csv("labels.csv") dataset = FeatureStoreDataset( entity_df=entity_df, feature_refs=[ "user_stats:total_purchases", "user_stats:avg_order_value", "user_stats:purchase_frequency" ], labels=labels ) dataloader = DataLoader(dataset, batch_size=256, shuffle=True) model = torch.nn.Sequential( torch.nn.Linear(3, 64), torch.nn.ReLU(), torch.nn.Dropout(0.3), torch.nn.Linear(64, 32), torch.nn.ReLU(), torch.nn.Linear(32, 1), torch.nn.Sigmoid() ) optimizer = torch.optim.Adam(model.parameters(), lr=0.001) criterion = torch.nn.BCELoss() for epoch in range(10): for features, targets in dataloader: optimizer.zero_grad() outputs = model(features).squeeze() loss = criterion(outputs, targets) loss.backward() optimizer.step() Пример кода показывает стандартный паттерн интеграции Feast с PyTorch. Dataset класс инкапсулирует логику получения признаков из Feature Store, что упрощает эксперименты с разными комбинациями фичей: достаточно изменить список feature_refs. Для инференса в продакшен среде паттерн упрощается. API сервис получает entity ID из запроса, запрашивает признаки из online store, и передает их в модель. from fastapi import FastAPI import torch app = FastAPI() fs = FeatureStore(repo_path=".") model = torch.load("model.pt") model.eval() @app.post("/predict") async def predict(user_id: int): # Получение признаков из online store features_dict = fs.get_online_features( features=[ "user_stats:total_purchases", "user_stats:avg_order_value", "user_stats:purchase_frequency" ], entity_rows=[{"user_id": user_id}] ).to_dict() # Формирование вектора признаков features = torch.tensor([ features_dict["total_purchases"][0], features_dict["avg_order_value"][0], features_dict["purchase_frequency"][0] ], dtype=torch.float32) # Inference with torch.no_grad(): prediction = model(features).item() return {"user_id": user_id, "prediction": prediction} Латентность запроса определяется скоростью online store (1-5 мс) плюс inference (зависит от размера модели). Для большинства REST API это укладывается в приемлемые 10-50 миллисекунд. Инжиниринг признаков в Feature Store Материализация признаков Материализация переносит вычисленные признаки из offline в online хранилище. Процесс запускается по расписанию или при обновлении исходных данных. Частота материализации зависит от требований к свежести признаков: Для признаков, обновляемых раз в сутки (агрегаты по историческому поведению), материализация выполняется, как правило, ночью; Признаки, требующие обновления каждый час (недавняя активность пользователя), материализуются чаще; Стриминговые признаки обновляются в реальном времени через сервисы Kafka или Kinesis без полной материализации. Инкрементальная материализация (Incremental materialization) оптимизирует процесс: вместо пересчета всех признаков обрабатываются только изменившиеся entities. Это особенно важно для крупных наборов данных, где полная материализация может занимать часы. Feast поддерживает инкрементальный режим за счет отслеживания последней успешной материализации. from feast import FeatureStore from datetime import datetime, timedelta fs = FeatureStore(repo_path=".") # Полная материализация для первого запуска fs.materialize( start_date=datetime(2024, 1, 1), end_date=datetime.now() ) # Инкрементальная материализация для регулярных обновлений # Feast автоматически определяет временной диапазон с последней материализации fs.materialize_incremental( end_date=datetime.now() ) Стратегия материализации влияет на консистентность данных. При обновлении признаков существует окно, когда online store содержит старые значения. Для критичных приложений используется атомарная замена: новые данные загружаются в отдельное пространство имен, затем происходит переключение. Онлайн трансформации Трансформации по запросу (On-demand) вычисляют признаки в момент запроса на основе других фичей или входных данных. Данный метод подходит для легковесных операций: математические преобразования, комбинации существующих признаков, нормализации. Библиотека Feast поддерживает представление признаков, вычисляемых по запросу, с помощью функций на Python. Такие трансформации выполняются как при формировании обучающего датасете, так и в online режиме, что гарантирует консистентность между обучением и инференсом. from feast import Field, on_demand_feature_view from feast.types import Float32, Int64 @on_demand_feature_view( sources=[user_stats_fv], schema=[ Field(name="purchase_recency_score", dtype=Float32), Field(name="purchase_frequency_normalized", dtype=Float32) ] ) def user_derived_features(inputs: pd.DataFrame) -> pd.DataFrame: df = pd.DataFrame() # Скоринг на основе давности покупки # Более свежие покупки дают выше скор df["purchase_recency_score"] = 1.0 / (inputs["days_since_last_purchase"] + 1) # Нормализация частоты покупок # Логарифм сглаживает выбросы df["purchase_frequency_normalized"] = np.log1p(inputs["purchase_frequency"]) return df Трансформации по запросу (on-demand transformations) добавляют небольшую задержку (latency) — обычно менее 3 мс. Однако при этом экономят место в онлайн-хранилище (online storage) и упрощают обновление логики без повторной материализации данных. Выбор баланса между полной материализацией и on-demand вычислениями зависит от сложности трансформаций и требований к задержке. Сложные агрегации, требующие соединения нескольких таблиц или временных окон, выполняются на этапе создания признаков, а не on-demand. Материализация таких фичей происходит заранее, чтобы не перегружать online систему. Учет корректности фичей по времени (Point-in-time correctness) Метод Point-in-time correctness предотвращает утечку данных из будущего в обучающий датасет. При формировании обучающего примера на дату T признаки должны вычисляться только на основе данных, доступных до момента T. Feast автоматически обеспечивает соблюдение корректности во времени с помощью объединений по временной метке (timestamp-based joins). Для каждой строки в таблице сущностей Feast находит последнее значение признака с временной меткой (timestamp) <= этой строки (event_timestamp). # Entity dataframe с временными метками прогнозов entity_df = pd.DataFrame({ "user_id": [1001, 1001, 1001], "event_timestamp": [ datetime(2024, 6, 1), datetime(2024, 7, 1), datetime(2024, 8, 1) ] }) # Feast подтянет признаки с учетом временных меток # Для event_timestamp = 2024-06-01 используются признаки, # вычисленные до этой даты training_df = fs.get_historical_features( entity_df=entity_df, features=["user_stats:total_purchases"] ).to_df() TTL (time-to-live) параметр в feature view определяет, как долго значение признака считается актуальным. Если последнее обновление признака произошло раньше, чем event_timestamp - TTL, Feast вернет null. Такой подход корректно обрабатывает ситуации с редко обновляемыми entities. user_stats_fv = FeatureView( name="user_stats", entities=[user], ttl=timedelta(days=30), # Признак актуален 30 дней schema=[...], source=user_stats_source ) Метод Point-in-time correctness показывает наибольшую важность для временных рядов и прогностических задач. Нарушение этого правила приводит к завышенным метрикам качества на валидации, которые не воспроизводятся в продакшене. Использование в продакшене Задержки и производительность Online store должен обеспечивать латентность на уровне единиц миллисекунд для большинства ML-приложений. Redis, DynamoDB, Bigtable обеспечивают p95 latency <5 мс при правильной конфигурации. Производительность зависит от нескольких факторов: Размер запроса: получение 10 признаков для одного entity быстрее, чем 100 признаков; Батчинг: запрос признаков для 100 entities одновременно эффективнее, чем 100 отдельных запросов; Сетевая близость: Feature Store должен находиться в том же регионе/AZ, что и inference сервис; Кеширование: промежуточное кеширование на уровне приложения снижает нагрузку на online store. Feast поддерживает батчинг через передачу нескольких строк сущностей (entity_rows) в метод get_online_features. Это особенно важно для пакетного инференса (batch inference) или обработки потоков данных с высоким пропускным потоком. # Батчинг запросов для множества пользователей entity_rows = [ {"user_id": user_id} for user_id in user_ids_batch ] features_batch = fs.get_online_features( features=["user_stats:total_purchases", "user_stats:avg_order_value"], entity_rows=entity_rows ).to_dict() # Обработка результатов батчем for i, user_id in enumerate(user_ids_batch): features = [ features_batch["total_purchases"][i], features_batch["avg_order_value"][i] ] predictions.append(model.predict(features)) Мониторинг латентности хранилища признаков интегрируется в общий пайплайн наблюдаемости (observability pipeline). Метрики собираются как на стороне клиента (SDK), так и на стороне онлайн-хранилища (online store). Алерты настраиваются для 95-го и 99-го перцентилей латентности (p95/p99 latency) и уровня ошибок (error rate). Мониторинг качества признаков Сдвиг данных в признаках (Data Drift) может приводить к деградации моделей. Мониторинг метрик качества позволяет выявлять такие проблемы до того, как они начнут влиять на бизнес-метрики. Основные категории проверок: Полнота (Completeness): доля пропущенных значений не превышает заданный порог. Актуальность (Freshness): признаки обновлены в ожидаемом временном окне. Сдвиг распределения (Distribution Shift): статистические характеристики (среднее, стандартное отклонение, перцентили) не отклоняются существенно от базового распределения (baseline). Валидация схемы (Schema Validation): типы данных соответствуют определению. Библиотека Great Expectations интегрируется с Feast для автоматизации проверок качества. Ожидания определяются для каждого feature view и проверяются при материализации. import great_expectations as ge from feast import FeatureStore fs = FeatureStore(repo_path=".") # Получение датафрейма с признаками features_df = fs.get_historical_features( entity_df=entity_df, features=["user_stats:total_purchases", "user_stats:avg_order_value"] ).to_df() # Создание Great Expectations dataset ge_df = ge.from_pandas(features_df) # Определение ожиданий ge_df.expect_column_values_to_not_be_null("total_purchases") ge_df.expect_column_values_to_be_between( "avg_order_value", min_value=0, max_value=10000 ) ge_df.expect_column_mean_to_be_between( "total_purchases", min_value=5, max_value=50 ) # Валидация validation_result = ge_df.validate() if not validation_result["success"]: print("Quality checks failed:") for result in validation_result["results"]: if not result["success"]: print(f" - {result['expectation_config']['expectation_type']}") print(f" {result['exception_info']['raised_exception']}") Мониторинг настраивается как часть материализации. При обнаружении аномалий процесс останавливается, отправляется алерт, и данные не попадают в online store. Это предотвращает распространение некачественных признаков в продакшене. Distribution monitoring требует сравнения текущих значений с reference dataset. Обычно используется скользящее окно: последние 7 дней сравниваются с предыдущими 7 днями. Статистические тесты (Kolmogorov-Smirnov, Jensen-Shannon divergence) количественно оценивают вероятное смещение (drift). Стратегии кеширования Кеширование на уровне приложения снижает нагрузку на online store и уменьшает латентность. Эффективность зависит от паттернов доступа к признакам. Для признаков, которые редко меняются в течение дня (например, агрегаты по истории или демографические данные) клиентский кэш (client-side cache) с временем жизни (TTL) от 1 до 6 часов является приемлемым. Признаки, отражающие недавнее поведение пользователя, обычно кэшируются на несколько минут или вообще не кэшируются, чтобы обеспечивать актуальность данных. from functools import lru_cache from datetime import datetime, timedelta class CachedFeatureStore: def __init__(self, fs, cache_ttl_minutes=60): self.fs = fs self.cache_ttl = timedelta(minutes=cache_ttl_minutes) self.cache = {} def get_online_features(self, features, entity_rows): # Генерация ключа кеша cache_key = self._make_cache_key(features, entity_rows) # Проверка кеша if cache_key in self.cache: cached_data, timestamp = self.cache[cache_key] if datetime.now() - timestamp < self.cache_ttl: return cached_data # Запрос из Feature Store result = self.fs.get_online_features( features=features, entity_rows=entity_rows ) # Сохранение в кеш self.cache[cache_key] = (result, datetime.now()) return result def _make_cache_key(self, features, entity_rows): # Простая имплементация, в production нужна более робастная логика return hash((tuple(features), tuple(str(row) for row in entity_rows))) # Использование fs = FeatureStore(repo_path=".") cached_fs = CachedFeatureStore(fs, cache_ttl_minutes=30) features = cached_fs.get_online_features( features=["user_stats:total_purchases"], entity_rows=[{"user_id": 1001}] ) Redis или Memcached на уровне приложения обеспечивают распределенное кэширование (distributed caching) между инстансами сервиса. Это особенно полезно в окружении Kubernetes с автоскейлингом (autoscaling). Стратегия инвалидирования (invalidation) оказывает прямое влияние на корректность данных. При обновлении признаков в онлайн-хранилище (online store) кэш должен сбрасываться. Это может происходить двумя путями: Простейший подход — использование времени жизни кэша (TTL); Более сложный — нотификации через pub/sub от хранилища признаков (Feature Store) при материализации. Сравнение open-source решений Из open-source решений для хранения признаков сегодня наиболее популярны 3 сервиса. Они различаются архитектурой, зрелостью и подходом к интеграции с инфраструктурой. Feast Наиболее зрелое и широко используемое решение; Легковесная архитектура без собственных вычислительных компонентов; Поддержка множества бекендов: Snowflake, BigQuery, Redshift, Spark, DynamoDB, Redis, Cassandra; Registry может работать в файловом режиме или SQL; Хорошая документация и активное коммьюнити; Минус - Отсутствие встроенного UI (требуются сторонние инструменты). Tecton Акцент на stream processing и реал-тайм фичи; Интеграция с Spark Structured Streaming, Flink, Kinesis; Встроенный UI для управления feature definitions; Автоматический мониторинг качества данных; Минус - open-source версия ограничена по функциональности. Hopsworks Feature Store Тесная интеграция с Spark, Hive, HBase; Feature Store как часть end-to-end ML платформы; Встроенные возможности для инжиниринга признаков в Spark; Есть готовый интерфейс (UI) для поиска, исследований и управления признаками; Минус - требует развертывания полной Hopsworks платформы. Выбор хранилища признаков зависит от существующей инфраструктуры и требований: Практические рекомендации по выбору Feast подходит командам, которые хотят добавить Feature Store в существующую инфраструктуру без масштабных изменений. Поддержка различных бекендов позволяет использовать уже развернутые системы. Отсутствие UI компенсируется простотой Python API. Tecton оптимален для проектов с акцентом на признаки в реальном времени и сложный feature engineering. Встроенный мониторинг и управление пайплайнами данных помогают снижать эксплуатационные издержки. Однако коммерческая модель может стать барьером для небольших команд. Hopsworks имеет смысл при построении ML платформы с нуля или при наличии существующей инфраструктуры на Spark. Интеграция хранилиза признаков с обучением, инференсом и мониторингом упрощает сквозной ML-пайплайн, однако требует полной приверженности к экосистеме Hopsworks. Для первого опыта с Feature Store рекомендуется сервис Feast. Тут минимальные требования к инфраструктуре, хорошая документация, простота миграции на другое решение при необходимости. Проверка концепта занимает дни, а не недели. Заключение Feature Store трансформирует подход к управлению признаками в ML-проектах. Централизация устраняет дублирование работы, версионирование обеспечивает воспроизводимость экспериментов, единое API гарантирует консистентность между обучением и продакшеном. Организация экономит инженерное время, снижает риск ошибок, ускоряет разработку новых моделей. Однако важно учитывать, что внедрение хранилища признаков оправдано в уже зрелых командах: от 4-5 дата саентистов. Либо в проектах, где переиспользование признаков между проектами дает ощутимый эффект. Для индивидуальных проектов или простых моделей это лишний инструмент, в котором накладные расходы превышают выгоды. ### Фрактальный анализ финансовых рынков: показатель Херста, R/S анализ, фрактальная размерность временных рядов Финансовые рынки обладают сложной многомасштабной структурой, которую порой невозможно описать с помощью стандартных методов. Фрактальный анализ предлагает альтернативный подход, основанный на самоподобии и долговременной памяти временных рядов. Ключевое отличие фрактального подхода от стандартных методов: вместо анализа моментов распределения изучается структура автокорреляций на разных временных масштабах. Это позволяет выявить персистентность (тренды) или антиперсистентность (реверсии) в ценовых движениях, что напрямую влияет на выбор торговых стратегий. Показатель Херста: оценка персистентности рынка Показатель Херста (H) количественно описывает характер автокорреляций в временном ряде. Значение H определяет скорость роста размаха накопленных отклонений относительно среднего квадратичного отклонения при увеличении временного окна. Для временного ряда X₁, X₂, ..., Xₙ показатель Херста связан с перенормированным размахом R/S через степенной закон: E[R/S] = (n/2)^H где: R — размах накопленных отклонений от среднего; S — стандартное отклонение исходного ряда; n — размер временного окна; E[·] — математическое ожидание. Интерпретация значений показателя Херста определяет тип памяти в системе: H = 0.5: случайное блуждание, отсутствие памяти. Приращения независимы, прошлые движения не влияют на будущие. Дисперсия растет линейно со временем. H > 0.5: персистентность (трендовость). Положительные приращения с большей вероятностью сменяются положительными, отрицательные — отрицательными. Система демонстрирует долговременную память. При H → 1 ряд приближается к детерминированному тренду. H < 0.5: антиперсистентность (mean reversion). Положительные приращения чаще сменяются отрицательными и наоборот. Ряд стремится вернуться к среднему значению. При H → 0 наблюдаются максимально выраженные реверсии. Связь с фрактальной размерностью описывается соотношением D = 2 - H, где D — фрактальная размерность траектории. Для случайного блуждания D = 1.5, для персистентных рядов D < 1.5, для антиперсистентных D > 1.5. R/S анализ: методология расчета R/S анализ (rescaled range analysis) — классический метод оценки показателя Херста. Алгоритм расчета для временного ряда длины N следующий: 1) Вычисление накопленных отклонений от среднего: Y(t) = Σᵢ₌₁ᵗ (Xᵢ - X̄) где: X̄ — среднее значение ряда X₁, ..., Xₙ t = 1, 2, ..., n 2) Определение размаха R: R(n) = max(Y(t)) - min(Y(t)), t ∈ [1, n] где размах измеряет максимальное отклонение накопленной суммы от нуля. 3) Расчет стандартного отклонения S: S(n) = √(1/n · Σᵢ₌₁ⁿ (Xᵢ - X̄)²) 4) Вычисление перенормированного размаха: (R/S)(n) = R(n) / S(n) 5) Повторение процедуры для различных размеров окон n. Используются окна n = [16, 32, 64, 128, 256, ...] или другие степени 2 для равномерного покрытия логарифмической шкалы. 6) Оценка показателя Херста через линейную регрессию в логарифмических координатах: log(R/S) = H · log(n) + c где коэффициент наклона H является искомым показателем Херста, а c — константа. Модификация Anis-Lloyd учитывает краткосрочные корреляции, вычитая из R/S вклад автокорреляционной функции первого порядка. Это снижает смещение оценки для рядов с короткой памятью. Модификация Lo более радикальна: вместо стандартного отклонения S используется робастная оценка волатильности, учитывающая автокорреляции до лага q: S²ᵩ(n) = S²(n) + 2·Σⱼ₌₁ᵩ wⱼ·γⱼ где: γⱼ — автоковариация при лаге j wⱼ = 1 - j/(q+1) — веса q — максимальный лаг (обычно q = √n) Модификация Lo весьма часто применяется для анализа высокочастотных данных, где микроструктурные эффекты создают ложную антиперсистентность. Применение в трейдинге Выбор торговой стратегии напрямую зависит от значения показателя Херста: Для персистентных рынков (H > 0.5): Эффективны трендследящие стратегии. Momentum-подходы генерируют положительную доходность, так как текущее движение с высокой вероятностью продолжится. Оптимальны длинные периоды удержания позиций и широкие стоп-лоссы для избежания преждевременных выходов при краткосрочных коррекциях. Для антиперсистентных рынков (H < 0.5): Работают стратегии возврата к средним (mean reversion). Статистический арбитраж, парный трейдинг и контртрендовые подходы используют свойство цен возвращаться к среднему. Здесь важны быстрое исполнение и короткие периоды удержания для захвата реверсий до следующего отклонения. Для случайных рынков (H ≈ 0.5): Систематические стратегии без дополнительной информации неэффективны. Требуется переход к альтернативным источникам данных, микроструктурный анализ, взаимосвязи между активами (cross-asset relationships). Ограничения метода: Показатель Херста нестационарен во времени. Рынки меняют режимы: периоды персистентности сменяются периодами реверсий. Расчет H на историческом окне не гарантирует сохранение свойств в будущем. Требуется скользящее окно для отслеживания изменений режима, что создает временные лаги в детекции переключений. R/S анализ чувствителен к выбросам и нестационарности среднего. Структурные сдвиги в уровне цен искажают оценку H в сторону завышения персистентности. Предварительная детрендизация или работа с доходностями вместо цен снижает эту проблему. Минимальный размер выборки для надежной оценки составляет 500-1000 наблюдений. При меньших объемах данных стандартная ошибка оценки H достигает 0.1-0.15, что делает различие между H = 0.45 и H = 0.55 статистически незначимым. Фрактальная размерность временных рядов Фрактальная размерность количественно описывает сложность траектории временного ряда в фазовом пространстве. В отличие от топологической размерности (которая для любого графика функции равна 1), фрактальная размерность принимает нецелые значения и отражает степень заполнения пространства. Метод Box-counting Метод Box-counting определяет размерность через скорость изменения минимального количества покрывающих элементов при изменении масштаба. Формула расчета Box-counting следующая: D = lim(ε→0) log N(ε) / log(1/ε) где: N(ε) — минимальное количество боксов (ячеек) размера ε, необходимое для покрытия графика; ε — размер ячейки (масштаб измерения); D — фрактальная размерность. Алгоритм расчета для временного ряда (t, X(t)): Нормализация ряда к единичному квадрату [0,1] × [0,1] для устранения влияния абсолютных значений; Разбиение плоскости на сетку с ячейками размера ε. Используются значения ε = 1/2, 1/4, 1/8, 1/16, ..., покрывающие диапазон масштабов; Подсчет количества ячеек N(ε), через которые проходит график функции; Построение log-log графика зависимости log N(ε) от log(1/ε); Фрактальная размерность D определяется как угловой коэффициент линейной регрессии на этом графике. Для гладких функций D = 1, что соответствует топологической размерности линии. Для фрактальных кривых 1 < D < 2, причем большие значения D указывают на более изрезанную, сложную траекторию. Связь фрактальной размерности с показателем Херста описывается соотношением D = 2 - H. Это позволяет интерпретировать D в терминах персистентности: D = 1.5 (H = 0.5): случайное блуждание; D < 1.5 (H > 0.5): гладкие персистентные траектории; D > 1.5 (H < 0.5): изломанные антиперсистентные траектории. Для финансовых временных рядов типичные значения D находятся в диапазоне 1.4-1.6, что указывает на близость к случайному блужданию с небольшими отклонениями в сторону персистентности или антиперсистентности. Альтернативные методы оценки Метод Higuchi fractal dimension использует прямое измерение длины кривой на разных временных масштабах. Метод более устойчив к нестационарности и не требует фазового пространства. Для временного ряда X(1), X(2), ..., X(N) строятся k подпоследовательностей: X^k_m = X(m), X(m+k), X(m+2k), ... где: m = 1, 2, ..., k — начальное смещение; k — временной интервал между точками. Для каждой подпоследовательности рассчитывается нормализованная длина кривой: L_m(k) = (1/k) · [(N-1)/([(N-m)/k]·k)] · Σᵢ₌₁^[(N-m)/k] |X(m+i·k) - X(m+(i-1)·k)| Средняя длина по всем подпоследовательностям: L(k) = (1/k) · Σₘ₌₁ᵏ L_m(k) Фрактальная размерность определяется из степенного закона L(k) ∝ k^(-D) через линейную регрессию log L(k) на log(1/k). Преимущество метода Higuchi: не требуется визуализация в двумерном пространстве, расчет выполняется непосредственно на одномерном временном ряде. Это снижает вычислительную сложность и упрощает автоматизацию. Корреляционная размерность (Grassberger-Procaccia) анализирует распределение расстояний между точками в реконструированном фазовом пространстве. Метод чувствителен к детерминированному хаосу и позволяет отличить стохастические процессы от хаотических динамических систем. Корреляционный интеграл определяется как: C(r) = lim(N→∞) (1/N²) · Σᵢ,ⱼ₌₁ᴺ Θ(r - ||X_i - X_j||) где: Θ — функция Хевисайда; r — радиус окрестности; ||·|| — евклидова норма; X_i — точки в реконструированном фазовом пространстве. Корреляционная размерность D₂ определяется из степенного закона C(r) ∝ r^(D₂) в пределе малых r. Сравнение методов показывает их различную чувствительность к свойствам данных: Box-counting оптимален для визуального анализа и интуитивной интерпретации, однако требует большого объема данных (N > 2000) для надежной оценки; Метод Higuchi работает с меньшими выборками (N > 500) и более устойчив к шуму; Корреляционная размерность отлично работает в задачах детекции низкоразмерного хаоса, однако неэффективна для высокоразмерных стохастических процессов, типичных для финансовых рынков. Связь с волатильностью и режимами рынка Фрактальная размерность коррелирует с режимом волатильности рынка: В периоды высокой волатильности D увеличивается: траектория цен становится более изломанной, с частыми разнонаправленными движениями; В периоды низкой волатильности D снижается: цены движутся более гладко, с меньшим количеством резких изменений направления. Теоретическое обоснование связи: волатильность определяет масштаб флуктуаций, а фрактальная размерность — их частоту. Высокочастотные флуктуации увеличивают локальную изломанность траектории, что повышает D при фиксированном временном масштабе наблюдения. Эмпирические исследования на данных S&P 500 показывают: в периоды кризисов (2008, 2020) фрактальная размерность возрастает до D ≈ 1.65-1.75, что соответствует антиперсистентному режиму с H ≈ 0.25-0.35. В спокойные периоды D ≈ 1.45-1.55, что близко к случайному блужданию или слабой персистентности. Связь D с типом рынка: Трендовые рынки: D < 1.5, траектория относительно гладкая. Низкая размерность указывает на доминирование долгосрочных компонент движения над краткосрочным шумом. Визуально график имеет четкое направление с небольшими коррекциями; Флэтовые рынки с высокой волатильностью: D > 1.6, траектория сильно изломана. Высокая размерность отражает быстрые переключения направления без формирования устойчивого тренда. Внутридневная волатильность высока при отсутствии направленного движения; Переходные режимы: D ≈ 1.5-1.6, промежуточное состояние между трендом и флэтом. Такие периоды типичны для начала или окончания трендов, когда рынок теряет направленность но еще не перешел в полноценную консолидацию. Практическое применение включает адаптацию параметров торговых систем к текущему значению D. При D < 1.5 увеличиваются периоды расчета индикаторов и ширина стоп-лоссов для захвата трендового движения. При D > 1.6 сокращаются таймфреймы и усиливается риск-менеджмент для защиты от высокой волатильности без тренда. Комбинированный подход: показатель Херста + фрактальная размерность Математическая логика объединения метрик Совместное использование показателя Херста и фрактальной размерности создает двумерное пространство состояний рынка. Хотя D = 2 - H теоретически связывает эти метрики, практические оценки часто демонстрируют отклонения от точного соотношения из-за различий в методах расчета и чувствительности к особенностям данных. Показатель Херста, оцененный через R/S анализ, отражает долговременную память на больших временных масштабах (от десятков до сотен наблюдений). Фрактальная размерность, особенно в методе box-counting, более чувствительна к локальной структуре на малых масштабах (единицы наблюдений). Это различие в масштабах анализа делает метрики частично независимыми индикаторами. Построение двумерного индикатора (H, D) позволяет классифицировать рыночные режимы: Зона 1: H > 0.55, D < 1.45 — сильная персистентность на всех масштабах. Устойчивые тренды с гладкими траекториями. Оптимальны долгосрочные трендследящие стратегии с широкими стоп-лоссами. Риск: запаздывание детекции разворота тренда. Зона 2: H < 0.45, D > 1.55 — сильная антиперсистентность с высокой локальной изломанностью. Mean reversion на всех таймфреймах. Эффективны краткосрочные контртрендовые стратегии и статистический арбитраж. Риск: ложные сигналы на начале формирования нового тренда. Зона 3: H ≈ 0.5, D ≈ 1.5 — случайное блуждание. Отсутствие структуры для эксплуатации. Систематические стратегии неэффективны без дополнительной информации. Переход к нейтральным позициям или использование альтернативных источников сигналов. Зона 4: H > 0.5, D > 1.5 — долговременная персистентность с высокой краткосрочной волатильностью. Общий тренд существует, но с сильными внутритрендовыми коррекциями. Требуется балансировка: трендследящие стратегии с адаптивными стоп-лоссами, расширяющимися при росте краткосрочной волатильности. Зона 5: H < 0.5, D < 1.5 — долговременная антиперсистентность с гладкими локальными траекториями. Редкий режим, типичен для управляемых рынков или активов с жестким курсовым таргетированием. Mean reversion работает на долгосрочном горизонте при низком шуме на коротких периодах. Математически степень рассогласования между H и D можно квантифицировать через метрику: Δ = |D - (2 - H)| где Δ > 0.1 указывает на значимое расхождение оценок, требующее осторожности в интерпретации. Высокие значения Δ возникают при нестационарности, структурных сдвигах или наличии нескольких временных масштабов с различными свойствами. Детекция смены режимов рынка Траектория точки (H(t), D(t)) в двумерном пространстве индикаторов позволяет идентифицировать переходы между режимами до их проявления в ценах. Ранние сигналы смены режима возникают когда метрики начинают совместное движение в новую зону классификации. Критическими являются переходы из зон устойчивых трендов (зона 1) в зоны высокой волатильности (зоны 2 или 4). Математически переход обнаруживается через формулы: Скорость изменения метрик: v_H = (H(t) - H(t-Δt)) / Δt v_D = (D(t) - D(t-Δt)) / Δt Направление движения в пространстве (H, D): θ = arctan(v_D / v_H) Евклидово расстояние от текущей точки до центра новой зоны: d = √[(H(t) - H_центр)² + (D(t) - D_центр)²] Алерт генерируется когда скорость |v| = √(v_H² + v_D²) превышает пороговое значение (обычно 90-й процентиль исторического распределения) и направление θ указывает на движение к границе зоны. Практическая реализация требует учета запаздываний в интерпретации метрик. Показатель Херста на скользящем окне N = 252 наблюдения (годовые данные) обновляется ежедневно, но эффективно отражает свойства ряда за последние N/2 ≈ 126 дней. Фрактальная размерность с меньшим окном (N = 60-90) быстрее реагирует на изменения, создавая естественное разделение: D как опережающий (leading) индикатор, H как подтверждающий (confirming) индикатор. Комбинированный сигнал смены режима формируется по правилу: D пересекает критический уровень (например, D = 1.55 для перехода к высокой волатильности); И в течение следующих 10-20 периодов H движется в том же направлении (снижается для подтверждения роста антиперсистентности). Это двухэтапное подтверждение снижает количество ложных сигналов по сравнению с использованием единственной метрики. Адаптация параметров стратегий Динамическая корректировка параметров торговых систем на основе текущих значений (H, D) повышает устойчивость к изменениям рыночного режима. Ключевые параметры для адаптации: Период расчета индикаторов Базовый период P₀ масштабируется как: P = P₀ · (H / 0.5)^α где α ≈ 1.5-2 — параметр чувствительности. При H = 0.6 (персистентность) период увеличивается на 30-60%, при H = 0.4 (антиперсистентность) сокращается на аналогичную величину. Это обеспечивает оптимальное соотношение чувствительности и запаздывания индикатора к текущим свойствам ряда. Ширина стоп-лосса Расстояние до стоп-лосса адаптируется к ожидаемой амплитуде флуктуаций: SL = SL₀ · √(D - 1) где SL₀ — базовая ширина. Коэффициент √(D - 1) растет с увеличением локальной изломанности, защищая позиции от преждевременного закрытия при высокой краткосрочной волатильности без изменения долгосрочного направления. Размер позиции Риск на сделку масштабируется обратно пропорционально неопределенности: Position Size = Base Size · (1 - |H - 0.5| / 0.5) · (1 - |D - 1.5| / 0.5) Множители снижаются при отклонении метрик от нейтральных значений, уменьшая экспозицию в неопределенных или переходных режимах. Частота ребалансировки портфеля В режимах высокой персистентности (H > 0.6, D < 1.4) оптимальна низкая частота ребалансировки для минимизации транзакционных издержек и сохранения трендовых позиций. В антиперсистентных режимах (H < 0.4, D > 1.6) увеличение частоты ребалансировки позволяет эффективнее захватывать реверсии к среднему. Математически оптимальная частота ребалансировки f (в днях) связана с характеристикой автокорреляций: f ≈ 1 / |2H - 1| При H = 0.5 (случайное блуждание) оптимальная частота не определена и зависит от внешних факторов. При H = 0.7 f ≈ 2.5 дня, при H = 0.3 f ≈ 2.5 дня. Симметричность относительно H = 0.5 отражает одинаковую скорость затухания автокорреляций для персистентных и антиперсистентных процессов с одинаковым |H - 0.5|. Адаптивные стратегии с параметрами, привязанными к (H, D), демонстрируют меньшую просадку в периоды смены режимов по сравнению со статическими настройками. Эмпирически снижение максимальной просадки составляет 15-25% при сохранении средней доходности, что улучшает коэффициент Шарпа (Sharpe ratio) на 0.15-0.3 пункта. Заключение Фрактальный анализ предоставляет количественный инструментарий для диагностики структуры финансовых временных рядов за пределами классических статистических предположений: Показатель Херста выявляет долговременную память и определяет оптимальный класс торговых стратегий: трендследящие для персистентных рынков, реверсивные к среднему (mean reversion) для антиперсистентных. Фрактальная размерность дополняет анализ, описывая локальную сложность траектории и связь с режимом волатильности. Совместное использование метрик в двумерном пространстве (H, D) создает систему раннего предупреждения о смене рыночных режимов. Практическая значимость фрактального подхода особенно заметна в адаптивных торговых системах, где параметры стратегий динамически подстраиваются под текущие фрактальные свойства рынка. Такой подход повышает устойчивость к структурным сдвигам и снижает риск значительных просадок при неожиданных изменениях динамики цен. Таким образом, фрактальный анализ не генерирует торговые сигналы напрямую, но задает контекст, в котором любые сигналы могут быть интерпретированы и применены более эффективно. ### Обучение baseline моделей для временных рядов: инжиниринг признаков, регуляризация, оценка качества Бейзлайн (baseline) — это простая стартовая модель, определяющая минимально приемлемый уровень качества следующих ML-моделей. Если другие модели с более сложной архитектурой выдают метрики хуже бейзлайна, значит, их применение неоправданно. Нередко так бывает, что линейная регрессия с правильными признаками превосходит нейросеть со слабой подготовкой данных. А градиентный бустинг с грамотной регуляризацией обходит LSTM на горизонте прогноза в 1-5 дней для большинства финансовых инструментов. Это значит что бейзлайн модель лучше улавливает закономерности в данных и следует менять либо архитектуру модели, либо подход к ее обучению. Выбор baseline архитектуры для временных рядов Сегодня большинство задач прогнозирования покрывают 3 класса моделей: Линейные методы с регуляризацией; Градиентный бустинг; Модели Prophet и ETS. Выбор зависит от объема данных, горизонта прогноза и требований к интерпретируемости. Линейные модели остаются сильным бейзлайном при малом количестве данных. Ridge и Lasso регрессии эффективно работают на выборках от 200–300 наблюдений, предотвращая переобучение за счет регуляризации. В Ridge веса коррелированных признаков сжимаются равномерно, а Lasso обнуляет незначимые, автоматически отбирая релевантные переменные. В прогнозировании финансовых рядов Lasso нередко оставляет лишь 10–20 ключевых лагов из сотен, обеспечивая простую и интерпретируемую модель. Модели Prophet и ETS (Error, Trend, Seasonality) решают задачи с выраженной сезонностью и трендом. Так, к примеру, Prophet декомпозирует ряд на тренд, сезонность и праздники, подходит для бизнес-метрик с недельными и годовыми циклами. ETS тоже моделирует уровень, тренд и сезонность, однако делает это через экспоненциальное сглаживание, тогда как Prophet явно декомпозирует ряд на тренд, сезонность и внешние эффекты (например, праздники). Модели Prophet и ETS лично я часто использую как бейзлайны. Они хорошо справляются с временными рядами, где есть ярко выраженные зависимости и дают неплохие прогнозы на месяц или квартал. Для краткосрочного прогноза цен активов эти методы уступают моделям градиентного бустинга — финансовые ряды слабо детерминированы и требуют гибкости в захвате нелинейных зависимостей. Градиентный бустинг уверенно доминирует на средних и больших выборках благодаря высокой эффективности и способности работать с разнородными данными: LightGBM способен обрабатывать миллионы строк за считанные минуты за счет histogram-based алгоритма разбиения и продуманной оптимизации памяти. CatBoost особенно эффективен при наличии категориальных признаков, таких как тикеры, сектора или типы финансовых инструментов. Все благодаря использованию ordered target encoding, который позволяет извлекать информацию из категорий без риска переобучения. В реальных задачах эти библиотеки нередко становятся не только бейзлайном, но и частью ансамбля моделей, применяемого для построения точных и интерпретируемых прогнозов на табличных данных. import numpy as np import pandas as pd from sklearn.linear_model import Ridge, Lasso from sklearn.preprocessing import StandardScaler from lightgbm import LGBMRegressor from catboost import CatBoostRegressor import yfinance as yf # Загрузка данных ticker = yf.Ticker("GOOG") #Google data = ticker.history(period="3y", interval="1d") # Проверка на MultiIndex if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) data = data[['Close', 'Volume']].dropna() data['returns'] = data['Close'].pct_change() # Создание лаговых признаков for lag in [1, 2, 3, 5, 10]: data[f'returns_lag_{lag}'] = data['returns'].shift(lag) data[f'volume_lag_{lag}'] = data['Volume'].shift(lag) data = data.dropna() # Целевая переменная: доходность через 1 день data['target'] = data['returns'].shift(-1) data = data.dropna() # Разделение на признаки и таргет feature_cols = [col for col in data.columns if 'lag' in col] X = data[feature_cols] y = data['target'] # Разделение на train/test split_idx = int(len(data) * 0.8) X_train, X_test = X[:split_idx], X[split_idx:] y_train, y_test = y[:split_idx], y[split_idx:] # Масштабирование для линейных моделей scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) # Ridge регрессия ridge = Ridge(alpha=1.0, random_state=42) ridge.fit(X_train_scaled, y_train) # Lasso регрессия lasso = Lasso(alpha=0.0001, random_state=42) lasso.fit(X_train_scaled, y_train) # LightGBM lgbm = LGBMRegressor( n_estimators=100, learning_rate=0.05, max_depth=3, subsample=0.8, colsample_bytree=0.8, random_state=42, verbose=-1 ) lgbm.fit(X_train, y_train) # CatBoost catboost = CatBoostRegressor( iterations=100, learning_rate=0.05, depth=3, subsample=0.8, random_state=42, verbose=0 ) catboost.fit(X_train, y_train) # Оценка на тестовой выборке from sklearn.metrics import mean_absolute_error, mean_squared_error models = { 'Ridge': (ridge, X_test_scaled), 'Lasso': (lasso, X_test_scaled), 'LightGBM': (lgbm, X_test), 'CatBoost': (catboost, X_test) } for name, (model, X_test_data) in models.items(): y_pred = model.predict(X_test_data) mae = mean_absolute_error(y_test, y_pred) rmse = np.sqrt(mean_squared_error(y_test, y_pred)) print(f"{name:12} MAE: {mae:.6f}, RMSE: {rmse:.6f}") Ridge MAE: 0.013874, RMSE: 0.020179 Lasso MAE: 0.013928, RMSE: 0.020189 LightGBM MAE: 0.014496, RMSE: 0.020402 CatBoost MAE: 0.014140, RMSE: 0.020149 Представленный выше код демонстрирует сравнение четырех baseline архитектур на данных акций Google за последние 3 года. Ridge и Lasso получают масштабированные признаки через StandardScaler — линейные модели чувствительны к разбросу значений. LightGBM и CatBoost работают с исходными данными, tree-based алгоритмы инвариантны к масштабу. Параметры регуляризации установлены консервативно: alpha=1.0 для Ridge; alpha=0.0001 для Lasso; max_depth=3 для бустинга. Мелкие деревья снижают риск переобучения на финансовых данных с высоким уровнем шума. Гиперпараметры subsample=0.8 и colsample_bytree=0.8 добавляют стохастичность, улучшая генерализацию. Результаты показывают MAE и RMSE на тестовой выборке. Для временных рядов доходностей MAE интерпретируется как средняя ошибка прогноза в процентах. RMSE больше штрафует крупные промахи — что более актуально для управления рисками. Если разница между моделями менее 5-10%, выбираем более простую архитектуру. Инжиниринг признаков для временных рядов Качество признаков определяет потолок производительности модели. Временные ряды содержат зависимости на разных временных масштабах: краткосрочную инерцию, внутридневные паттерны, недельную сезонность. Конструирование признаков выявляет эти структуры и делает их доступными для алгоритма. Лаговые переменные и скользящие окна Лаговые признаки захватывают автокорреляцию — зависимость текущего значения от прошлых. Для дневных данных lag_1 означает вчерашнее значение, lag_5 — неделю назад с учетом торговых дней. Выбор горизонта лагов зависит от частоты данных и характера зависимостей. Финансовые временные ряды демонстрируют короткую память: автокорреляция доходностей угасает через 1-10 дней. Для решения этой проблемы используются лаги разной периодичности: Включение lag_1 до lag_10 покрывает релевантный диапазон; Добавляются среднесрочные лаги: lag_20, lag_30 (хотя они обычно не улучшают качество — сигнал растворяется в шуме); Для внутридневных данных горизонт сокращается до lag_1...lag_50 баров по 5 минут. Скользящие (Rolling) статистики агрегируют информацию по свигающимся окнам по ряду. Так, к примеру: rolling_mean_5 усредняет 5 последних значений; rolling_std_10 измеряет волатильность на 10 рядах. Эти признаки сглаживают шум и выделяют тренды. Rolling_min и rolling_max определяют границы недавнего диапазона — полезно для стратегий возврата к среднему (mean-reversion). import pandas as pd import numpy as np import yfinance as yf pd.set_option('display.expand_frame_repr', False) # Загрузка данных ticker = yf.Ticker("TSM") data = ticker.history(period="2y", interval="1d") if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) data = data[['Close', 'Volume', 'High', 'Low']].dropna() data['returns'] = data['Close'].pct_change() # Лаговые признаки для доходностей for lag in range(1, 11): data[f'returns_lag_{lag}'] = data['returns'].shift(lag) # Лаговые признаки для объема for lag in [1, 5, 10]: data[f'volume_lag_{lag}'] = data['Volume'].shift(lag) # Rolling статистики windows = [5, 10, 20] for window in windows: data[f'returns_rolling_mean_{window}'] = data['returns'].rolling(window).mean() data[f'returns_rolling_std_{window}'] = data['returns'].rolling(window).std() data[f'price_rolling_min_{window}'] = data['Close'].rolling(window).min() data[f'price_rolling_max_{window}'] = data['Close'].rolling(window).max() # Относительная позиция цены в диапазоне for window in windows: price_min = data['Close'].rolling(window).min() price_max = data['Close'].rolling(window).max() data[f'price_position_{window}'] = (data['Close'] - price_min) / (price_max - price_min + 1e-8) # High-Low spread как мера внутридневной волатильности data['hl_spread'] = (data['High'] - data['Low']) / data['Close'] data['hl_spread_rolling_mean_5'] = data['hl_spread'].rolling(5).mean() # Удаление строк с NaN data = data.dropna() print(f"Количество признаков: {len([col for col in data.columns if 'lag' in col or 'rolling' in col or 'position' in col or 'spread' in col])}") print(f"Размер выборки: {len(data)}") print("\nПримеры признаков:") print(data[[col for col in data.columns if 'lag' in col or 'rolling' in col]].head()) Количество признаков: 30 Размер выборки: 480 Примеры признаков: returns_lag_1 returns_lag_2 returns_lag_3 returns_lag_4 returns_lag_5 returns_lag_6 returns_lag_7 returns_lag_8 returns_lag_9 returns_lag_10 ... price_rolling_max_5 returns_rolling_mean_10 returns_rolling_std_10 price_rolling_min_10 price_rolling_max_10 returns_rolling_mean_20 returns_rolling_std_20 price_rolling_min_20 price_rolling_max_20 hl_spread_rolling_mean_5 Date ... 2023-11-20 00:00:00-05:00 0.010554 -0.002631 -0.001112 0.025825 -0.010468 0.063523 -0.004130 -0.004437 -0.002052 0.008824 ... 97.365601 0.007889 0.022021 89.242065 97.365601 0.004850 0.020857 83.758186 97.365601 0.015462 2023-11-21 00:00:00-05:00 0.003816 0.010554 -0.002631 -0.001112 0.025825 -0.010468 0.063523 -0.004130 -0.004437 -0.002052 ... 97.365601 0.006543 0.023082 89.242065 97.365601 0.003784 0.021344 83.758186 97.365601 0.015825 2023-11-22 00:00:00-05:00 -0.015506 0.003816 0.010554 -0.002631 -0.001112 0.025825 -0.010468 0.063523 -0.004130 -0.004437 ... 97.365601 0.007231 0.022819 89.242065 97.365601 0.006088 0.018212 83.758186 97.365601 0.014656 2023-11-24 00:00:00-05:00 0.002439 -0.015506 0.003816 0.010554 -0.002631 -0.001112 0.025825 -0.010468 0.063523 -0.004130 ... 97.365601 0.006813 0.023087 93.917473 97.365601 0.005781 0.018409 83.758186 97.365601 0.014817 2023-11-27 00:00:00-05:00 -0.008312 0.002439 -0.015506 0.003816 0.010554 -0.002631 -0.001112 0.025825 -0.010468 0.063523 ... 97.365601 -0.000173 0.011860 93.917473 97.365601 0.006299 0.017881 83.826363 97.365601 0.014330 [5 rows x 26 columns] Пример кода Python генерирует 30 признаков из базовых OHLC данных. Лаги returns охватывают 10 дней — достаточно для захвата краткосрочной памяти. Rolling статистики вычисляются на окнах 5, 10, 20 дней, соответствующих торговой неделе, двум неделям и месяцу. Признак Price_position измеряет текущую цену относительно минимума и максимума на окне: Значение 0 означает цену на минимуме; 1 — на максимуме; 0.5 — в середине диапазона. Этот признак помогает модели определять перекупленность и перепроданность. Признак High-Low spread отражает внутридневную волатильность — расстояние между максимумом и минимумом дня. Скользящее среднее spread сглаживает ежедневные всплески и показывает тренд изменения волатильности. Рост spread предшествует периодам высокой неопределенности. Календарные и циклические признаки Временные ряды содержат календарные паттерны: День недели влияет на объем торгов; Час дня определяет ликвидность; Месяц года коррелирует с налоговыми периодами и ребалансировкой портфелей. Прямое кодирование времени как целого числа (1, 2, 3, 4, 5 для дней недели) разрывает цикличность — модель не понимает, что понедельник следует за пятницей. Для сохранения цикличности применяют Синус-косинус трансформацию. День недели преобразуется в две переменные: sin(2π × day / 7) cos(2π × day / 7) При такой трансформации понедельник и пятница оказываются близки в признаковом пространстве. Аналогично для часа дня, месяца года, недели года. Бинарные признаки отмечают специфичные события: is_month_start, is_month_end, is_quarter_end. Конец квартала сопровождается феноменом window dressing — фондовые менеджеры корректируют портфели для отчетности. Начало месяца характеризуется притоком капитала от институциональных инвесторов. import pandas as pd import numpy as np import yfinance as yf # Загрузка внутридневных данных ticker = yf.Ticker("ASML") data = ticker.history(period="60d", interval="1h") if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) data = data[['Close', 'Volume']].dropna() data['returns'] = data['Close'].pct_change() # Извлечение временных компонент data['hour'] = data.index.hour data['day_of_week'] = data.index.dayofweek data['day_of_month'] = data.index.day data['month'] = data.index.month data['week_of_year'] = data.index.isocalendar().week # Синус-косинус кодирование для цикличности data['hour_sin'] = np.sin(2 * np.pi * data['hour'] / 24) data['hour_cos'] = np.cos(2 * np.pi * data['hour'] / 24) data['day_of_week_sin'] = np.sin(2 * np.pi * data['day_of_week'] / 7) data['day_of_week_cos'] = np.cos(2 * np.pi * data['day_of_week'] / 7) data['month_sin'] = np.sin(2 * np.pi * data['month'] / 12) data['month_cos'] = np.cos(2 * np.pi * data['month'] / 12) # Бинарные признаки для специфичных периодов data['is_month_start'] = (data.index.day <= 3).astype(int) data['is_month_end'] = (data.index.day >= 28).astype(int) data['is_quarter_end'] = data.index.month.isin([3, 6, 9, 12]).astype(int) # Признаки для торговых сессий data['is_market_open'] = ((data['hour'] >= 9) & (data['hour'] < 16)).astype(int) data['is_first_hour'] = (data['hour'] == 9).astype(int) data['is_last_hour'] = (data['hour'] == 15).astype(int) data = data.dropna() print("Календарные признаки:") calendar_features = [col for col in data.columns if 'sin' in col or 'cos' in col or 'is_' in col] print(data[calendar_features].head(10)) print(f"\nКорреляция hour_sin и hour_cos с доходностью:") print(data[['returns', 'hour_sin', 'hour_cos']].corr()['returns'][1:]) Календарные признаки: hour_sin hour_cos day_of_week_sin day_of_week_cos month_sin month_cos is_month_start is_month_end is_quarter_end is_market_open is_first_hour is_last_hour Datetime 2025-07-28 10:30:00-04:00 5.000000e-01 -0.866025 0.000000 1.00000 -0.5 -0.866025 0 1 0 1 0 0 2025-07-28 11:30:00-04:00 2.588190e-01 -0.965926 0.000000 1.00000 -0.5 -0.866025 0 1 0 1 0 0 2025-07-28 12:30:00-04:00 1.224647e-16 -1.000000 0.000000 1.00000 -0.5 -0.866025 0 1 0 1 0 0 2025-07-28 13:30:00-04:00 -2.588190e-01 -0.965926 0.000000 1.00000 -0.5 -0.866025 0 1 0 1 0 0 2025-07-28 14:30:00-04:00 -5.000000e-01 -0.866025 0.000000 1.00000 -0.5 -0.866025 0 1 0 1 0 0 2025-07-28 15:30:00-04:00 -7.071068e-01 -0.707107 0.000000 1.00000 -0.5 -0.866025 0 1 0 1 0 1 2025-07-29 09:30:00-04:00 7.071068e-01 -0.707107 0.781831 0.62349 -0.5 -0.866025 0 1 0 1 1 0 2025-07-29 10:30:00-04:00 5.000000e-01 -0.866025 0.781831 0.62349 -0.5 -0.866025 0 1 0 1 0 0 2025-07-29 11:30:00-04:00 2.588190e-01 -0.965926 0.781831 0.62349 -0.5 -0.866025 0 1 0 1 0 0 2025-07-29 12:30:00-04:00 1.224647e-16 -1.000000 0.781831 0.62349 -0.5 -0.866025 0 1 0 1 0 0 Корреляция hour_sin и hour_cos с доходностью: hour_sin 0.154682 hour_cos 0.171963 Name: returns, dtype: float64 Код создает признаки для внутридневных данных ASML. Синус-косинус трансформация применяется к часу дня, дню недели и месяцу. Пара (sin, cos) полностью описывает положение на цикле без разрывов. Бинарные флаги is_market_open, is_first_hour, is_last_hour выделяют торговые сессии. Первый час характеризуется повышенной волатильностью из-за обработки ночных новостей. Последний час демонстрирует рост объемов — закрытие позиций перед выходными или праздниками. Корреляционный анализ показывает связь календарных признаков с доходностью. Тут важно отметить, что слабая корреляция (|r| < 0.05) не означает бесполезность признака — многие нелинейные модели извлекают паттерны, невидимые для линейной корреляции. Так, к примеру, градиентные бустинги обычно находят взаимодействия между hour_sin и volume_lag_1, улучшая предсказание внутридневных движений. Технические индикаторы как признаки Технические индикаторы агрегируют ценовую информацию в компактные метрики. Я обычно не использую популярные типа MACD, RSI, потому что не вижу в них практической ценности. Вместо них я использую другие производные цены: Моментум (Momentum). Этот индикатор измеряет скорость изменения цены; Реализованная волатильность и ATR - количественно описывают риски; Относительные изменения цен нормализуют данные для сравнения разных инструментов. Momentum вычисляется как разница текущей цены и цены N периодов назад, деленная на цену N периодов назад: (Close_t - Close_{t-N}) / Close_{t-N}. Положительный моментум указывает на восходящий тренд, отрицательный — на нисходящий. Окна 5, 10, 20 дней захватывают индикатор на разных масштабах. Реализованная волатильность оценивается через стандартное отклонение доходностей на скользящем окне. Рост волатильности предшествует крупным движениям цены в любую сторону. Отношение текущей волатильности к исторической (volatility_ratio) нормализует метрику и упрощает сравнение периодов. import pandas as pd import numpy as np import yfinance as yf # Загрузка данных ticker = yf.Ticker("AMD") data = ticker.history(period="3y", interval="1d") if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) data = data[['Close', 'Volume', 'High', 'Low']].dropna() data['returns'] = data['Close'].pct_change() # Momentum на разных окнах momentum_windows = [5, 10, 20, 50] for window in momentum_windows: data[f'momentum_{window}'] = (data['Close'] - data['Close'].shift(window)) / data['Close'].shift(window) # Реализованная волатильность volatility_windows = [5, 10, 20] for window in volatility_windows: data[f'volatility_{window}'] = data['returns'].rolling(window).std() # Отношение текущей волатильности к исторической data['volatility_ratio'] = data['volatility_5'] / (data['volatility_20'] + 1e-8) # Относительное изменение объема data['volume_change'] = data['Volume'].pct_change() for window in [5, 10]: data[f'volume_momentum_{window}'] = (data['Volume'] - data['Volume'].shift(window)) / data['Volume'].shift(window) # Average True Range (волатильность с учетом гэпов) data['true_range'] = np.maximum( data['High'] - data['Low'], np.maximum( np.abs(data['High'] - data['Close'].shift(1)), np.abs(data['Low'] - data['Close'].shift(1)) ) ) data['atr_14'] = data['true_range'].rolling(14).mean() # Нормализованный ATR data['atr_normalized'] = data['atr_14'] / data['Close'] # Относительная сила (upside vs downside momentum) up_returns = data['returns'].clip(lower=0) down_returns = -data['returns'].clip(upper=0) for window in [10, 20]: up_mean = up_returns.rolling(window).mean() down_mean = down_returns.rolling(window).mean() data[f'relative_strength_{window}'] = up_mean / (down_mean + 1e-8) data = data.dropna() print("Технические индикаторы:") technical_features = [col for col in data.columns if 'momentum' in col or 'volatility' in col or 'atr' in col or 'relative_strength' in col] print(data[technical_features].tail(10)) # Анализ волатильности в разные периоды high_vol_periods = data[data['volatility_ratio'] > 1.5] low_vol_periods = data[data['volatility_ratio'] < 0.7] print(f"\nПериоды высокой волатильности (>1.5x): {len(high_vol_periods)}") print(f"Периоды низкой волатильности (<0.7x): {len(low_vol_periods)}") Технические индикаторы: momentum_5 momentum_10 momentum_20 momentum_50 volatility_5 volatility_10 volatility_20 volatility_ratio volume_momentum_5 volume_momentum_10 atr_14 atr_normalized relative_strength_10 relative_strength_20 Date 2025-10-07 00:00:00-04:00 0.307312 0.314543 0.357399 0.217955 0.103273 0.075468 0.054730 1.886940 2.901275 1.934172 10.328570 0.048833 8.284096 4.741110 2025-10-08 00:00:00-04:00 0.436254 0.464197 0.476495 0.327547 0.102027 0.078928 0.058883 1.732708 3.010094 3.159294 11.461427 0.048656 11.080096 5.750359 2025-10-09 00:00:00-04:00 0.372120 0.444100 0.496049 0.297365 0.108867 0.079797 0.058428 1.863283 0.699691 1.561558 11.934999 0.051247 8.635701 6.726995 2025-10-10 00:00:00-04:00 0.305034 0.347673 0.355237 0.218876 0.121077 0.086919 0.062492 1.937468 1.778902 2.913876 13.058571 0.060766 3.819915 3.216558 2025-10-13 00:00:00-04:00 0.062393 0.341225 0.342889 0.260454 0.069905 0.087065 0.062532 1.117919 -0.746427 0.584381 13.423572 0.062026 3.779017 3.156165 2025-10-14 00:00:00-04:00 0.031110 0.347982 0.359155 0.233680 0.068585 0.086885 0.062378 1.099509 -0.384730 1.400336 13.595714 0.062340 3.821685 3.299968 2025-10-15 00:00:00-04:00 0.012905 0.454789 0.499120 0.368826 0.061171 0.088559 0.064353 0.950542 -0.321924 1.719149 14.617143 0.061262 4.500120 4.157236 2025-10-16 00:00:00-04:00 0.007171 0.381960 0.485309 0.437960 0.061572 0.090480 0.064610 0.952989 -0.260517 0.256893 14.895714 0.063505 3.679340 3.903939 2025-10-17 00:00:00-04:00 0.084598 0.415437 0.480907 0.351972 0.044202 0.088864 0.064674 0.683457 -0.529700 0.306917 15.083571 0.064714 4.452642 3.828568 2025-10-20 00:00:00-04:00 0.111542 0.180894 0.505476 0.392452 0.044196 0.055298 0.064696 0.683141 -0.102783 -0.772490 15.572857 0.064736 2.619499 3.938667 Периоды высокой волатильности (>1.5x): 56 Периоды низкой волатильности (<0.7x): 214 Код генерирует индикаторы momentum, volatility и relative strength для акций AMD. Momentum вычисляется на окнах от 5 до 50 дней — краткосрочные сигналы и долгосрочные тренды. Отрицательный momentum_5 при положительном momentum_20 указывает на коррекцию внутри восходящего тренда. Average True Range учитывает гэпы между днями — расстояние между закрытием вчера и открытием сегодня. True range определяется как максимум из трех величин: внутридневной диапазон (High - Low); гэп вверх (High - Close_prev); гэп вниз (Low - Close_prev). ATR усредняет true range на 14 днях и нормализуется делением на цену. Relative strength сравнивает среднюю величину роста и падения на окне. Значение выше 1 означает преобладание восходящих движений, ниже 1 — нисходящих. В отличие от классического RSI, этот индикатор не ограничен диапазоном 0-100 и не требует пороговых значений для интерпретации — модель самостоятельно находит значимые уровни. Регуляризация для временных рядов Переобучение на временных рядах проявляется иначе, чем на табличных данных. Модель запоминает случайные флуктуации конкретного периода и проваливается на новых данных. Регуляризация ограничивает сложность модели и повышает робастность к изменениям режима рынка. L1 и L2 регуляризация L2-регуляризация (Ridge) добавляет к функции потерь штраф за величину весов. Рассчитывается по формуле: Loss = MSE + α × Σw². Параметр α контролирует силу регуляризации — большие значения сильнее сжимают веса. Для временных рядов α подбирается через кросс-валидацию в диапазоне [0.01, 100]. L1-регуляризация (Lasso) использует штраф: α × Σ|w| Данный штраф приводит к разреженности — часть весов обнуляется. Lasso выполняет автоматический отбор признаков: из 100 лаговых переменных остаются 10-15 значимых. Для финансовых данных с высокой коррелированностью лагов Lasso выбирает подмножество без потери информации. Elastic Net комбинирует L1 и L2: Loss = MSE + α₁ × Σ|w| + α₂ × Σw². Параметр l1_ratio определяет баланс между двумя типами регуляризации. Значение 0.5 дает равный вес L1 и L2, подходит для данных с группами коррелированных признаков. import numpy as np import pandas as pd from sklearn.linear_model import Ridge, Lasso, ElasticNet from sklearn.preprocessing import StandardScaler from sklearn.model_selection import TimeSeriesSplit from sklearn.metrics import mean_squared_error import yfinance as yf # Загрузка данных ticker = yf.Ticker("INTC") data = ticker.history(period="3y", interval="1d") if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) data = data[['Close', 'Volume']].dropna() data['returns'] = data['Close'].pct_change() # Feature engineering for lag in range(1, 21): data[f'returns_lag_{lag}'] = data['returns'].shift(lag) for window in [5, 10, 20]: data[f'returns_rolling_mean_{window}'] = data['returns'].rolling(window).mean() data[f'returns_rolling_std_{window}'] = data['returns'].rolling(window).std() data['target'] = data['returns'].shift(-1) data = data.dropna() # Признаки и таргет feature_cols = [col for col in data.columns if 'lag' in col or 'rolling' in col] X = data[feature_cols] y = data['target'] # Масштабирование scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # Time series cross-validation для подбора alpha tscv = TimeSeriesSplit(n_splits=5) # Подбор alpha для Ridge alphas_ridge = [0.01, 0.1, 1.0, 10.0, 100.0] ridge_scores = [] for alpha in alphas_ridge: scores = [] for train_idx, val_idx in tscv.split(X_scaled): X_train, X_val = X_scaled[train_idx], X_scaled[val_idx] y_train, y_val = y.iloc[train_idx], y.iloc[val_idx] model = Ridge(alpha=alpha) model.fit(X_train, y_train) y_pred = model.predict(X_val) scores.append(mean_squared_error(y_val, y_pred)) ridge_scores.append(np.mean(scores)) best_alpha_ridge = alphas_ridge[np.argmin(ridge_scores)] print(f"Лучший alpha для Ridge: {best_alpha_ridge}, RMSE: {np.sqrt(min(ridge_scores)):.6f}") # Подбор alpha для Lasso alphas_lasso = [0.00001, 0.0001, 0.001, 0.01, 0.1] lasso_scores = [] for alpha in alphas_lasso: scores = [] for train_idx, val_idx in tscv.split(X_scaled): X_train, X_val = X_scaled[train_idx], X_scaled[val_idx] y_train, y_val = y.iloc[train_idx], y.iloc[val_idx] model = Lasso(alpha=alpha, max_iter=10000) model.fit(X_train, y_train) y_pred = model.predict(X_val) scores.append(mean_squared_error(y_val, y_pred)) lasso_scores.append(np.mean(scores)) best_alpha_lasso = alphas_lasso[np.argmin(lasso_scores)] print(f"Лучший alpha для Lasso: {best_alpha_lasso}, RMSE: {np.sqrt(min(lasso_scores)):.6f}") # Обучение финальной Lasso модели lasso_final = Lasso(alpha=best_alpha_lasso, max_iter=10000) lasso_final.fit(X_scaled, y) # Анализ отобранных признаков selected_features = np.abs(lasso_final.coef_) > 0.0001 print(f"\nLasso отобрал {selected_features.sum()} из {len(feature_cols)} признаков") print("\nТоп-10 признаков по важности:") feature_importance = pd.DataFrame({ 'feature': np.array(feature_cols)[selected_features], 'coef': lasso_final.coef_[selected_features] }).sort_values('coef', key=abs, ascending=False) print(feature_importance.head(10)) Лучший alpha для Ridge: 100.0, RMSE: 0.035134 Лучший alpha для Lasso: 0.01, RMSE: 0.034327 Lasso отобрал 3 из 26 признаков Топ-10 признаков по важности: Empty DataFrame Columns: [feature, coef] Index: [returns_rolling_mean_5, returns_lag_3, returns_lag_7] Код демонстрирует подбор коэффициента регуляризации через time series cross-validation на данных Intel. TimeSeriesSplit создает 5 фолдов с сохранением временного порядка — каждый следующий фолд использует больше обучающих данных, предотвращая возможную утечку данных в будущее (look-ahead bias). Ridge тестируется на диапазоне alpha от 0.01 до 100: Малые значения (0.01-0.1) применяют слабую регуляризацию, подходят для данных с низким шумом; Большие значения (10-100) агрессивно сжимают веса, защищают от переобучения на зашумленных финансовых рядах. Lasso требует меньших alpha (0.00001-0.1) из-за более сильного эффекта L1-штрафа. Анализ отобранных признаков показывает, какие лаги и rolling статистики значимы для прогноза. Lasso обнуляет коррелированные переменные — если returns_lag_1 и returns_lag_2 сильно коррелированы, модель оставляет один из них. Топ-10 признаков по абсолютной величине коэффициентов указывают на ключевые временные зависимости в данных. Dropout и Early stopping в градиентном бустинге Модели градиентного бустинга склонны к переобучению через построение слишком глубоких деревьев на одних и тех же данных. Стохастические методы добавляют случайность в процесс обучения, снижая корреляцию между деревьями и повышая диверсификацию ансамбля. За стохастику в бустинговых ML-моделях отвечают следующие гиперпараметры: Subsample контролирует долю строк для обучения каждого дерева. Значение 0.8 означает, что каждое дерево видит 80% случайно выбранных наблюдений. Для временных рядов это вносит вариативность без нарушения временного порядка — сэмплирование происходит внутри каждого батча. Colsample_bytree определяет долю признаков, доступных для построения дерева. Значение 0.6 ограничивает модель 60% случайно выбранных переменных на каждой итерации. Это предотвращает доминирование нескольких сильных признаков и заставляет ансамбль использовать разнообразные паттерны. Early stopping прекращает обучение при отсутствии улучшения на валидационной выборке. Параметр early_stopping_rounds=50 останавливает процесс, если validation loss не снижается 50 итераций подряд. Для временных рядов валидация выполняется на последних 20% данных, сохраняя хронологический порядок. import numpy as np import pandas as pd from lightgbm import LGBMRegressor, early_stopping from sklearn.metrics import mean_squared_error import matplotlib.pyplot as plt # Генерация синтетического ряда np.random.seed(24) n = 1000 time = np.arange(n) # Линейный тренд + синусоидальная сезонность + шум trend = time * 0.01 seasonality = 0.5 * np.sin(2 * np.pi * time / 50) noise = np.random.normal(0, 0.3, n) series = trend + seasonality + noise data = pd.DataFrame({'value': series}) # Feature engineering # Лаги for lag in range(1, 21): data[f'value_lag_{lag}'] = data['value'].shift(lag) # Скользящие характеристики for window in [5, 10, 20]: data[f'value_rolling_mean_{window}'] = data['value'].rolling(window).mean() data[f'value_rolling_std_{window}'] = data['value'].rolling(window).std() # Целевая переменная: через 1 шаг data['target'] = data['value'].shift(-1) data = data.dropna() feature_cols = [col for col in data.columns if 'lag' in col or 'rolling' in col] X = data[feature_cols] y = data['target'] # Train/validation/test split train_size = int(len(data) * 0.7) val_size = int(len(data) * 0.15) X_train = X[:train_size] y_train = y[:train_size] X_val = X[train_size:train_size + val_size] y_val = y[train_size:train_size + val_size] X_test = X[train_size + val_size:] y_test = y[train_size + val_size:] # Конфиги LightGBM с разной регуляризацией configs = [ { 'name': 'Weak regularization', 'subsample': 1.0, 'colsample_bytree': 1.0, 'max_depth': 12, 'reg_alpha': 0, 'reg_lambda': 0, 'learning_rate': 0.01, 'n_estimators': 1000 }, { 'name': 'Medium regularization', 'subsample': 0.6, 'colsample_bytree': 0.6, 'max_depth': 6, 'reg_alpha': 1, 'reg_lambda': 2, 'learning_rate': 0.01, 'n_estimators': 1500 }, { 'name': 'Strong regularization', 'subsample': 0.4, 'colsample_bytree': 0.4, 'max_depth': 2, 'reg_alpha': 2, 'reg_lambda': 5, 'learning_rate': 0.01, 'n_estimators': 2000 } ] results = [] # Обучение моделей for config in configs: model = LGBMRegressor( n_estimators=config['n_estimators'], learning_rate=config['learning_rate'], max_depth=config['max_depth'], subsample=config['subsample'], colsample_bytree=config['colsample_bytree'], reg_alpha=config['reg_alpha'], reg_lambda=config['reg_lambda'], random_state=24, verbose=-1 ) model.fit( X_train, y_train, eval_set=[(X_val, y_val)], eval_metric='rmse', callbacks=[early_stopping(stopping_rounds=100, verbose=False)] ) # Предсказания y_train_pred = model.predict(X_train) y_val_pred = model.predict(X_val) y_test_pred = model.predict(X_test) # Метрики train_rmse = np.sqrt(mean_squared_error(y_train, y_train_pred)) val_rmse = np.sqrt(mean_squared_error(y_val, y_val_pred)) test_rmse = np.sqrt(mean_squared_error(y_test, y_test_pred)) results.append({ 'Config': config['name'], 'Trees': model.best_iteration_, 'Train RMSE': train_rmse, 'Val RMSE': val_rmse, 'Test RMSE': test_rmse, 'Overfit': train_rmse / test_rmse }) # Вывод результатов results_df = pd.DataFrame(results) print(results_df.to_string(index=False)) # Визуализация fig, ax = plt.subplots(figsize=(9, 5)) x_pos = np.arange(len(results_df)) width = 0.25 ax.bar(x_pos - width, results_df['Train RMSE'], width, label='Train', color='#2C3E50') ax.bar(x_pos, results_df['Val RMSE'], width, label='Validation', color='#7F8C8D') ax.bar(x_pos + width, results_df['Test RMSE'], width, label='Test', color='#95A5A6') ax.set_xlabel('Configuration') ax.set_ylabel('RMSE') ax.set_title('Регуляризация Градиентного бустинга: Воздействие на переобучение') ax.set_xticks(x_pos) ax.set_xticklabels(results_df['Config'], rotation=15, ha='right') ax.legend() ax.grid(axis='y', alpha=0.3) plt.tight_layout() plt.show() Config Trees Train RMSE Val RMSE Test RMSE Overfit Weak regularization 553 0.160315 1.090539 2.526468 0.063454 Medium regularization 1030 0.202766 1.103364 2.544447 0.079690 Strong regularization 738 0.310814 1.045300 2.453614 0.126676 Рис. 1: Влияние регуляризации на переобучение gradient boosting. Три группы столбцов показывают RMSE на train, validation и test выборках для разных конфигураций. Weak regularization демонстрирует низкий train RMSE и высокий test RMSE — классический признак переобучения. Strong regularization выравнивает метрики между выборками, подтверждая лучшую генерализацию Регуляризация с учетом природы временных рядов (Time-series specific regularization) Временные ряды требуют дополнительных ограничений, учитывающих последовательную природу данных финансовых временных рядов: Min_data_in_leaf контролирует минимальное количество наблюдений в листе дерева — защита от создания правил на основе единичных выбросов. Для дневных данных min_data_in_leaf=20 гарантирует, что каждое правило основано минимум на месяце наблюдений. Для внутридневных баров по 5 минут значение увеличивается до 50-100 — один торговый день должен содержать достаточно точек для статистически значимого паттерна. Max_depth ограничивает глубину дерева и сложность взаимодействий признаков. Глубокие деревья (depth > 5) захватывают сложные нелинейности, но легко переобучаются на шуме. Для baseline моделей max_depth=3 оптимален — позволяет находить взаимодействия второго порядка без избыточной сложности. Lambda_l1 и Lambda_l2 применяют L1 и L2 регуляризацию к весам листьев в LightGBM. Lambda_l2=1.0 сжимает веса крайних листьев, снижая влияние редких паттернов. Lambda_l1=0.5 дополнительно обнуляет незначимые листья, упрощая модель. import numpy as np import pandas as pd from lightgbm import LGBMRegressor from sklearn.metrics import mean_squared_error import yfinance as yf # Загрузка данных ticker = yf.Ticker("BABA") #Alibaba Corp. data = ticker.history(period="3y", interval="1d") if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) data = data[['Close', 'Volume']].dropna() data['returns'] = data['Close'].pct_change() # Feature engineering for lag in range(1, 11): data[f'returns_lag_{lag}'] = data['returns'].shift(lag) for window in [5, 10, 20]: data[f'returns_rolling_mean_{window}'] = data['returns'].rolling(window).mean() data[f'returns_rolling_std_{window}'] = data['returns'].rolling(window).std() # Целевая переменная — доходность через 5 дней data['target'] = data['returns'].shift(-5) data = data.dropna() feature_cols = [col for col in data.columns if 'lag' in col or 'rolling' in col] X = data[feature_cols] y = data['target'] # Split train_size = int(len(data) * 0.8) X_train, X_test = X[:train_size], X[train_size:] y_train, y_test = y[:train_size], y[train_size:] # Конфигурации ts_configs = [ {'name': 'Default', 'min_data_in_leaf': 20, 'max_depth': 3, 'lambda_l1': 0.0, 'lambda_l2': 0.0}, {'name': 'Aggressive min_data', 'min_data_in_leaf': 50, 'max_depth': 3, 'lambda_l1': 0.0, 'lambda_l2': 0.0}, {'name': 'Shallow trees', 'min_data_in_leaf': 20, 'max_depth': 2, 'lambda_l1': 0.0, 'lambda_l2': 0.0}, {'name': 'L1+L2 on leaves', 'min_data_in_leaf': 20, 'max_depth': 3, 'lambda_l1': 0.5, 'lambda_l2': 1.0}, {'name': 'Combined', 'min_data_in_leaf': 40, 'max_depth': 2, 'lambda_l1': 0.5, 'lambda_l2': 1.0} ] results = [] for config in ts_configs: model = LGBMRegressor( n_estimators=200, learning_rate=0.05, max_depth=config['max_depth'], min_data_in_leaf=config['min_data_in_leaf'], lambda_l1=config['lambda_l1'], lambda_l2=config['lambda_l2'], subsample=0.8, colsample_bytree=0.8, random_state=42, verbose=-1 ) model.fit(X_train, y_train) y_train_pred = model.predict(X_train) y_test_pred = model.predict(X_test) train_rmse = np.sqrt(mean_squared_error(y_train, y_train_pred)) test_rmse = np.sqrt(mean_squared_error(y_test, y_test_pred)) # Извлекаем структуру деревьев tree_df = model.booster_.trees_to_dataframe() leaves_per_tree = ( tree_df[tree_df['split_feature'].isna()] .groupby('tree_index') .size() ) avg_leaves = leaves_per_tree.mean() results.append({ 'Configuration': config['name'], 'Min data in leaf': config['min_data_in_leaf'], 'Max depth': config['max_depth'], 'Train RMSE': train_rmse, 'Test RMSE': test_rmse, 'Overfit ratio': train_rmse / test_rmse, 'Avg leaves': avg_leaves }) results_df = pd.DataFrame(results) print(results_df.to_string(index=False)) print("\nВыводы:") best_config = results_df.loc[results_df['Test RMSE'].idxmin()] print(f"Лучшая конфигурация: {best_config['Configuration']}") print(f"Test RMSE: {best_config['Test RMSE']:.6f}") print(f"Overfit ratio: {best_config['Overfit ratio']:.4f}") Configuration Min data in leaf Max depth Train RMSE Test RMSE Overfit ratio Avg leaves Default 20 3 0.019195 0.032785 0.585483 5.855000 Aggressive min_data 50 3 0.020990 0.032048 0.654956 5.330000 Shallow trees 20 2 0.022346 0.032714 0.683073 3.695000 L1+L2 on leaves 20 3 0.026157 0.031126 0.840351 6.269231 Combined 40 2 0.026305 0.031057 0.846974 3.977273 Выводы: Лучшая конфигурация: Combined Test RMSE: 0.031057 Overfit ratio: 0.8470 Код тестирует пять конфигураций регуляризации ML-модели прогноза временных рядов на данных акций Alibaba: Default конфигурация использует стандартные параметры LightGBM; Aggressive min_data увеличивает минимум наблюдений в листе до 50 — каждое правило основано на большей статистике; Shallow trees ограничивают глубину двумя уровнями — простые взаимодействия признаков; L1+L2 on leaves применяет регуляризацию к весам листьев без изменения структуры дерева; Combined объединяет все методы — агрессивный min_data_in_leaf, мелкие деревья и регуляризация весов; Метрика Avg leaves показывает среднее количество листьев на дерево — индикатор сложности модели. Результаты демонстрируют компромисс между train и test производительностью. Конфигурации с сильной регуляризацией показывают худший train RMSE, но лучший test RMSE и overfit ratio ближе к 1.0. Для продакшена выбираем конфигурацию с наименьшим test RMSE и стабильным overfit ratio, поскольку модель лучше генерализует на новые данные. Оценка качества на временных рядах Стандартная кросс-валидация нарушает временную структуру данных. Она не учитывает паттерны последовательностей. Так, к примеру, метод Random shuffle перемешивает наблюдения, создавая утечку информации из будущего в прошлое. В результате модель обучается на данных уже после валидационного периода и демонстрирует завышенное качество, что ведет к провалу метрик в продакшене. Time-series cross-validation Метод Walk-forward validation сохраняет хронологический порядок рядов для кросс-валидации: Модель обучается на данных до момента T; Валидируется на периоде [T, T+h]; Затем окно сдвигается вперед. Каждая итерация использует только прошлые данные для предсказания будущих — реалистичная симуляция продакшена. В Time-series cross-validation используются 2 разных подхода к разбиению данных: Expanding window наращивает обучающую выборку с каждой итерацией. Первый фолд обучается на данных [0, T₁], второй на [0, T₂], третий на [0, T₃]. Валидационное окно фиксированной длины h следует сразу за обучающими данными. Этот подход максимизирует использование данных, но замедляет обучение на больших выборках. Sliding window сохраняет постоянный размер обучающей выборки. Первый фолд использует [0, T], второй [h, T+h], третий [2h, T+2h]. Этот метод адаптируется к изменениям режима рынка — старые данные забываются, модель фокусируется на актуальных паттернах. Для высокочастотных стратегий sliding window предпочтительнее. import numpy as np import pandas as pd from sklearn.model_selection import TimeSeriesSplit from lightgbm import LGBMRegressor from sklearn.metrics import mean_squared_error, mean_absolute_error import yfinance as yf import matplotlib.pyplot as plt # Загрузка данных ticker = yf.Ticker("SAP") data = ticker.history(period="5y", interval="1d") if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) data = data[['Close', 'Volume']].dropna() data['returns'] = data['Close'].pct_change() # Feature engineering for lag in range(1, 11): data[f'returns_lag_{lag}'] = data['returns'].shift(lag) for window in [5, 10, 20]: data[f'returns_rolling_mean_{window}'] = data['returns'].rolling(window).mean() data[f'returns_rolling_std_{window}'] = data['returns'].rolling(window).std() data['target'] = data['returns'].shift(-1) data = data.dropna() feature_cols = [col for col in data.columns if 'lag' in col or 'rolling' in col] X = data[feature_cols] y = data['target'] # TimeSeriesSplit для expanding window tscv = TimeSeriesSplit(n_splits=5) expanding_results = [] for fold_idx, (train_idx, val_idx) in enumerate(tscv.split(X)): X_train, X_val = X.iloc[train_idx], X.iloc[val_idx] y_train, y_val = y.iloc[train_idx], y.iloc[val_idx] model = LGBMRegressor( n_estimators=100, learning_rate=0.05, max_depth=3, subsample=0.8, colsample_bytree=0.8, random_state=42, verbose=-1 ) model.fit(X_train, y_train) y_pred = model.predict(X_val) rmse = np.sqrt(mean_squared_error(y_val, y_pred)) mae = mean_absolute_error(y_val, y_pred) expanding_results.append({ 'Fold': fold_idx + 1, 'Train size': len(X_train), 'Val size': len(X_val), 'RMSE': rmse, 'MAE': mae }) expanding_df = pd.DataFrame(expanding_results) print("Expanding Window Validation:") print(expanding_df.to_string(index=False)) print(f"\nСредний RMSE: {expanding_df['RMSE'].mean():.6f}") print(f"Std RMSE: {expanding_df['RMSE'].std():.6f}") # Sliding window validation window_size = 500 # Фиксированный размер обучающей выборки val_size = 100 # Размер валидационного окна n_folds = 5 sliding_results = [] for fold_idx in range(n_folds): start_idx = fold_idx * val_size train_end_idx = start_idx + window_size val_end_idx = train_end_idx + val_size if val_end_idx > len(X): break X_train = X.iloc[start_idx:train_end_idx] y_train = y.iloc[start_idx:train_end_idx] X_val = X.iloc[train_end_idx:val_end_idx] y_val = y.iloc[train_end_idx:val_end_idx] model = LGBMRegressor( n_estimators=100, learning_rate=0.05, max_depth=3, subsample=0.8, colsample_bytree=0.8, random_state=42, verbose=-1 ) model.fit(X_train, y_train) y_pred = model.predict(X_val) rmse = np.sqrt(mean_squared_error(y_val, y_pred)) mae = mean_absolute_error(y_val, y_pred) sliding_results.append({ 'Fold': fold_idx + 1, 'Train size': len(X_train), 'Val size': len(X_val), 'RMSE': rmse, 'MAE': mae }) sliding_df = pd.DataFrame(sliding_results) print("\n\nSliding Window Validation:") print(sliding_df.to_string(index=False)) print(f"\nСредний RMSE: {sliding_df['RMSE'].mean():.6f}") print(f"Std RMSE: {sliding_df['RMSE'].std():.6f}") # Визуализация стабильности метрик fig, axes = plt.subplots(1, 2, figsize=(14, 5)) # Expanding window axes[0].plot(expanding_df['Fold'], expanding_df['RMSE'], marker='o', linewidth=2, markersize=8, color='#2C3E50', label='RMSE') axes[0].plot(expanding_df['Fold'], expanding_df['MAE'], marker='s', linewidth=2, markersize=8, color='#7F8C8D', label='MAE') axes[0].set_xlabel('Fold') axes[0].set_ylabel('Error') axes[0].set_title('Expanding Window: Metric Stability') axes[0].legend() axes[0].grid(alpha=0.3) # Sliding window axes[1].plot(sliding_df['Fold'], sliding_df['RMSE'], marker='o', linewidth=2, markersize=8, color='#2C3E50', label='RMSE') axes[1].plot(sliding_df['Fold'], sliding_df['MAE'], marker='s', linewidth=2, markersize=8, color='#7F8C8D', label='MAE') axes[1].set_xlabel('Fold') axes[1].set_ylabel('Error') axes[1].set_title('Sliding Window: Metric Stability') axes[1].legend() axes[1].grid(alpha=0.3) plt.tight_layout() plt.show() Рис. 2: Стабильность метрик при expanding и sliding window валидации. Левая панель показывает RMSE и MAE для expanding window — метрики стабилизируются с ростом обучающей выборки. Правая панель демонстрирует sliding window — колебания метрик отражают изменения режима рынка между периодами. Sliding window выявляет периоды, где модель проваливается, даже если средний RMSE приемлем Expanding Window Validation: Fold Train size Val size RMSE MAE 1 209 205 0.020395 0.015975 2 414 205 0.019985 0.014418 3 619 205 0.014190 0.010333 4 824 205 0.015718 0.011926 5 1029 205 0.018325 0.013105 Средний RMSE: 0.017723 Std RMSE: 0.002698 Sliding Window Validation: Fold Train size Val size RMSE MAE 1 500 100 0.015581 0.012205 2 500 100 0.014041 0.009830 3 500 100 0.014332 0.010582 4 500 100 0.016033 0.012301 5 500 100 0.016046 0.012163 Средний RMSE: 0.015207 Std RMSE: 0.000955 Код реализует оба подхода к time-series кросс-валидации на котировках акций SAP. Expanding window использует TimeSeriesSplit из sklearn — каждый фолд наращивает обучающую выборку. Train size увеличивается с каждой итерацией, val size остается постоянным. Sliding window реализован вручную с фиксированным window_size=500 дней. Каждый фолд сдвигает окно на val_size=100 дней вперед. Этот метод тестирует модель на последовательных периодах одинаковой длины — проверка стабильности на разных режимах рынка. Стандартное отклонение RMSE между фолдами показывает стабильность модели: Низкий std (< 10% от среднего RMSE) указывает на устойчивость к изменениям данных; Высокий std (> 30%) означает зависимость качества от конкретного периода — модель захватывает временные паттерны, не генерализующиеся на новые данные. Метрики качества В обучении бейзлайн моделей обычно используют 4 метрики качества: MAE, RMSE, MAPE, Directional Accuracy. MAE MAE (Mean Absolute Error) измеряет среднюю абсолютную ошибку прогноза. Для временных рядов доходностей MAE=0.01 означает среднее отклонение 1% от факта. Метрика линейна — ошибка в 2% штрафуется вдвое сильнее, чем ошибка в 1%. MAE робастна к выбросам, подходит для данных с редкими крупными движениями. RMSE RMSE (Root Mean Squared Error) возводит ошибки в квадрат перед усреднением. Крупные промахи получают непропорционально большой вес — ошибка в 2% штрафуется в 4 раза сильнее, чем ошибка в 1%. Для риск-менеджмента RMSE предпочтительнее — модель должна избегать катастрофических ошибок, даже если средняя точность ниже. MAPE MAPE (Mean Absolute Percentage Error) нормализует ошибку относительно фактического значения. Выражается в процентах. С этой метрикой легче сравнивать эффективность моделей на рядах с разными масштабами размаха значений. Для цен активов MAPE неприменим напрямую — деление на доходность, близкую к нулю, дает бесконечность. Вместо этого используется symmetric MAPE: 2 × |pred - actual| / (|pred| + |actual|), избегающий деления на ноль. Directional Accuracy Показатель Directional Accuracy измеряет долю правильно предсказанных направлений движения. Модель корректна, если sign(pred) = sign(actual). Для торговых стратегий Directional Accuracy >53% на дневных данных создает положительное математическое ожидание с учетом комиссий. Однако надо учитывать, что метрика не учитывает величину движения — предсказание роста на 0.1% при факте +5% считается успехом. import numpy as np import pandas as pd from lightgbm import LGBMRegressor from sklearn.metrics import mean_squared_error, mean_absolute_error import yfinance as yf # Функция для расчета метрик качества для временных рядов def calculate_metrics(y_true, y_pred): mae = mean_absolute_error(y_true, y_pred) rmse = np.sqrt(mean_squared_error(y_true, y_pred)) # Symmetric MAPE smape = np.mean(2 * np.abs(y_pred - y_true) / (np.abs(y_pred) + np.abs(y_true) + 1e-8)) * 100 # Directional accuracy correct_direction = np.sign(y_pred) == np.sign(y_true) directional_acc = correct_direction.mean() * 100 # Hit rate для значимых движений (>0.5%) significant_moves = np.abs(y_true) > 0.005 if significant_moves.sum() > 0: hit_rate_significant = correct_direction[significant_moves].mean() * 100 else: hit_rate_significant = np.nan return { 'MAE': mae, 'RMSE': rmse, 'SMAPE': smape, 'Directional Accuracy': directional_acc, 'Hit Rate (>0.5%)': hit_rate_significant } # Загрузка данных ticker = yf.Ticker("NVDA") #NVIDIA data = ticker.history(period="3y", interval="1d") if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) data = data[['Close', 'Volume']].dropna() data['returns'] = data['Close'].pct_change() # Feature engineering for lag in range(1, 11): data[f'returns_lag_{lag}'] = data['returns'].shift(lag) for window in [5, 10, 20]: data[f'returns_rolling_mean_{window}'] = data['returns'].rolling(window).mean() data[f'returns_rolling_std_{window}'] = data['returns'].rolling(window).std() # Целевая переменная: доходность на следующий день data['target'] = data['returns'].shift(-1) data = data.dropna() feature_cols = [col for col in data.columns if 'lag' in col or 'rolling' in col] X = data[feature_cols] y = data['target'] # Train/test split train_size = int(len(data) * 0.8) X_train, X_test = X[:train_size], X[train_size:] y_train, y_test = y[:train_size], y[train_size:] # Обучение модели model = LGBMRegressor( n_estimators=250, learning_rate=0.05, max_depth=5, subsample=0.8, colsample_bytree=0.8, random_state=24, verbose=-1 ) model.fit(X_train, y_train) # Предсказания y_train_pred = model.predict(X_train) y_test_pred = model.predict(X_test) # Расчет метрик train_metrics = calculate_metrics(y_train, y_train_pred) test_metrics = calculate_metrics(y_test, y_test_pred) print("Метрики на обучающей выборке:") for metric, value in train_metrics.items(): print(f" {metric:25}: {value:.4f}") print("\nМетрики на тестовой выборке:") for metric, value in test_metrics.items(): print(f" {metric:25}: {value:.4f}") # Анализ ошибок по квантилям test_errors = np.abs(y_test - y_test_pred) quantiles = [0.5, 0.75, 0.9, 0.95, 0.99] print("\nРаспределение ошибок (квантили):") for q in quantiles: print(f" {int(q*100)}%: {np.quantile(test_errors, q):.6f}") # Производительность на разных горизонтах доходности print("\nDirectional accuracy по величине движения:") for threshold in [0.001, 0.005, 0.01, 0.02, 0.03]: mask = np.abs(y_test) > threshold if mask.sum() > 0: acc = (np.sign(y_test_pred[mask]) == np.sign(y_test[mask])).mean() * 100 print(f" >{threshold*100:.1f}%: {acc:.2f}% (n={mask.sum()})") Метрики на обучающей выборке: MAE : 0.0096 RMSE : 0.0134 SMAPE : 69.3480 Directional Accuracy : 89.0411 Hit Rate (>0.5%) : 94.4223 Метрики на тестовой выборке: MAE : 0.0201 RMSE : 0.0294 SMAPE : 136.8930 Directional Accuracy : 58.9041 Hit Rate (>0.5%) : 58.9286 Распределение ошибок (квантили): 50%: 0.014875 75%: 0.025482 90%: 0.040927 95%: 0.047746 99%: 0.087581 Directional accuracy по величине движения: >0.1%: 58.87% (n=141) >0.5%: 58.93% (n=112) >1.0%: 64.20% (n=81) >2.0%: 63.46% (n=52) >3.0%: 65.52% (n=29) Код вычисляет пять метрик на котировках акций Nvidia. MAE и RMSE показывают абсолютную точность прогноза в единицах доходности. SMAPE нормализует ошибку, позволяя сравнивать модели на разных инструментах. Directional accuracy оценивает способность предсказывать направление — ключевая метрика для торговых систем. Hit rate для значимых движений (>0.5%) фильтрует дни с минимальной волатильностью. Предсказание направления при движении в 0.1% не несет практической ценности — транзакционные издержки съедают прибыль. Фокус на движениях >0.5% показывает реальную применимость модели для трейдинга. Анализ квантилей ошибок выявляет вероятность хвостовых рисков распределений (tail risk): 95% квантиль показывает максимальную ошибку для 95% предсказаний; Если 99% квантиль значительно выше 95%, модель периодически дает катастрофические промахи; Для автоматизированных стратегий важна стабильность — лучше модель с MAE=0.012 и узким распределением ошибок, чем с MAE=0.010 и редкими выбросами в 5%. Directional accuracy по величине движения демонстрирует участки, где модель работает лучше. Как правило, точность растет на крупных движениях (>2%) — сильные сигналы легче предсказывать. Падение точности на малых движениях (<0.5%) ожидаемо — шум доминирует над сигналом. Sharpe ratio адаптируется как метрика качества модели через конструирование синтетической стратегии. Позиция определяется предсказанием: long при pred > 0, short при pred < 0. Доходность стратегии вычисляется как pred × actual — корректное предсказание направления дает положительный вклад. Sharpe ratio этой синтетической equity curve оценивает качество модели с поправками на риск. import numpy as np import pandas as pd from lightgbm import LGBMRegressor import yfinance as yf import matplotlib.pyplot as plt # Загрузка данных ticker = yf.Ticker("BA") #Boeing data = ticker.history(period="3y", interval="1d") if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) data = data[['Close']].dropna() data['returns'] = data['Close'].pct_change() # Feature engineering for lag in range(1, 11): data[f'returns_lag_{lag}'] = data['returns'].shift(lag) for window in [5, 10, 20]: data[f'returns_rolling_mean_{window}'] = data['returns'].rolling(window).mean() data[f'returns_rolling_std_{window}'] = data['returns'].rolling(window).std() data['target'] = data['returns'].shift(-1) data = data.dropna() feature_cols = [col for col in data.columns if 'lag' in col or 'rolling' in col] X = data[feature_cols] y = data['target'] # Train/test split train_size = int(len(data) * 0.8) X_train, X_test = X[:train_size], X[train_size:] y_train, y_test = y[:train_size], y[train_size:] # Обучение модели model = LGBMRegressor( n_estimators=100, learning_rate=0.05, max_depth=3, subsample=0.8, colsample_bytree=0.8, random_state=42, verbose=-1 ) model.fit(X_train, y_train) y_test_pred = model.predict(X_test) # Конструирование синтетической стратегии strategy_returns = np.sign(y_test_pred) * y_test cumulative_returns = (1 + strategy_returns).cumprod() # Buy & Hold benchmark buy_hold_returns = y_test buy_hold_cumulative = (1 + buy_hold_returns).cumprod() # Расчет Sharpe ratio (252 торговых дня) strategy_sharpe = strategy_returns.mean() / strategy_returns.std() * np.sqrt(252) buy_hold_sharpe = buy_hold_returns.mean() / buy_hold_returns.std() * np.sqrt(252) # Максимальная просадка def max_drawdown(returns): cumulative = (1 + returns).cumprod() running_max = cumulative.expanding().max() drawdown = (cumulative - running_max) / running_max return drawdown.min() strategy_mdd = max_drawdown(strategy_returns) buy_hold_mdd = max_drawdown(buy_hold_returns) print("Метрики стратегии на основе модели:") print(f" Sharpe Ratio: {strategy_sharpe:.4f}") print(f" Max Drawdown: {strategy_mdd:.4%}") print(f" Total Return: {(cumulative_returns.iloc[-1] - 1):.4%}") print(f" Win Rate: {(strategy_returns > 0).mean():.4%}") print("\nBuy & Hold benchmark:") print(f" Sharpe Ratio: {buy_hold_sharpe:.4f}") print(f" Max Drawdown: {buy_hold_mdd:.4%}") print(f" Total Return: {(buy_hold_cumulative.iloc[-1] - 1):.4%}") # Визуализация equity curve fig, axes = plt.subplots(2, 1, figsize=(12, 8)) # Cumulative returns axes[0].plot(cumulative_returns.index, cumulative_returns.values, linewidth=2, color='#2C3E50', label='Model Strategy') axes[0].plot(buy_hold_cumulative.index, buy_hold_cumulative.values, linewidth=2, color='#7F8C8D', label='Buy & Hold', alpha=0.7) axes[0].set_ylabel('Cumulative Return') axes[0].set_title('Strategy Performance: Model vs Buy & Hold') axes[0].legend() axes[0].grid(alpha=0.3) # Drawdown strategy_cumulative_full = (1 + strategy_returns).cumprod() strategy_running_max = strategy_cumulative_full.expanding().max() strategy_drawdown = (strategy_cumulative_full - strategy_running_max) / strategy_running_max axes[1].fill_between(strategy_drawdown.index, strategy_drawdown.values * 100, 0, color='#E74C3C', alpha=0.3) axes[1].plot(strategy_drawdown.index, strategy_drawdown.values * 100, linewidth=1, color='#C0392B') axes[1].set_ylabel('Drawdown (%)') axes[1].set_xlabel('Date') axes[1].set_title('Strategy Drawdown') axes[1].grid(alpha=0.3) plt.tight_layout() plt.show() Рис. 3: Производительность стратегии на основе модели против buy & hold. Верхняя панель показывает кумулятивную доходность — стратегия модели превосходит пассивное владение в периоды высокой предсказуемости. Нижняя панель демонстрирует просадки стратегии — максимальная просадка остается в контролируемых пределах. Периоды глубоких просадок совпадают с режимными сдвигами рынка, когда исторические паттерны перестают работать Метрики стратегии на основе модели: Sharpe Ratio: 3.8074 Max Drawdown: -14.7837% Total Return: 121.7934% Win Rate: 60.9589% Buy & Hold benchmark: Sharpe Ratio: 1.0624 Max Drawdown: -25.1931% Total Return: 21.7338% Код создает синтетическую торговую стратегию на котировках акций Boeing за последние 3 года. Позиция определяется знаком предсказания: положительный прогноз означает long, отрицательный — short. В отчете этой стратегии мы можем наблюдать следующие показатели: Strategy_returns вычисляется как произведение предсказанного и фактического направления — корректные прогнозы дают положительную доходность. Sharpe ratio стратегии сравнивается с бенчмарком стратегии buy & hold. Значение выше 1.0 считается хорошим для дневных стратегий, выше 2.0 — отличным. Если Sharpe модели ниже buy & hold, стратегия не имеет смысла — проще держать актив. Win rate показывает долю прибыльных дней. Чем выше Win rate, тем лучше. Однако важно помнить, что данная метрика обманчива — 45% win rate при правильном управлении размером позиции дает положительную прибыль. Максимальная просадка (Maximum drawdown, MDD) измеряет наихудшее падение от пика за период. Для автоматизированных систем MDD определяет требования к капиталу — стратегия с MDD -25% требует запас ликвидности для того, чтобы "пересидеть" период просадки. Также важно помнить, что просадки выше -40% и -50% неприемлемы для большинства инвесторов, даже при высоком Sharpe ratio. Проверка на переобучение (overfitting) Разделение временного ряда на Train / Validation / Test выборки изолирует три этапа разработки модели: Train используется для обучения; Validation для подбора гиперпараметров; Test для финальной оценки. Модель не должна видеть данные из Test выборки до завершения всех экспериментов — иначе неявная оптимизация под эту выборку приводит к переобучению на уровне процесса разработки. Для временных рядов разделение сохраняет хронологический порядок: train [0, 0.7], validation [0.7, 0.85], test [0.85, 1.0]. Пропорции зависят от объема данных — для 5 лет дневных данных (1250 наблюдений) test выборка в 15% дает 187 дней, достаточно для статистически значимой оценки. Стабильность метрик на разных периодах выявляет переобучение на конкретных режимах рынка. Модель тестируется на последовательных 3-месячных окнах внутри test выборки. Если RMSE варьируется от 0.008 до 0.025, модель захватывает временные паттерны без генерализации. Стабильный RMSE в диапазоне 0.012-0.016, напротив, указывает на робастность обученной модели. Важно не забывать, что период данных вне выборки (Out-of-sample period) должен содержать разные режимы рынка: рост, падение, боковое движение, высокую и низкую волатильность. Модель, обученная только на бычьем рынке 2020-2021, ожидаемо провалится в коррекции 2022 года. Включение в train данных разных циклов повышает устойчиовсть ML-модели, но увеличивает риск устаревания паттернов. import numpy as np import pandas as pd from lightgbm import LGBMRegressor from sklearn.metrics import mean_squared_error import yfinance as yf import matplotlib.pyplot as plt # Загрузка данных ticker = yf.Ticker("GOOGL") data = ticker.history(period="5y", interval="1d") if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) data = data[['Close', 'Volume']].dropna() data['returns'] = data['Close'].pct_change() # Feature engineering for lag in range(1, 11): data[f'returns_lag_{lag}'] = data['returns'].shift(lag) for window in [5, 10, 20]: data[f'returns_rolling_mean_{window}'] = data['returns'].rolling(window).mean() data[f'returns_rolling_std_{window}'] = data['returns'].rolling(window).std() data['target'] = data['returns'].shift(-1) data = data.dropna() feature_cols = [col for col in data.columns if 'lag' in col or 'rolling' in col] X = data[feature_cols] y = data['target'] # Train/validation/test split train_size = int(len(data) * 0.7) val_size = int(len(data) * 0.15) X_train = X[:train_size] y_train = y[:train_size] X_val = X[train_size:train_size + val_size] y_val = y[train_size:train_size + val_size] X_test = X[train_size + val_size:] y_test = y[train_size + val_size:] print(f"Train size: {len(X_train)} ({len(X_train)/len(X)*100:.1f}%)") print(f"Validation size: {len(X_val)} ({len(X_val)/len(X)*100:.1f}%)") print(f"Test size: {len(X_test)} ({len(X_test)/len(X)*100:.1f}%)") # Обучение модели model = LGBMRegressor( n_estimators=250, learning_rate=0.01, max_depth=5, subsample=0.8, colsample_bytree=0.8, random_state=42, verbose=-1 ) model.fit(X_train, y_train) # Предсказания на всех выборках y_train_pred = model.predict(X_train) y_val_pred = model.predict(X_val) y_test_pred = model.predict(X_test) # RMSE на каждой выборке train_rmse = np.sqrt(mean_squared_error(y_train, y_train_pred)) val_rmse = np.sqrt(mean_squared_error(y_val, y_val_pred)) test_rmse = np.sqrt(mean_squared_error(y_test, y_test_pred)) print(f"\nRMSE:") print(f" Train: {train_rmse:.6f}") print(f" Validation: {val_rmse:.6f}") print(f" Test: {test_rmse:.6f}") overfit_train_test = train_rmse / test_rmse print(f"\nOverfit ratio (Train/Test): {overfit_train_test:.4f}") # Тест на стабильность: разбиение test на подпериоды test_dates = X_test.index n_subperiods = 4 subperiod_size = len(X_test) // n_subperiods subperiod_results = [] for i in range(n_subperiods): start_idx = i * subperiod_size end_idx = (i + 1) * subperiod_size if i < n_subperiods - 1 else len(X_test) X_sub = X_test.iloc[start_idx:end_idx] y_sub = y_test.iloc[start_idx:end_idx] y_sub_pred = model.predict(X_sub) rmse_sub = np.sqrt(mean_squared_error(y_sub, y_sub_pred)) directional_acc = (np.sign(y_sub_pred) == np.sign(y_sub)).mean() * 100 subperiod_results.append({ 'Period': f"{test_dates[start_idx].strftime('%Y-%m-%d')} to {test_dates[min(end_idx-1, len(test_dates)-1)].strftime('%Y-%m-%d')}", 'RMSE': rmse_sub, 'Dir. Acc.': directional_acc, 'n': len(X_sub) }) subperiod_df = pd.DataFrame(subperiod_results) print("\nСтабильность на подпериодах test выборки:") print(subperiod_df.to_string(index=False)) rmse_std = subperiod_df['RMSE'].std() rmse_mean = subperiod_df['RMSE'].mean() print(f"\nКоэффициент вариации RMSE: {rmse_std / rmse_mean:.4f}") # Визуализация ошибок по времени fig, axes = plt.subplots(2, 1, figsize=(14, 8)) # Предсказания vs факт на test axes[0].scatter(range(len(y_test)), y_test.values, alpha=0.5, s=20, color='#2C3E50', label='Actual') axes[0].scatter(range(len(y_test)), y_test_pred, alpha=0.5, s=20, color='#E74C3C', label='Predicted') axes[0].set_ylabel('Returns') axes[0].set_title('Test Set: Actual vs Predicted Returns') axes[0].legend() axes[0].grid(alpha=0.3) # Rolling RMSE на test выборке window_rmse = 50 rolling_rmse = [] for i in range(window_rmse, len(y_test)): window_actual = y_test.iloc[i-window_rmse:i] window_pred = y_test_pred[i-window_rmse:i] rmse_window = np.sqrt(mean_squared_error(window_actual, window_pred)) rolling_rmse.append(rmse_window) axes[1].plot(range(window_rmse, len(y_test)), rolling_rmse, linewidth=2, color='#3498DB') axes[1].axhline(y=test_rmse, color='#E74C3C', linestyle='--', linewidth=2, label=f'Overall Test RMSE: {test_rmse:.6f}') axes[1].set_ylabel('Rolling RMSE') axes[1].set_xlabel('Test Sample Index') axes[1].set_title(f'Rolling RMSE (window={window_rmse})') axes[1].legend() axes[1].grid(alpha=0.3) plt.tight_layout() plt.show() Рис. 4: Анализ переобучения на test выборке. Верхняя панель показывает рассеяние фактических и предсказанных доходностей — предсказания группируются вокруг нуля с меньшей дисперсией, чем факт. Модель консервативна, недооценивает экстремальные движения. Нижняя панель демонстрирует rolling RMSE на окне 50 дней — всплески ошибки совпадают с периодами повышенной волатильности. Стабильность rolling RMSE вокруг overall test RMSE подтверждает отсутствие критической деградации качества Train size: 863 (69.9%) Validation size: 185 (15.0%) Test size: 186 (15.1%) RMSE: Train: 0.016973 Validation: 0.016590 Test: 0.020967 Overfit ratio (Train/Test): 0.8095 Стабильность на подпериодах test выборки: Period RMSE Dir. Acc. n 2025-01-23 to 2025-03-28 0.023245 47.826087 46 2025-03-31 to 2025-06-04 0.025336 47.826087 46 2025-06-05 to 2025-08-11 0.014821 56.521739 46 2025-08-12 to 2025-10-17 0.018976 54.166667 48 Коэффициент вариации RMSE: 0.2268 Код выполняет комплексную проверку на переобучение модели прогнозирования котировок акций Google: Разделение временного ряда на выборки Train / Validation / Test в пропорции 70/15/15 обеспечивает достаточный объем для каждого этапа. Валидационная выборка используется для мониторинга качества в процессе разработки, а тестовая остается нетронутой до финальной оценки качества ML-модели. Показатель Overfit ratio (Train RMSE / Test RMSE) количественно оценивает переобучение. Значение близкое к 1.0 идеально, 0.9-0.95 приемлемо для финансовых данных, ниже 0.8 указывает на серьезное переобучение. Если val_rmse близок к test_rmse, но оба значительно хуже train_rmse, модель переобучилась на обучающей выборке. Разбиение test на 4 подпериода проверяет стабильность метрик качества во времени. Интерпретация других метрик следующая. Коэффициент вариации RMSE (std / mean) ниже 0.15 означает стабильную производительность. Значение выше 0.30 указывает на зависимость от режима рынка — модель работает в одних условиях и проваливается в других. Заключение Baseline модели определяют точку отсчета для любой системы прогнозирования временных рядов. Они позволяют оценить, стоит ли использовать более сложные алгоритмы, и помогают избежать неоправданной сложности в проектах. Простые модели, такие как линейная регрессия с правильными признаками или ETS/Prophet для рядов с выраженной сезонностью, часто показывают стабильные результаты и могут служить надежным ориентиром для оценки улучшений. В то же время современные методы, такие как градиентный бустинг и CatBoost, демонстрируют высокую эффективность на средних и больших выборках, особенно когда данные разнородны или содержат категориальные признаки. Правильная настройка регуляризации и подбор гиперпараметров позволяют моделям избегать переобучения и извлекать сложные нелинейные зависимости, которые недоступны для простых моделей. Таким образом, использование бейзлайн моделей в сочетании с более сложными алгоритмами позволяет строить прогнозы, которые одновременно точны, интерпретируемы и устойчивы к шуму, что особенно важно для финансовых временных рядов и бизнес-метрик. Такие бейзлайн модели могут стать не только инструментом оценки качества, но и отправной точкой для разработки надежной системы прогнозирования. ### Топ-10 API для биржевой торговли Современная алгоритмическая торговля требует надежного доступа к рыночным данным и возможности автоматического исполнения ордеров. API биржевых брокеров и поставщиков данных решают обе задачи, предоставляя программный интерфейс для получения котировок, исторических данных и управления позициями. Выбор правильного API влияет на скорость исполнения стратегий, качество данных для бэктестинга и общую эффективность торговой системы. В этой статье я рассмотрю топ-10 API, которые покрывают различные сегменты рынка: от акций и опционов до криптовалют и форекса. Каждый API проанализирован с точки зрения технических возможностей, качества данных и практического применения в алгоритмической торговле. Критерии выбора API для алгоритмической торговли Технические характеристики Пожалуй, ключевой критерий выбора API - скорость получения данных и исполнения ордеров. За это отвечает показатель латентность (Latency). Разумеется, тут многое зависит от торговой стратегии: Для высокочастотных стратегий подойдут только API с минимальной задержкой (до 50-100 мс); Для позиционной торговли допустимы значения до 500 мс. Даже если вы намерены исполнять сделки всего лишь несколько раз в неделю долгий latency может существенно испортить доходность стратегии проскальзыванием на совершенно неожиданные уровни цен. Протокол передачи данных провайдера API также имеет значение: Протокол WebSocket обеспечивает real-time стриминг с минимальной задержкой; Протокол REST API медленный, и больше подходит для запросов исторических данных и размещения лимитных ордеров. Еще один важный показатель - ограничения на частоту запросов (Rate limiting). Лимит в 200 запросов в минуту достаточен для портфельных стратегий с ребалансировкой раз в день, однако недостаточен для внутридневного скальпинга по 30+ инструментам. Некоторые API предлагают burst режим — возможность отправить пакет запросов выше обычного лимита за короткий период. Немаловажный критерий выбора API - качество документации и наличие официальных библиотек для Python и C++. Это ускоряет время и издержки на интеграцию. API с активным комьюнити и примерами кода на GitHub позволяют быстрее решать технические проблемы. Стабильность API и частота больших обновлений определяют затраты на поддержку: частые обновления требуют постоянной адаптации торговых систем. Качество данных и покрытие рынков Источник данных влияет на точность анализа: Консолидированные данные от нескольких бирж позволяют увидеть полную картину ликвидности; В то же время данные от одной биржи могут содержать гэпы в котировках в периоды низкой активности; Для акций США критично наличие данных уровня Level 2 (depth of market) — это позволяет анализировать дисбалансы в книге заявок. Историческая глубина данных определяет возможности бэктестинга. Для проверки стратегий в различных режимах, как правило, требуется минимум 5 лет дневных данных, включая кризисные периоды. Для внутридневных стратегий важна доступность минутных и тиковых данных. Хотя, по моему опыту, бары на 1-минутном интервале достаточны для большинства задач. В исторических данных рынка акций важна правильна обработка корпоративных событий (splits, dividends). API без автоматической корректировки требуют ручной обработки данных, что увеличивает риск ошибок в бэктестинге. Проверка качества данных включает детекцию выбросов, пропусков и аномальных свечей — некоторые API предоставляют уже очищенные данные, другие требуют собственной валидации. Ценообразование и лимиты Структура тарификации биржевых API весьма разнообразна и варьируется от бесплатных планов с ограничениями до корпоративных подписок за несколько тысяч долларов в месяц. Бесплатные планы обычно включают 5-500 запросов в день и задержку данных на 5-15 минут, что подходит для обучения и тестирования идей, но неприменимо в продакшене. Платные планы начинаются от $10-50 в месяц для розничных трейдеров и масштабируются до $1000+ для профессиональных фирм. Модель оплаты влияет на экономику стратегии: Фиксированная подписка предсказуема и выгодна при высокой частоте запросов; Модель оплаты pay-per-call оптимальна для редких обращений. Некоторые API берут плату за каждый инструмент в портфеле — это тоже важно учитывать, если вы планируете торговать диверсифицированными стратегиями на 50-100 тикеров и более. Есть провайдеры биржевых данных, которые устанавливают лимиты на объем данных. Это напрямую влияет на масштабируемость торговли. Так, к примеру, ограничение в 100 тикеров на аккаунт достаточно для сфокусированного портфеля, однако ограничивает возможности реализации статистического арбитража на множестве рынков. Лимиты на размер исторических выгрузок также влияют на скорость инициализации торговой системы: возможность загрузить 5 лет данных одним запросом экономит время против сотни запросов по небольшим чанкам. Топ-10 API для биржевой торговли 1. Interactive Brokers API Interactive Brokers - это, пожалуй, один из лучших брокеров сегодня. И их API - не исключение. API от Interactive Brokers предоставляет доступ к акциям, опционам, фьючерсам, форексу и облигациям более чем на 150 биржах. API поддерживает как исторические данные, так и real-time котировки с возможностью прямого исполнения ордеров. Архитектура основана на TWS (Trader Workstation) — desktop приложении, которое работает как gateway между торговой системой и брокером. Python-библиотека ib_insync упрощает работу с нативным API, предоставляя асинхронный интерфейс для запросов данных и управления ордерами. Латентность получения и обработки данных зависит от географии: если подключаться к серверам в США задержка составляет 50-100 мс для европейских клиентов; если подключаться к локальным серверам - менее 20 мс. Лимиты на запросы у Interactive Brokers составляют 60 запросов за 10 минут для исторических данных, стриминг котировок ограничен 100 одновременными подписками для базового аккаунта. from ib_insync import IB, Stock ib = IB() ib.connect('127.0.0.1', 7497, clientId=1) contract = Stock('TSMC', 'SMART', 'USD') bars = ib.reqHistoricalData( contract, endDateTime='', durationStr='30 D', barSizeSetting='1 day', whatToShow='TRADES', useRTH=True ) ib.disconnect() Представленный выше код: Загружает 30 дневные данные в таймфрейме D1 для акций Taiwan Semiconductor через IB Gateway; Параметр SMART автоматически выбирает оптимальную биржу для исполнения; Полученные бары содержат OHLC и объем, готовые для использования в pandas; API возвращает скорректированные на сплиты цены, но дивиденды нужно обрабатывать отдельно через reqDividends(). Основное преимущество Interactive Brokers — комплексность решения для профессиональной торговли. Один аккаунт дает доступ к глобальным рынкам без необходимости интеграции с несколькими брокерами. Комиссии за пользование API отсутствуют, однако требуется активный брокерский счет с минимальным балансом $10,000 для доступа к real-time данным по всем рынкам. Для счетов меньшего размера доступны данные с задержкой или платная подписка на real-time фиды. 2. Alpaca API Платформа Alpaca фокусируется на комиссионной торговле акциями и криптовалютами для алгоритмических трейдеров. Ее API построен на современном REST и WebSocket стеке, документация включает примеры для Python, Go и JavaScript. Отличительная особенность Alpaca API — Paper trading environment идентичный продакшену, что упрощает тестирование стратегий без риска реальных потерь. Исторические данные доступны бесплатно через Alpaca Data API v2 с минутными барами и агрегированными сделками. Покрытие включает все акции NYSE, NASDAQ и AMEX с 2016 года. Real-time стриминг предоставляется через WebSocket с латентностью 100-300 мс для розничных клиентов. Лимиты составляют 200 запросов в минуту для REST API, стриминг не ограничен по количеству подписок. from alpaca.data import StockHistoricalDataClient from alpaca.data.requests import StockBarsRequest from alpaca.data.timeframe import TimeFrame from datetime import datetime client = StockHistoricalDataClient(api_key='YOUR_KEY', secret_key='YOUR_SECRET') request = StockBarsRequest( symbol_or_symbols='TSM', timeframe=TimeFrame.Day, start=datetime(2024, 1, 1) ) bars = client.get_stock_bars(request) df = bars.df Представленный пример кода формирует запрос и получает дневные бары для Taiwan Semiconductor с начала 2024 года. Метод get_stock_bars() возвращает объект с методом df для конвертации в pandas датафрейм. Данные включают OHLC, объем и количество сделок за период. API автоматически корректирует цены на splits и предоставляет VWAP для каждого бара. API от Alpaca оптимален для розничных трейдеров, начинающих в алгоритмической торговле. Отсутствие минимального депозита и комиссий за торговлю акциями снижает порог входа. Ограничения включают работу только с рынком США и отсутствие опционов и фьючерсов. Для криптовалют доступны BTC, ETH и несколько альткоинов через партнерство с криптобиржами. 3. TD Ameritrade API TD Ameritrade API предоставляет доступ к акциям, опционам, фьючерсам и форексу с фокусом на розничных трейдеров в США. Интеграция через OAuth 2.0 обеспечивает безопасную аутентификацию без передачи учетных данных в коде. API от Ameritrade поддерживает получение котировок, исторических данных, размещение ордеров и мониторинг позиций через единый REST интерфейс. Исторические данные доступны с детализацией от минутных баров до месячных свечей, глубина архива достигает 20 лет для популярных акций. Real-time котировки предоставляются с задержкой 500 мс для базового аккаунта, funded аккаунты получают доступ к Level 1 данным без задержки. Лимит составляет 120 запросов в минуту, превышение приводит к временной блокировке на 1 минуту. import tda from tda import auth client = auth.client_from_token_file('token.json', 'YOUR_API_KEY') resp = client.get_price_history( 'ASML', period_type=tda.client.Client.PriceHistory.PeriodType.MONTH, period=6, frequency_type=tda.client.Client.PriceHistory.FrequencyType.DAILY, frequency=1 ) data = resp.json() candles = data['candles'] Выше пример как можно загрузить 6 месяцев дневных баров акций ASML через официальную библиотеку tda-api: Метод get_price_history() возвращает response object; JSON содержит массив свечей с полями open, high, low, close, volume и datetime; Параметры period_type и frequency_type контролируют временной диапазон и детализацию, что позволяет гибко настраивать запросы под различные стратегии. TD Ameritrade подходит для трейдеров, работающих с опционами и мультилеговыми стратегиями. API поддерживает создание цепочек опционов с возможностью фильтрации по страйку, экспирации и греках. Недостаток Ameritrade — сервис доступен только для резидентов США и требует открытия брокерского счета. После поглощения Charles Schwab API планируется мигрировать на новую платформу с сохранением функциональности. 4. Polygon.io Polygon.io специализируется на предоставлении высококачественных рыночных данных с акцентом на полноту и детализацию. API от Polygon.io предоставляет тиковые данные, агрегированные бары, опционные цепочки и данные уровня Level 2 для акций США. Архитектура построена на REST и WebSocket с поддержкой как исторических запросов, так и real-time стриминга. Покрытие включает все акции, опционы и криптовалюты с историей до 2004 года. Тиковые данные содержат каждую сделку с точностью до наносекунды, что может быть крайне полезным для бэктестинга HFT-стратегий, так и для анализа текущей микроструктуры рынка. Агрегированные бары доступны с интервалами от 1 секунды до месяца. Бесплатный план ограничен 5 запросами в минуту и задержкой данных на 15 минут, платные планы начинаются от $29/месяц с лимитом 100 запросов в минуту. from polygon import RESTClient client = RESTClient(api_key='YOUR_API_KEY') aggs = client.get_aggs( ticker='AMD', multiplier=1, timespan='day', from_='2024-01-01', to='2024-10-01' ) for bar in aggs: print(f"{bar.timestamp}: O={bar.open} H={bar.high} L={bar.low} C={bar.close} V={bar.volume}") Выше пример запроса к API от Polygon.io: Запрашиваются дневные бары для акций AMD за указанный период; Параметр multiplier позволяет создавать кастомные интервалы — например, 5-минутные бары задаются как multiplier=5, timespan='minute'; Объект aggs возвращает итератор с атрибутами timestamp, OHLC и volume для каждой свечи. Polygon.io оптимален для разработки и бэктестинга стратегий, требующих детальных данных. Интересно отметить, что данный API автоматически обрабатывает пагинацию для больших датасетов. План Starter за $29/месяц позволяет делать 100 запросов в минуту и 2 года истории без задержки — достаточно для большинства внутридневных стратегий. Недостаток — API не поддерживает торговое исполнение, требуется интеграция с отдельным брокером. Для институциональных клиентов доступен план за $399/месяц с неограниченными запросами и полным доступом к тиковым данным. 5. Alpha Vantage Alpha Vantage предоставляет бесплатный доступ к историческим данным акций, форекса, криптовалют и технических индикаторов. API построен на REST с простым интерфейсом, не требующим аутентификации через OAuth — достаточно API ключа. Покрытие включает глобальные рынки акций, 150+ валютных пар и основные криптовалюты. Функциональность поставляемых данных от Alpha Vantage расширена встроенными техническими индикаторами — от простых скользящих средних до MACD и Stochastic. Это снижает необходимость в собственных вычислениях, однако индикаторы рассчитаны по стандартным формулам без возможности кастомизации параметров. Бесплатный план ограничен 25 запросами в день и 5 запросами в минуту, премиум планы от $49.99/месяц увеличивают лимиты до 75-1200 запросов в минуту. import requests import pandas as pd url = 'https://www.alphavantage.co/query' params = { 'function': 'TIME_SERIES_DAILY', 'symbol': 'SAP', 'apikey': 'YOUR_API_KEY', 'outputsize': 'full' } response = requests.get(url, params=params) data = response.json() time_series = data['Time Series (Daily)'] df = pd.DataFrame.from_dict(time_series, orient='index') df.index = pd.to_datetime(df.index) df = df.astype(float) Запрос выше получает полную историю дневных данных для акций SAP: Параметр outputsize='full' возвращает все доступные данные (до 20 лет); Значение 'compact' ограничивает выдачу последними 100 барами; JSON структура требует ручной обработки для конвертации в датафреймы pandas (необходимо транспонировать dict и привести типы из строк в float); Ключи в time_series имеют префиксы '1. open', '2. high', что требует переименования колонок. API от Alpha Vantage подходит для обучения и экспериментов с минимальным бюджетом. Бесплатный лимит 25 запросов в день достаточен для загрузки данных нескольких тикеров раз в сутки при разработке долгосрочных стратегий. Однако этот API не подойдет для профессионального деплоя торговых систем. По нескольким причинам: Жесткие лимиты на число запросов в минуту; Отсутствие внутридневных данных детальнее 1-минутных баров; Невозможность торгового исполнения сделок; Задержка real-time данных составляет несколько минут, что делает невозможным применение данных этого провайдера для HFT. 6. IEX Cloud IEX Cloud предоставляет финансовые данные через REST API с фокусом на прозрачность и качество. Данные поступают из IEX Exchange — биржи, известной борьбой с высокочастотным front-running через speed bump механизм. API от IEX Cloud включает котировки акций, фундаментальные данные, опционы, новости и альтернативные датасеты. Структура тарификации основана на кредитах: Каждый endpoint потребляет определенное количество кредитов за запрос; Бесплатный план дает 50,000 кредитов в месяц — достаточно для 500-1000 запросов в зависимости от типа данных; Котировки стоят 1-10 кредитов; Исторические данные 10-100 кредитов за запрос; Ежемесячные планы начинаются от $9/месяц с 500,000 кредитов и масштабируются до корпоративных контрактов. import requests base_url = 'https://cloud.iexapis.com/stable' token = 'YOUR_TOKEN' endpoint = f'{base_url}/stock/NVO/chart/6m' params = {'token': token} response = requests.get(endpoint, params=params) data = response.json() for bar in data: print(f"{bar['date']}: Close={bar['close']}, Volume={bar['volume']}") Запрос получает 6 месяцев дневных данных для Novo Nordisk: Endpoint /chart поддерживает интервалы от '1d' до '5y', параметр можно заменить на динамический диапазон вроде 'ytd' (year-to-date); JSON возвращает массив словарей с полями date, open, high, low, close, volume; API не предоставляет adjusted цены по умолчанию — корректировки на сплиты доступны через отдельный параметр includeToday. API от IEX Cloud оптимален для стратегий, комбинирующих ценовые данные с фундаментальными показателями, поскольку предоставляет финансовую отчетность, календари выручки, данные сделок инсайдеров через единый интерфейс. Главный недостаток данного API — отсутствие торгового исполнения и ограниченная поддержка международных рынков за пределами крупных бирж. Для real-time стриминга доступен SSE (Server-Sent Events) протокол как альтернатива WebSocket. 7. OANDA API OANDA API специализируется на торговле Forex и CFD с поддержкой более 100 валютных пар, металлов и индексов. Платформа ориентирована на розничных форекс трейдеров с возможностью торговли от $0 минимального депозита. Архитектура разделена на REST API для управления ордерами и получения исторических данных, и streaming API для real-time котировок. Исторические данные доступны с детализацией от тиковых данных до месячных свечей, история сохраняется до 5 лет для основных валютных пар. Streaming API предоставляет bid/ask котировки с латентностью 100-200 мс. Лимиты на запросы составляют 100 запросов за 10 секунд для REST API, стриминг поддерживает до 1000 одновременных инструментов. Демо-аккаунт с виртуальными деньгами идентичен Live аккаунту по функциональности. import requests account_id = 'YOUR_ACCOUNT_ID' token = 'YOUR_TOKEN' base_url = 'https://api-fxpractice.oanda.com' endpoint = f'{base_url}/v3/instruments/EUR_USD/candles' headers = {'Authorization': f'Bearer {token}'} params = { 'count': 100, 'granularity': 'D' } response = requests.get(endpoint, headers=headers, params=params) data = response.json() candles = data['candles'] for candle in candles: print(f"{candle['time']}: Mid={candle['mid']['c']}") Запрос выше получает последние 100 дневных свечей для валютной пары EUR/USD: Параметр granularity поддерживает значения от 'S5' (5 секунд) до 'M' (месяц); OANDA возвращает как bid, так и ask цены для каждой свечи, параметр mid содержит среднее значение; Поле time представлено в RFC3339 формате, требуется конвертация в datetime для анализа; API поддерживает запросы по временному диапазону через параметры from и to как альтернативу count. OANDA подходит для Forex и CFD стратегий с возможностью прямого исполнения сделок. Спреды Оанды весьма конкурентны для мажорных пар (0.6-1.2 пункта для EUR/USD), кредитное плечо составляет до 50:1 для розничных клиентов в большинстве юрисдикций. Ключевыми недостатками данного API можно считать отсутствие акций и опционов, а также ограниченный набор технических индикаторов. Комиссия за неактивность составляет $10/месяц при отсутствии сделок более 12 месяцев. 8. Binance API Binance API предоставляет доступ к криптовалютной торговле с поддержкой рынков Spot, Margin, фьючерсов и опционов. API бесплатен, комиссии взимаются только за торговые операции. REST API от Binance покрывает управление ордерами и получение рыночных данных, WebSocket обеспечивает real-time стриминг с латентностью 10-50 мс. Платформа поддерживает более 600 торговых пар с высокой ликвидностью и глубиной стакана. Исторические данные доступны с минутными барами и агрегированными сделками, максимальная глубина составляет 1000 баров на запрос. Для больших диапазонов требуется пагинация. Лимиты зависят от весовой системы: каждый endpoint имеет вес от 1 до 40, суммарный лимит 1200 весовых единиц в минуту. WebSocket стримы не учитываются в лимитах. from binance.client import Client from binance.enums import * client = Client(api_key='YOUR_KEY', api_secret='YOUR_SECRET') klines = client.get_historical_klines( 'ADAUSDT', Client.KLINE_INTERVAL_1DAY, '1 Jan, 2024', '1 Oct, 2024' ) for kline in klines: timestamp, open_price, high, low, close, volume = kline[0:6] print(f"{timestamp}: Close={close}") Запрос получает дневные свечи для Cardano (ADA) vs USDT от Tether. Библиотека python-binance упрощает работу с REST API, предоставляя методы для всех endpoints. API от Binance оптимален для криптовалютных стратегий с возможностью маржинальной торговли и деривативов. Низкие комиссии (0.1% для spot, 0.02% для futures со скидками за объем) делают платформу привлекательной для высокочастотных стратегий. Ограничения включают регуляторные риски — платформа недоступна в некоторых юрисдикциях, включая США для большинства продуктов. Для US клиентов доступен Binance.US с урезанной функциональностью. 9. Bloomberg API Bloomberg Terminal API предоставляет институциональный доступ к финансовым данным, аналитике и новостям через Desktop API или Server API. Покрытие включает акции, облигации, деривативы, сырьевые товары, форекс и альтернативные данные с глубиной до нескольких десятилетий. API поддерживает как исторические запросы, так и real-time подписки с минимальной латентностью. Архитектура основана на BLPAPI — нативной библиотеке с обертками для Python, C++, Java и .NET: Desktop API работает через локальный Bloomberg Terminal; Server API допускает удаленные подключения для автоматизированных систем. Данные включают не только котировки, но и фундаментальные показатели, оценки аналитиков, корпоративные действия и экономические индикаторы. Качество данных проходит мультиуровневую валидацию Bloomberg. import blpapi session = blpapi.Session() session.start() session.openService('//blp/refdata') service = session.getService('//blp/refdata') request = service.createRequest('HistoricalDataRequest') request.append('securities', 'QCOM US Equity') request.append('fields', 'PX_LAST') request.set('startDate', '20240101') request.set('endDate', '20241001') session.sendRequest(request) Запрос получает исторические цены закрытия (PX_LAST) для Qualcomm через Bloomberg Terminal. Идентификаторы инструментов используют формат 'Ticker Exchange Type', например 'QCOM US Equity'. Bloomberg API возвращает данные через асинхронный event-driven механизм — после sendRequest() необходимо обрабатывать события в цикле для получения результатов. Библиотека поддерживает bulk запросы для множества инструментов и полей одновременно. Bloomberg Terminal - самое профессиональное решение в нашем рейтинге. Однако он требует подписки стоимостью $24,000+ в год на пользователя, что делает его самым дорогим биржевым API. Более того, для автоматизированных стратегий придется еще доплатить за доступ к Server API. Если говорить о преимуществах, то это: Непревзойденное качество данных; Покрытие любых рынков и экзотических инструментов; Глубокая интеграция с аналитическими инструментами. 10. Quandl (Nasdaq Data Link) Quandl, ребрендированный в Nasdaq Data Link, агрегирует финансовые и альтернативные данные из сотен источников. Платформа предоставляет доступ к акциям, фьючерсам, форексу, макроэкономическим индикаторам и альтернативным датасетам (спутниковые данные, анализ сентимента, веб-трафик). API построен на REST с Python библиотекой для упрощенного доступа. Структура данных организована по датасетам и таблицам с уникальными кодами. Бесплатные датасеты включают базовые исторические данные акций и индексов, премиум датасеты требуют платной подписки от $50 до нескольких тысяч долларов в месяц в зависимости от источника. Лимиты бесплатного плана составляют 50 запросов в день, премиум планы увеличивают лимит до неограниченного с приоритетной обработкой запросов. import nasdaqdatalink nasdaqdatalink.ApiConfig.api_key = 'YOUR_API_KEY' data = nasdaqdatalink.get( 'WIKI/INTC', start_date='2024-01-01', end_date='2024-10-01' ) print(data[['Open', 'High', 'Low', 'Close', 'Volume']].head()) Запрос получает данные Intel из датасета WIKI (бесплатный архив Quandl с корректировками на splits и dividends). Код датасета формируется как 'SOURCE/TICKER', где SOURCE — провайдер данных. Метод get() возвращает pandas DataFrame с готовой структурой, индексированной по датам. API автоматически обрабатывает корректировки цен и предоставляет adjusted колонки для точного бэктестинга. Quandl оптимален для исследовательских задач, требующих комбинации традиционных и альтернативных данных. Платформа упрощает доступ к макроэкономическим индикаторам (GDP, CPI, unemployment) и нишевым датасетам вроде короткого интереса или институционального владения. Недостатки API включают отсутствие real-time данных и торгового исполнения. Датасет WIKI прекратил обновления в 2018 году, актуальные данные доступны через премиум подписки на EOD (End of Day) датасеты. Сравнительный анализ Выбор API зависит от типа стратегии и классов активов: Для глобальной диверсифицированной торговли Interactive Brokers предоставляет наиболее полное решение с прямым исполнением; Разработка и тестирование стратегий на US акциях эффективнее через Alpaca или Polygon.io благодаря простоте интеграции и качеству документации; Для торговых стратегий Forex и CFD стратегии лучше выбрать OANDA; Для криптовалютных стратегий — Binance. Институциональные решения вроде Bloomberg оправданы только если вы строите хедж-фонд, либо инвестбанк, либо планируете работать с экзотическими инструментами и по всем биржам мира. Ниже представлена таблица сравнения топ-10 биржевых API по ключевым характеристикам: Примечание: Латентность указана для географически близких подключений, международные соединения увеличивают задержку на 50-200 мс. Стоимость приведена для базовых планов по состоянию на октябрь 2024 года, корпоративные контракты требуют индивидуального согласования. Заключение API биржевых данных трансформировали алгоритмическую торговлю из привилегии институциональных игроков в доступный инструмент для розничных трейдеров. Десять рассмотренных решений покрывают спектр от бесплатных сервисов для обучения до институциональных платформ с молниеносным исполнением сделок. Сегодня именно качество и скорость доступа к данным становятся ключевыми факторами конкурентного преимущества на финансовых рынках. Современные API позволяют получать котировки, исторические ряды, фундаментальные показатели и даже новости в реальном времени, что открывает путь к построению сложных аналитических моделей, торговых роботов и систем мониторинга рисков. ### Виды статистических распределений в финансах: их интерпретация и применение Статистические распределения формируют математический фундамент финансового анализа. Выбор адекватной модели распределения влияет на точность оценки рисков, корректность ценообразования активов и надежность прогнозных моделей. Понимание свойств различных распределений позволяет избежать систематических ошибок при принятии инвестиционных решений. Нормальное распределение: базовая модель и ее ограничения Нормальное (гауссово) распределение - наиболее популярное в финансовом моделировании. Распределение задается двумя параметрами: μ (математическое ожидание); σ (стандартное отклонение). Плотность вероятности симметрична относительно среднего, 68% значений лежат в пределах одного стандартного отклонения, 95% — в пределах двух. Центральная предельная теорема (ЦПТ) объясняет широкое применение нормального распределения: сумма большого числа независимых случайных величин стремится к нормальному распределению. Это делает его естественным выбором для моделирования агрегированных эффектов множества факторов. Модель Блэка-Шоулза, современная портфельная теория Марковица, CAPM — все базируются на предположении нормальности доходностей. Между тем, реальные финансовые данные систематически нарушают предположения нормальности. Эмпирические распределения доходностей демонстрируют три ключевых отклонения: избыточный эксцесс (leptokurtosis), асимметрия (skewness) и тяжелые хвосты (fat tails). Эксцесс нормального распределения равен 3, для финансовых временных рядов типичны значения 5-10 и выше. Это означает, что экстремальные события происходят чаще, чем предсказывает гауссова модель. import numpy as np import matplotlib.pyplot as plt from scipy import stats # Генерация синтетических финансовых доходностей np.random.seed(42) n_samples = 2000 # Создаем данные с характеристиками реальных финансовых рядов # Используем смесь нормального и t-распределения для имитации толстых хвостов normal_component = np.random.normal(0.0005, 0.015, int(n_samples * 0.9)) extreme_component = np.random.standard_t(df=3, size=int(n_samples * 0.1)) * 0.04 returns = np.concatenate([normal_component, extreme_component]) np.random.shuffle(returns) # Статистики распределения mean_ret = np.mean(returns) std_ret = np.std(returns) skew = stats.skew(returns) kurt = stats.kurtosis(returns, fisher=True) # Тест Харке-Бера на нормальность jb_stat, jb_pvalue = stats.jarque_bera(returns) # Тест Колмогорова-Смирнова ks_stat, ks_pvalue = stats.kstest(returns, 'norm', args=(mean_ret, std_ret)) print(f"Статистики распределения:") print(f"Среднее: {mean_ret:.6f}") print(f"Ст. отклонение: {std_ret:.6f}") print(f"Асимметрия: {skew:.4f}") print(f"Эксцесс: {kurt:.4f}") print(f"\nТест Харке-Бера: статистика={jb_stat:.2f}, p-value={jb_pvalue:.6f}") print(f"Тест Колмогорова-Смирнова: статистика={ks_stat:.4f}, p-value={ks_pvalue:.6f}") # Визуализация fig, axes = plt.subplots(2, 2, figsize=(14, 10)) # График 1: Временной ряд доходностей axes[0, 0].plot(range(len(returns)), returns, color='black', linewidth=0.5, alpha=0.7) axes[0, 0].set_title('Временной ряд доходностей', fontsize=11, fontweight='bold') axes[0, 0].set_xlabel('Период') axes[0, 0].set_ylabel('Доходность') axes[0, 0].axhline(y=0, color='gray', linestyle='--', linewidth=1) axes[0, 0].grid(alpha=0.3) # График 2: Гистограмма с наложением нормального распределения axes[0, 1].hist(returns, bins=60, density=True, alpha=0.6, color='gray', edgecolor='black') x_range = np.linspace(returns.min(), returns.max(), 200) axes[0, 1].plot(x_range, stats.norm.pdf(x_range, mean_ret, std_ret), 'r-', linewidth=2, label='Нормальное') axes[0, 1].set_title('Распределение доходностей', fontsize=11, fontweight='bold') axes[0, 1].set_xlabel('Доходность') axes[0, 1].set_ylabel('Плотность') axes[0, 1].legend() axes[0, 1].grid(alpha=0.3) # График 3: Q-Q plot stats.probplot(returns, dist="norm", plot=axes[1, 0]) axes[1, 0].set_title('Q-Q график (Normal)', fontsize=11, fontweight='bold') axes[1, 0].grid(alpha=0.3) # График 4: Сравнение хвостов quantiles = np.arange(0.01, 0.1, 0.01) empirical_left = np.quantile(returns, quantiles) theoretical_left = stats.norm.ppf(quantiles, mean_ret, std_ret) quantiles_right = np.arange(0.9, 1.0, 0.01) empirical_right = np.quantile(returns, quantiles_right) theoretical_right = stats.norm.ppf(quantiles_right, mean_ret, std_ret) axes[1, 1].scatter(theoretical_left, empirical_left, color='red', alpha=0.7, s=30, label='Левый хвост (1-10%)') axes[1, 1].scatter(theoretical_right, empirical_right, color='blue', alpha=0.7, s=30, label='Правый хвост (90-99%)') axes[1, 1].plot([returns.min(), returns.max()], [returns.min(), returns.max()], 'k--', linewidth=1, label='Идеальное соответствие') axes[1, 1].set_title('Сравнение квантилей', fontsize=11, fontweight='bold') axes[1, 1].set_xlabel('Теоретические квантили (Normal)') axes[1, 1].set_ylabel('Эмпирические квантили') axes[1, 1].legend() axes[1, 1].grid(alpha=0.3) plt.tight_layout() plt.show() # Анализ хвостов print(f"\nАнализ хвостов:") print(f"5% квантиль (эмпирический): {np.quantile(returns, 0.05):.6f}") print(f"5% квантиль (теоретический): {stats.norm.ppf(0.05, mean_ret, std_ret):.6f}") print(f"95% квантиль (эмпирический): {np.quantile(returns, 0.95):.6f}") print(f"95% квантиль (теоретический): {stats.norm.ppf(0.95, mean_ret, std_ret):.6f}") Статистики распределения: Среднее: 0.000689 Ст. отклонение: 0.025977 Асимметрия: -1.0466 Эксцесс: 39.7292 Тест Харке-Бера: статистика=131899.05, p-value=0.000000 Тест Колмогорова-Смирнова: статистика=0.1188, p-value=0.000000 Анализ хвостов: 5% квантиль (эмпирический): -0.026578 5% квантиль (теоретический): -0.042040 95% квантиль (эмпирический): 0.030146 95% квантиль (теоретический): 0.043417 Рис. 1: Анализ распределения финансовых доходностей. Верхняя левая панель показывает временной ряд с видимыми экстремальными выбросами. Верхняя правая демонстрирует гистограмму с наложением теоретической нормальной кривой: эмпирическое распределение имеет более острый пик и толстые хвосты. Q-Q график подтверждает систематическое отклонение от нормальности в хвостах. Нижняя правая панель показывает, что эмпирические квантили в обоих хвостах лежат дальше от центра, чем предсказывает нормальная модель Представленный выше код генерирует синтетические данные, имитирующие характеристики реальных финансовых доходностей. Смесь нормального распределения (90% данных) и t-распределения с малыми степенями свободы (10% данных) создает распределение с толстыми хвостами и избыточным эксцессом. Такой подход отражает феномен, наблюдаемый на финансовых рынках: большую часть времени доходности ведут себя относительно предсказуемо, но периодически происходят резкие скачки. Тест Харке-Бера проверяет гипотезу о нормальности на основе асимметрии и эксцесса. Низкое p-value (обычно < 0.05) указывает на отклонение от нормального распределения. Тест Колмогорова-Смирнова сравнивает эмпирическую функцию распределения с теоретической, измеряя максимальное расстояние между ними. Q-Q график (quantile-quantile plot) визуализирует соответствие квантилей данных теоретическим квантилям нормального распределения. Если точки лежат на прямой линии, распределение близко к нормальному. Отклонения в хвостах (концах графика) указывают на избыточную частоту экстремальных событий. Для финансовых данных типично наблюдать S-образную форму: левый хвост выше линии (больше экстремальных падений), правый хвост также выше (больше резких ростов). Альтернативные распределения для финансов Распределения с тяжелыми хвостами Распределение Стьюдента (t-распределение) добавляет параметр степеней свободы ν, контролирующий толщину хвостов. При ν → ∞ распределение сходится к нормальному, при малых ν (3-7) хвосты существенно тяжелее. Четвертый момент (эксцесс) определяется как 3 + 6/(ν-4) для ν > 4, что дает значения 6-9 при типичных для финансов ν = 5-10. T-распределение используется в моделировании доходностей на коротких временных интервалах (дневные, недельные данные), где влияние редких событий максимально. Модель позволяет корректно оценить вероятность движений в 3-5 стандартных отклонений, которые в нормальной модели считались бы практически невозможными. from scipy.stats import t as student_t, norm, gennorm # Генерация данных из различных распределений np.random.seed(42) n = 5000 # Нормальное распределение normal_data = np.random.normal(0, 1, n) # t-распределение с разными степенями свободы t3_data = student_t.rvs(df=3, loc=0, scale=1, size=n) t5_data = student_t.rvs(df=5, loc=0, scale=1, size=n) t10_data = student_t.rvs(df=10, loc=0, scale=1, size=n) # GED с разными параметрами формы ged_beta1 = gennorm.rvs(beta=1, loc=0, scale=1, size=n) # Laplace ged_beta15 = gennorm.rvs(beta=1.5, loc=0, scale=1, size=n) # Визуализация fig, axes = plt.subplots(2, 2, figsize=(14, 10)) # График 1: Сравнение t-распределений с нормальным x = np.linspace(-5, 5, 500) axes[0, 0].plot(x, norm.pdf(x, 0, 1), 'k-', linewidth=2, label='Normal') axes[0, 0].plot(x, student_t.pdf(x, df=3, loc=0, scale=1), 'r-', linewidth=2, label='t (ν=3)') axes[0, 0].plot(x, student_t.pdf(x, df=5, loc=0, scale=1), 'b-', linewidth=2, label='t (ν=5)') axes[0, 0].plot(x, student_t.pdf(x, df=10, loc=0, scale=1), 'g-', linewidth=2, label='t (ν=10)') axes[0, 0].set_title('t-распределение: влияние степеней свободы', fontsize=11, fontweight='bold') axes[0, 0].set_xlabel('Значение') axes[0, 0].set_ylabel('Плотность вероятности') axes[0, 0].legend() axes[0, 0].grid(alpha=0.3) axes[0, 0].set_ylim(0, 0.45) # График 2: Фокус на хвостах (логарифмическая шкала) axes[0, 1].semilogy(x, norm.pdf(x, 0, 1), 'k-', linewidth=2, label='Normal') axes[0, 1].semilogy(x, student_t.pdf(x, df=3, loc=0, scale=1), 'r-', linewidth=2, label='t (ν=3)') axes[0, 1].semilogy(x, student_t.pdf(x, df=5, loc=0, scale=1), 'b-', linewidth=2, label='t (ν=5)') axes[0, 1].set_title('Сравнение хвостов (лог-шкала)', fontsize=11, fontweight='bold') axes[0, 1].set_xlabel('Значение') axes[0, 1].set_ylabel('Плотность вероятности (log)') axes[0, 1].legend() axes[0, 1].grid(alpha=0.3) axes[0, 1].set_xlim(2, 5) # График 3: GED с различными параметрами формы axes[1, 0].plot(x, norm.pdf(x, 0, 1), 'k-', linewidth=2, label='Normal (β=2)') axes[1, 0].plot(x, gennorm.pdf(x, beta=1, loc=0, scale=1), 'r-', linewidth=2, label='Laplace (β=1)') axes[1, 0].plot(x, gennorm.pdf(x, beta=1.5, loc=0, scale=1), 'b-', linewidth=2, label='GED (β=1.5)') axes[1, 0].plot(x, gennorm.pdf(x, beta=3, loc=0, scale=1), 'g-', linewidth=2, label='GED (β=3)') axes[1, 0].set_title('GED: влияние параметра формы β', fontsize=11, fontweight='bold') axes[1, 0].set_xlabel('Значение') axes[1, 0].set_ylabel('Плотность вероятности') axes[1, 0].legend() axes[1, 0].grid(alpha=0.3) axes[1, 0].set_ylim(0, 0.6) # График 4: Сравнение эксцесса datasets = { 'Normal': normal_data, 't (ν=3)': t3_data, 't (ν=5)': t5_data, 't (ν=10)': t10_data, 'GED (β=1)': ged_beta1, 'GED (β=1.5)': ged_beta15 } kurtosis_values = [stats.kurtosis(data, fisher=True) for data in datasets.values()] names = list(datasets.keys()) axes[1, 1].bar(range(len(names)), kurtosis_values, color=['black', 'red', 'blue', 'green', 'orange', 'purple']) axes[1, 1].set_xticks(range(len(names))) axes[1, 1].set_xticklabels(names, rotation=45, ha='right') axes[1, 1].set_title('Эксцесс различных распределений', fontsize=11, fontweight='bold') axes[1, 1].set_ylabel('Excess Kurtosis') axes[1, 1].axhline(y=0, color='gray', linestyle='--', linewidth=1) axes[1, 1].grid(alpha=0.3, axis='y') plt.tight_layout() plt.show() # Численное сравнение вероятностей в хвостах print("\nВероятность события |X| > 3σ:") print(f"Normal: {2 * (1 - norm.cdf(3)):.6f}") print(f"t (ν=3): {2 * (1 - student_t.cdf(3, df=3)):.6f}") print(f"t (ν=5): {2 * (1 - student_t.cdf(3, df=5)):.6f}") print(f"t (ν=10): {2 * (1 - student_t.cdf(3, df=10)):.6f}") print("\nВероятность события |X| > 4σ:") print(f"Normal: {2 * (1 - norm.cdf(4)):.8f}") print(f"t (ν=3): {2 * (1 - student_t.cdf(4, df=3)):.8f}") print(f"t (ν=5): {2 * (1 - student_t.cdf(4, df=5)):.8f}") Рис. 2: Сравнение распределений с тяжелыми хвостами. Верхняя левая панель показывает, как уменьшение степеней свободы ν делает t-распределение более островершинным с толстыми хвостами. Верхняя правая панель в логарифмической шкале демонстрирует, что хвосты t-распределения убывают медленнее нормального. Нижняя левая показывает семейство GED: меньшие значения β создают более острый пик и тяжелые хвосты. Нижняя правая панель количественно сравнивает эксцесс: t-распределение с малыми ν имеет существенно больший эксцесс, чем нормальное Вероятность события |X| > 3σ: Normal: 0.002700 t (ν=3): 0.057669 t (ν=5): 0.030099 t (ν=10): 0.013344 Вероятность события |X| > 4σ: Normal: 0.00006334 t (ν=3): 0.02800846 t (ν=5): 0.01032342 Визуализация демонстрирует ключевые различия между распределениями: При малых степенях свободы t-распределение имеет более острый пик в центре и существенно более толстые хвосты; График в логарифмической шкале показывает, что при удалении от центра (|x| > 3) вероятности t-распределения убывают медленнее, чем у нормального. Это важный момент для финансового анализа: событие в 4 стандартных отклонения в нормальной модели имеет вероятность 0.006%, в t-распределении с ν=5 — около 0.08%, то есть в 13 раз выше; Обобщенное распределение ошибок (Generalized Error Distribution, GED) вводит параметр формы β, регулирующий как хвосты, так и пик распределения. При β = 2 получается нормальное распределение, β < 2 дает более тяжелые хвосты, β > 2 — более легкие; Распределение Лапласа соответствует β = 1 и демонстрирует острый пик с экспоненциальными хвостами. GED активно применяется в GARCH-моделях для описания условного распределения остатков. Эмпирически для финансовых данных оптимальное значение β лежит в диапазоне 1.2-1.8. Асимметричные распределения Логнормальное распределение возникает естественным образом, когда логарифм случайной величины имеет нормальное распределение. Если цена актива следует геометрическому броуновскому движению с постоянными параметрами, ее распределение в любой момент времени логнормально. Распределение ограничено снизу нулем и имеет правостороннюю асимметрию. Плотность вероятности логнормального распределения для переменной X рассчитывается по формуле: f(x) = (1 / (x σ √(2π))) exp(-(ln(x) - μ)² / (2σ²)) где: x > 0 — значение случайной величины; μ — математическое ожидание логарифма X; σ — стандартное отклонение логарифма X. Среднее значение равно exp(μ + σ²/2). Медиана: exp(μ) Мода: exp(μ - σ²). Разница между средним и медианой отражает правостороннюю асимметрию: большинство значений сосредоточено ниже среднего, но редкие высокие значения сдвигают среднее вправо. from scipy.stats import lognorm, skewnorm # Параметры для различных распределений np.random.seed(42) n = 10000 # Логнормальное с разными параметрами mu1, sigma1 = 0, 0.3 mu2, sigma2 = 0, 0.6 lognorm1 = lognorm.rvs(s=sigma1, scale=np.exp(mu1), size=n) lognorm2 = lognorm.rvs(s=sigma2, scale=np.exp(mu2), size=n) # Скошенное нормальное (Skewed normal) распределение skewnorm_data_neg = skewnorm.rvs(a=-5, loc=0, scale=1, size=n) # левая асимметрия skewnorm_data_pos = skewnorm.rvs(a=5, loc=0, scale=1, size=n) # правая асимметрия # Визуализация fig, axes = plt.subplots(2, 2, figsize=(14, 10)) # График 1: Логнормальное распределение x_log = np.linspace(0.01, 5, 500) axes[0, 0].plot(x_log, lognorm.pdf(x_log, s=sigma1, scale=np.exp(mu1)), 'b-', linewidth=2, label=f'σ={sigma1}') axes[0, 0].plot(x_log, lognorm.pdf(x_log, s=sigma2, scale=np.exp(mu2)), 'r-', linewidth=2, label=f'σ={sigma2}') axes[0, 0].set_title('Логнормальное распределение', fontsize=11, fontweight='bold') axes[0, 0].set_xlabel('Значение') axes[0, 0].set_ylabel('Плотность вероятности') axes[0, 0].legend() axes[0, 0].grid(alpha=0.3) # График 2: Гистограмма логнормального axes[0, 1].hist(lognorm1, bins=100, density=True, alpha=0.6, color='blue', edgecolor='black', range=(0, 5)) axes[0, 1].set_title('Гистограмма: Логнормальное (σ=0.3)', fontsize=11, fontweight='bold') axes[0, 1].set_xlabel('Значение') axes[0, 1].set_ylabel('Плотность') axes[0, 1].axvline(np.mean(lognorm1), color='red', linestyle='--', linewidth=2, label='Среднее') axes[0, 1].axvline(np.median(lognorm1), color='green', linestyle='--', linewidth=2, label='Медиана') axes[0, 1].legend() axes[0, 1].grid(alpha=0.3) # График 3: Skewed normal распределение x_skew = np.linspace(-4, 4, 500) axes[1, 0].plot(x_skew, norm.pdf(x_skew, 0, 1), 'k-', linewidth=2, label='Normal') axes[1, 0].plot(x_skew, skewnorm.pdf(x_skew, a=-5, loc=0, scale=1), 'r-', linewidth=2, label='Skew α=-5 (левая)') axes[1, 0].plot(x_skew, skewnorm.pdf(x_skew, a=5, loc=0, scale=1), 'b-', linewidth=2, label='Skew α=5 (правая)') axes[1, 0].set_title('Skewed Normal распределение', fontsize=11, fontweight='bold') axes[1, 0].set_xlabel('Значение') axes[1, 0].set_ylabel('Плотность вероятности') axes[1, 0].legend() axes[1, 0].grid(alpha=0.3) # График 4: Сравнение асимметрии datasets_skew = { 'Normal': np.random.normal(0, 1, n), 'Lognormal\n(σ=0.3)': np.log(lognorm1[lognorm1 > 0]), # логарифм для центрирования 'Skew Normal\n(α=-5)': skewnorm_data_neg, 'Skew Normal\n(α=5)': skewnorm_data_pos } skewness_values = [stats.skew(data) for data in datasets_skew.values()] names_skew = list(datasets_skew.keys()) colors = ['black', 'blue', 'red', 'green'] axes[1, 1].bar(range(len(names_skew)), skewness_values, color=colors) axes[1, 1].set_xticks(range(len(names_skew))) axes[1, 1].set_xticklabels(names_skew) axes[1, 1].set_title('Асимметрия различных распределений', fontsize=11, fontweight='bold') axes[1, 1].set_ylabel('Skewness') axes[1, 1].axhline(y=0, color='gray', linestyle='--', linewidth=1) axes[1, 1].grid(alpha=0.3, axis='y') plt.tight_layout() plt.show() # Численные характеристики логнормального распределения print("Логнормальное распределение (σ=0.3):") print(f"Среднее: {np.mean(lognorm1):.4f}") print(f"Медиана: {np.median(lognorm1):.4f}") print(f"Мода (приблиз): {np.exp(mu1 - sigma1**2):.4f}") print(f"Асимметрия: {stats.skew(lognorm1):.4f}") print(f"Эксцесс: {stats.kurtosis(lognorm1, fisher=True):.4f}") print("\nSkewed Normal (α=-5, левая асимметрия):") print(f"Асимметрия: {stats.skew(skewnorm_data_neg):.4f}") print(f"Эксцесс: {stats.kurtosis(skewnorm_data_neg, fisher=True):.4f}") Рис. 3: Асимметричные распределения. Верхняя левая панель демонстрирует логнормальное распределение с разными параметрами волатильности: больший σ создает более выраженную правостороннюю асимметрию и длинный правый хвост. Верхняя правая показывает гистограмму логнормального распределения с явным разрывом между средним и медианой. Нижняя левая сравнивает скошенное нормальное распределение с различными параметрами асимметрии: отрицательный α создает толстый левый хвост, положительный — правый. Нижняя правая количественно показывает коэффициенты асимметрии для разных моделей Логнормальное распределение (σ=0.3): Среднее: 1.0457 Медиана: 0.9992 Мода (приблиз): 0.9139 Асимметрия: 0.9680 Эксцесс: 1.7289 Skewed Normal (α=-5, левая асимметрия): Асимметрия: -0.7959 Эксцесс: 0.5585 Логнормальное распределение применяется для моделирования: Цен активов (не доходностей); Стоимости опционов в модели Блэка-Шоулза; Размеров убытков в страховании. Ограничение положительными значениями делает его естественным выбором для величин, которые не могут быть отрицательными. График показывает, что с ростом параметра σ распределение становится более асимметричным, правый хвост удлиняется. Разница между средним и медианой увеличивается — большинство значений остаются близко к нулю, но среднее растет за счет редких высоких выбросов. Скошенное (ассиметричное) нормальное распределение расширяет нормальное, добавляя параметр асимметрии α: При α = 0 получается обычное нормальное распределение; Положительные значения α создают правостороннюю асимметрию; Отрицательные — левостороннюю. Для финансовых доходностей характерна отрицательная асимметрия: резкие падения происходят чаще и быстрее, чем резкие росты. Экстремальные распределения Теория экстремальных значений (Extreme Value Theory, EVT) фокусируется на моделировании хвостов распределения, игнорируя центральную часть. Обобщенное распределение экстремальных значений (Generalized Extreme Value, GEV) описывает распределение максимумов (или минимумов) в блоках данных фиксированного размера. Распределение содержит три параметра: расположение μ, масштаб σ и форма ξ. Параметр формы ξ определяет тип распределения: ξ = 0: распределение Гумбеля (экспоненциальные хвосты); ξ > 0: распределение Фреше (степенные хвосты, heavy-tailed); ξ < 0: распределение Вейбулла (ограниченные хвосты). Для финансовых данных обычно ξ > 0, что указывает на тяжелые хвосты. GEV применяется для оценки максимальных просадок портфеля за период, экстремальных дневных убытков, стресс-тестирования. from scipy.stats import genextreme, genpareto # Генерация экстремальных данных np.random.seed(42) n_blocks = 500 block_size = 20 # Симуляция доходностей с экстремальными событиями base_returns = np.random.normal(0, 0.01, n_blocks * block_size) extreme_events = np.random.choice(len(base_returns), size=50, replace=False) base_returns[extreme_events] += np.random.normal(-0.05, 0.02, 50) # Извлечение блочных минимумов для GEV block_minima = [] for i in range(n_blocks): block = base_returns[i * block_size:(i + 1) * block_size] block_minima.append(np.min(block)) block_minima = np.array(block_minima) # Peaks-over-threshold для GPD threshold = np.percentile(base_returns, 5) # 5% нижний квантиль exceedances = threshold - base_returns[base_returns < threshold] # Подгонка GEV gev_params = genextreme.fit(block_minima) print("GEV параметры (block minima):") print(f"Shape (ξ): {gev_params[0]:.4f}") print(f"Location (μ): {gev_params[1]:.4f}") print(f"Scale (σ): {gev_params[2]:.4f}") # Подгонка GPD gpd_params = genpareto.fit(exceedances) print(f"\nGPD параметры (exceedances):") print(f"Shape (ξ): {gpd_params[0]:.4f}") print(f"Location: {gpd_params[1]:.4f}") print(f"Scale (σ): {gpd_params[2]:.4f}") # Визуализация fig, axes = plt.subplots(2, 3, figsize=(16, 10)) # График 1: Временной ряд с порогом axes[0, 0].plot(base_returns, color='black', linewidth=0.5, alpha=0.7) axes[0, 0].axhline(y=threshold, color='red', linestyle='--', linewidth=2, label=f'Threshold (5%)') axes[0, 0].scatter(np.where(base_returns < threshold)[0], base_returns[base_returns < threshold], color='red', s=10, alpha=0.6, label='Exceedances') axes[0, 0].set_title('Временной ряд с порогом POT', fontsize=11, fontweight='bold') axes[0, 0].set_xlabel('Наблюдение') axes[0, 0].set_ylabel('Доходность') axes[0, 0].legend() axes[0, 0].grid(alpha=0.3) # График 2: Распределение блочных минимумов axes[0, 1].hist(block_minima, bins=40, density=True, alpha=0.6, color='gray', edgecolor='black', label='Данные') x_gev = np.linspace(block_minima.min(), block_minima.max(), 200) axes[0, 1].plot(x_gev, genextreme.pdf(x_gev, *gev_params), 'r-', linewidth=2, label='GEV fit') axes[0, 1].set_title('GEV: блочные минимумы', fontsize=11, fontweight='bold') axes[0, 1].set_xlabel('Минимальная доходность в блоке') axes[0, 1].set_ylabel('Плотность') axes[0, 1].legend() axes[0, 1].grid(alpha=0.3) # График 3: Распределение превышений (GPD) axes[0, 2].hist(exceedances, bins=40, density=True, alpha=0.6, color='gray', edgecolor='black', label='Данные') x_gpd = np.linspace(0, exceedances.max(), 200) axes[0, 2].plot(x_gpd, genpareto.pdf(x_gpd, *gpd_params), 'b-', linewidth=2, label='GPD fit') axes[0, 2].set_title('GPD: превышения порога', fontsize=11, fontweight='bold') axes[0, 2].set_xlabel('Величина превышения') axes[0, 2].set_ylabel('Плотность') axes[0, 2].legend() axes[0, 2].grid(alpha=0.3) # График 4: Q-Q plot для GEV theoretical_quantiles_gev = genextreme.ppf(np.linspace(0.01, 0.99, len(block_minima)), *gev_params) empirical_quantiles_gev = np.sort(block_minima) axes[1, 0].scatter(theoretical_quantiles_gev, empirical_quantiles_gev, alpha=0.6, color='red') axes[1, 0].plot([block_minima.min(), block_minima.max()], [block_minima.min(), block_minima.max()], 'k--', linewidth=1) axes[1, 0].set_title('Q-Q plot: GEV', fontsize=11, fontweight='bold') axes[1, 0].set_xlabel('Теоретические квантили') axes[1, 0].set_ylabel('Эмпирические квантили') axes[1, 0].grid(alpha=0.3) # График 5: Q-Q plot для GPD theoretical_quantiles_gpd = genpareto.ppf(np.linspace(0.01, 0.99, len(exceedances)), *gpd_params) empirical_quantiles_gpd = np.sort(exceedances) axes[1, 1].scatter(theoretical_quantiles_gpd, empirical_quantiles_gpd, alpha=0.6, color='blue') axes[1, 1].plot([0, exceedances.max()], [0, exceedances.max()], 'k--', linewidth=1) axes[1, 1].set_title('Q-Q plot: GPD', fontsize=11, fontweight='bold') axes[1, 1].set_xlabel('Теоретические квантили') axes[1, 1].set_ylabel('Эмпирические квантили') axes[1, 1].grid(alpha=0.3) # График 6: Сравнение GEV с разными параметрами формы x_comparison = np.linspace(-0.15, 0.02, 300) axes[1, 2].plot(x_comparison, genextreme.pdf(x_comparison, 0, loc=-0.03, scale=0.02), 'g-', linewidth=2, label='Gumbel (ξ=0)') axes[1, 2].plot(x_comparison, genextreme.pdf(x_comparison, 0.2, loc=-0.03, scale=0.02), 'r-', linewidth=2, label='Frechet (ξ=0.2)') axes[1, 2].plot(x_comparison, genextreme.pdf(x_comparison, -0.2, loc=-0.03, scale=0.02), 'b-', linewidth=2, label='Weibull (ξ=-0.2)') axes[1, 2].set_title('Типы GEV распределений', fontsize=11, fontweight='bold') axes[1, 2].set_xlabel('Значение') axes[1, 2].set_ylabel('Плотность вероятности') axes[1, 2].legend() axes[1, 2].grid(alpha=0.3) plt.tight_layout() plt.show() # Оценка экстремальных квантилей print("\nОценка экстремальных квантилей:") for p in [0.01, 0.005, 0.001]: gev_quantile = genextreme.ppf(p, *gev_params) print(f"GEV {p*100:.1f}% квантиль: {gev_quantile:.6f}") print("\nВероятность превышения через GPD:") for threshold_level in [0.05, 0.07, 0.10]: prob = 1 - genpareto.cdf(threshold_level, *gpd_params) print(f"P(превышение > {threshold_level:.2f}): {prob:.6f}") Рис. 4: Экстремальные распределения. Верхняя левая панель показывает временной ряд доходностей с красной пунктирной линией порога POT и отмеченными превышениями. Верхняя средняя демонстрирует подгонку GEV к блочным минимумам. Верхняя правая показывает GPD для превышений порога. Нижние панели содержат Q-Q графики для проверки качества подгонки обеих моделей и сравнение трех типов GEV: Gumbel (экспоненциальный хвост), Frechet (тяжелый хвост) и Weibull (ограниченный хвост) GEV параметры (block minima): Shape (ξ): 0.7281 Location (μ): -0.0239 Scale (σ): 0.0120 GPD параметры (exceedances): Shape (ξ): 0.4832 Location: 0.0000 Scale (σ): 0.0039 Оценка экстремальных квантилей: GEV 1.0% квантиль: -0.057541 GEV 0.5% квантиль: -0.062932 GEV 0.1% квантиль: -0.074766 Вероятность превышения через GPD: P(превышение > 0.05): 0.017139 P(превышение > 0.07): 0.009296 P(превышение > 0.10): 0.004746 Код демонстрирует два основных подхода EVT: Block maxima (GEV) или Метод блочных максимумов. Он разбивает данные на блоки фиксированного размера и извлекает минимум (или максимум) из каждого блока. Размер блока выбирается исходя из природы данных: для дневных доходностей типичен блок в 20-60 дней. Peaks-over-threshold (GPD). Метод GPD анализирует все наблюдения, превышающие заданный порог, что использует больше информации из хвоста. Как правило для него подбирается порог таким образом, чтобы осталось 5-10% наблюдений. Для этого используют специальные графики: Mean excess plot и Parameter stability plot. Q-Q графики показывают качество подгонки модели к экстремальным данным. Хорошее соответствие точек диагональной линии указывает, что модель корректно описывает хвост распределения. Отклонения в крайних квантилях (самые экстремальные события) ожидаемы из-за малого числа наблюдений, однако систематические паттерны указывают на необходимость пересмотра модели или порога. Обобщенное распределение Парето используется для расчета Value at Risk и Conditional Value at Risk на экстремальных квантилях (99%, 99.5%) там, где эмпирических данных недостаточно. Экстраполяция хвоста через параметрическую модель GPD дает более стабильные оценки, чем исторический метод. Положительное значение параметра формы ξ подтверждает наличие тяжелых хвостов: вероятность экстремальных событий убывает степенным образом, а не экспоненциально. Выбор и оценка распределений Выбор распределения базируется на трех критериях: Соответствие эмпирическим данным; Теоретическая обоснованность; Вычислительная простота. Визуальный анализ (гистограммы, Q-Q графики) дает первичное понимание, но требует формального статистического подтверждения. Тест Колмогорова-Смирнова сравнивает эмпирическую функцию распределения Fn(x) с теоретической F(x): D = sup|Fn(x) - F(x)| где: sup — супремум (максимум) разности по всем x; Fn(x) — доля наблюдений, не превышающих x; F(x) — теоретическая вероятность P(X ≤ x). Статистика D измеряет максимальное вертикальное расстояние между двумя функциями. Малые значения D (и высокое p-value) указывают на хорошее соответствие. Тест чувствителен к отклонениям в центре распределения, но менее чувствителен к хвостам. Тест Андерсона-Дарлинга модифицирует Колмогорова-Смирнова, давая больший вес хвостам: A² = n ∫[(Fn(x) - F(x))² / (F(x)(1 - F(x)))] dF(x) где: n — размер выборки; весовая функция 1/(F(x)(1-F(x))) увеличивает значимость хвостов. Для финансового анализа тест Андерсона-Дарлинга предпочтительнее, так как именно хвосты определяют риски крупных убытков. Информационные критерии Akaike (AIC) и Bayesian (BIC) позволяют сравнивать модели с разным числом параметров: AIC = 2k - 2ln(L) BIC = k ln(n) - 2ln(L) где: k — число параметров модели; L — максимальное значение функции правдоподобия; n — размер выборки. Меньшее значение AIC или BIC указывает на лучшую модель. BIC сильнее штрафует сложность модели (коэффициент ln(n) vs 2 в AIC), предпочитая более простые распределения. Для финансовых данных разница в AIC более 10 считается существенной, указывая на явное преимущество одной модели над другой. import pandas as pd from scipy.optimize import minimize # Генерация тестовых данных с известными свойствами np.random.seed(42) n = 3000 # Данные с характеристиками финансовых доходностей true_data = student_t.rvs(df=5, loc=0.0003, scale=0.018, size=n) # Функции для подбора параметров различных распределений def fit_student_t(data): def neg_log_likelihood(params): df, loc, scale = params if df <= 2 or scale <= 0: return 1e10 return -np.sum(student_t.logpdf(data, df, loc, scale)) init_params = [5, np.mean(data), np.std(data)] bounds = [(2.01, 50), (None, None), (1e-6, None)] result = minimize(neg_log_likelihood, init_params, bounds=bounds, method='L-BFGS-B') return result.x, -result.fun def fit_ged(data): def neg_log_likelihood(params): beta, loc, scale = params if beta <= 0 or scale <= 0: return 1e10 return -np.sum(gennorm.logpdf(data, beta, loc, scale)) init_params = [1.5, np.mean(data), np.std(data)] bounds = [(0.1, 5), (None, None), (1e-6, None)] result = minimize(neg_log_likelihood, init_params, bounds=bounds, method='L-BFGS-B') return result.x, -result.fun def fit_skewed_t(data): """Упрощенная версия: skewed normal как приближение""" def neg_log_likelihood(params): a, loc, scale = params if scale <= 0: return 1e10 return -np.sum(skewnorm.logpdf(data, a, loc, scale)) init_params = [0, np.mean(data), np.std(data)] bounds = [(-10, 10), (None, None), (1e-6, None)] result = minimize(neg_log_likelihood, init_params, bounds=bounds, method='L-BFGS-B') return result.x, -result.fun # Подгонка различных моделей params_norm = (np.mean(true_data), np.std(true_data)) log_lik_norm = np.sum(norm.logpdf(true_data, *params_norm)) params_t, log_lik_t = fit_student_t(true_data) params_ged, log_lik_ged = fit_ged(true_data) params_skew, log_lik_skew = fit_skewed_t(true_data) # Расчет информационных критериев def calculate_ic(log_lik, k, n): aic = 2 * k - 2 * log_lik bic = k * np.log(n) - 2 * log_lik return aic, bic n_obs = len(true_data) aic_norm, bic_norm = calculate_ic(log_lik_norm, 2, n_obs) aic_t, bic_t = calculate_ic(log_lik_t, 3, n_obs) aic_ged, bic_ged = calculate_ic(log_lik_ged, 3, n_obs) aic_skew, bic_skew = calculate_ic(log_lik_skew, 3, n_obs) # Статистические тесты ks_norm_stat, ks_norm_p = stats.kstest(true_data, 'norm', args=params_norm) ks_t_stat, ks_t_p = stats.kstest(true_data, lambda x: student_t.cdf(x, *params_t)) # Тест Андерсона-Дарлинга (только для нормального) ad_result = stats.anderson(true_data, dist='norm') # Результаты в таблице results_df = pd.DataFrame({ 'Модель': ['Normal', 'Student-t', 'GED', 'Skewed-N'], 'Параметры': [2, 3, 3, 3], 'Log-Lik': [log_lik_norm, log_lik_t, log_lik_ged, log_lik_skew], 'AIC': [aic_norm, aic_t, aic_ged, aic_skew], 'BIC': [bic_norm, bic_t, bic_ged, bic_skew], 'Δ AIC': [ aic_norm - aic_t, 0, aic_ged - aic_t, aic_skew - aic_t ] }) print("Сравнение моделей распределений:") print(results_df.to_string(index=False)) print(f"\nТест Колмогорова-Смирнова:") print(f"Normal: D={ks_norm_stat:.4f}, p-value={ks_norm_p:.6f}") print(f"Student-t: D={ks_t_stat:.4f}, p-value={ks_t_p:.6f}") print(f"\nТест Андерсона-Дарлинга (Normal):") print(f"Статистика: {ad_result.statistic:.4f}") print(f"Критические значения: {ad_result.critical_values}") print(f"Уровни значимости: {ad_result.significance_level}") # Подобранные параметры print(f"\nПодобранные параметры:") print(f"Student-t: ν={params_t[0]:.2f}, μ={params_t[1]:.6f}, σ={params_t[2]:.6f}") print(f"GED: β={params_ged[0]:.2f}, μ={params_ged[1]:.6f}, σ={params_ged[2]:.6f}") # Визуализация fig, axes = plt.subplots(2, 2, figsize=(14, 10)) # График 1: Наложение всех распределений x_range = np.linspace(true_data.min(), true_data.max(), 300) axes[0, 0].hist(true_data, bins=60, density=True, alpha=0.3, color='gray', edgecolor='black', label='Данные') axes[0, 0].plot(x_range, norm.pdf(x_range, *params_norm), 'r-', linewidth=2, label='Normal') axes[0, 0].plot(x_range, student_t.pdf(x_range, *params_t), 'b-', linewidth=2, label='Student-t') axes[0, 0].plot(x_range, gennorm.pdf(x_range, *params_ged), 'g-', linewidth=2, label='GED') axes[0, 0].plot(x_range, skewnorm.pdf(x_range, *params_skew), 'm-', linewidth=2, label='Skewed-N') axes[0, 0].set_title('Сравнение подогнанных моделей', fontsize=11, fontweight='bold') axes[0, 0].set_xlabel('Доходность') axes[0, 0].set_ylabel('Плотность') axes[0, 0].legend() axes[0, 0].grid(alpha=0.3) # График 2: Информационные критерии models = ['Normal', 'Student-t', 'GED', 'Skewed-N'] aic_values = [aic_norm, aic_t, aic_ged, aic_skew] bic_values = [bic_norm, bic_t, bic_ged, bic_skew] x_pos = np.arange(len(models)) width = 0.35 axes[0, 1].bar(x_pos - width/2, aic_values, width, label='AIC', color='steelblue') axes[0, 1].bar(x_pos + width/2, bic_values, width, label='BIC', color='darkorange') axes[0, 1].set_xticks(x_pos) axes[0, 1].set_xticklabels(models) axes[0, 1].set_title('Информационные критерии', fontsize=11, fontweight='bold') axes[0, 1].set_ylabel('Значение критерия') axes[0, 1].legend() axes[0, 1].grid(alpha=0.3, axis='y') # График 3: Фокус на левом хвосте left_quantile = np.percentile(true_data, 10) mask_left = x_range < left_quantile axes[1, 0].hist(true_data[true_data < left_quantile], bins=30, density=True, alpha=0.3, color='gray', edgecolor='black', label='Данные') axes[1, 0].plot(x_range[mask_left], norm.pdf(x_range[mask_left], *params_norm), 'r-', linewidth=2, label='Normal') axes[1, 0].plot(x_range[mask_left], student_t.pdf(x_range[mask_left], *params_t), 'b-', linewidth=2, label='Student-t') axes[1, 0].plot(x_range[mask_left], gennorm.pdf(x_range[mask_left], *params_ged), 'g-', linewidth=2, label='GED') axes[1, 0].set_title('Левый хвост (10% квантиль)', fontsize=11, fontweight='bold') axes[1, 0].set_xlabel('Доходность') axes[1, 0].set_ylabel('Плотность') axes[1, 0].legend() axes[1, 0].grid(alpha=0.3) # График 4: P-P plot для лучшей модели (Student-t) theoretical_probs = student_t.cdf(np.sort(true_data), *params_t) empirical_probs = np.arange(1, len(true_data) + 1) / len(true_data) axes[1, 1].scatter(theoretical_probs, empirical_probs, alpha=0.5, s=10, color='blue') axes[1, 1].plot([0, 1], [0, 1], 'k--', linewidth=1) axes[1, 1].set_title('P-P plot: Student-t', fontsize=11, fontweight='bold') axes[1, 1].set_xlabel('Теоретические вероятности') axes[1, 1].set_ylabel('Эмпирические вероятности') axes[1, 1].grid(alpha=0.3) plt.tight_layout() plt.show() Рис. 5: Сравнение и оценка распределений. Верхняя левая панель показывает наложение четырех подогнанных моделей на гистограмму данных: Student-t и GED лучше описывают острый пик и хвосты. Верхняя правая демонстрирует значения AIC и BIC: меньшие значения указывают на лучшую модель. Нижняя левая фокусируется на левом хвосте, где различия между моделями наиболее критичны для оценки рисков. P-P график показывает качество подгонки Student-t: точки близки к диагонали, что подтверждает хорошее соответствие модели данным Сравнение моделей распределений: Модель Параметры Log-Lik AIC BIC Δ AIC Normal 2 6982.703753 -13961.407506 -13949.394771 335.788231 Student-t 3 7151.597868 -14297.195737 -14279.176634 0.000000 GED 3 7121.032526 -14236.065053 -14218.045950 61.130684 Skewed-N 3 6982.703753 -13959.407506 -13941.388403 337.788231 Тест Колмогорова-Смирнова: Normal: D=0.0436, p-value=0.000022 Student-t: D=0.0106, p-value=0.888370 Тест Андерсона-Дарлинга (Normal): Статистика: 13.7753 Критические значения: [0.575 0.655 0.786 0.917 1.091] Уровни значимости: [15. 10. 5. 2.5 1. ] Подобранные параметры: Student-t: ν=5.00, μ=-0.000634, σ=0.018140 GED: β=1.25, μ=-0.000629, σ=0.022457 Давайте разберем что вычисляет этот код: Метод максимального правдоподобия (Maximum Likelihood Estimation, MLE) оптимизирует параметры распределения так, чтобы максимизировать вероятность наблюдения данных. Логарифм правдоподобия упрощает вычисления, превращая произведение вероятностей в сумму; Оптимизация методом L-BFGS-B минимизирует отрицательный логарифм правдоподобия с учетом ограничений на параметры: степени свободы t-распределения должны быть больше 2, параметр масштаба положителен; Сравнение AIC показывает относительное качество моделей. Разница Δ AIC интерпретируется следующим образом: 0-2 (модели эквивалентны), 4-7 (существенная разница), >10 (модель с большим AIC практически не имеет поддержки); Если Student-t дает AIC на 50 единиц ниже нормального распределения, оно явно предпочтительнее. BIC может выбрать более простую модель при больших выборках, так как сильнее штрафует дополнительные параметры. P-P график (probability-probability plot) сравнивает теоретические и эмпирические вероятности. В отличие от Q-Q графика, который сравнивает квантили, P-P график лучше подходит для оценки общего соответствия распределения. Точки должны лежать близко к диагональной линии. Систематические отклонения указывают на несоответствие модели: S-образная кривая означает различие в дисперсии, вогнутость/выпуклость — различие в асимметрии. Практическое применение Выбор распределения определяет результаты финансового анализа на всех этапах: от оценки рисков до ценообразования деривативов. Неадекватная модель распределения приводит к систематическим ошибкам в расчете метрик риска и недооценке вероятности крупных убытков. Value at Risk (VaR) определяет максимальный убыток портфеля на заданном доверительном уровне за период. VaR на уровне 95% отвечает на вопрос: какой убыток не будет превышен в 95% случаев. Conditional Value at Risk (CVaR), также известный как Expected Shortfall, измеряет средний убыток в хвосте распределения — при условии, что VaR был превышен. Для нормального распределения VaR вычисляется через квантиль: VaR_α = μ + σ · Φ^(-1)(α) где: α — уровень доверия (например, 0.05 для 5% VaR); Φ^(-1) — обратная функция стандартного нормального распределения; μ — ожидаемая доходность; σ — волатильность. Для t-распределения и других альтернативных моделей используются соответствующие обратные функции распределения. При этом важно помнить, что различия в VaR между нормальной и t-моделью могут достигать 30% на экстремальных квантилях (1%, 0.5%). # Генерация портфельных доходностей с реалистичными характеристиками np.random.seed(42) n_days = 2500 portfolio_value = 1000000 # $1M портфель # Имитация доходностей: смесь режимов (спокойный + волатильный) regime_1 = np.random.normal(0.0004, 0.01, int(n_days * 0.85)) regime_2 = student_t.rvs(df=4, loc=-0.002, scale=0.03, size=int(n_days * 0.15)) returns = np.concatenate([regime_1, regime_2]) np.random.shuffle(returns) # Подгонка различных распределений params_norm = (np.mean(returns), np.std(returns)) def fit_t_dist(data): def neg_ll(params): df, loc, scale = params if df <= 2 or scale <= 0: return 1e10 return -np.sum(student_t.logpdf(data, df, loc, scale)) result = minimize(neg_ll, [5, np.mean(data), np.std(data)], bounds=[(2.01, 50), (None, None), (1e-6, None)], method='L-BFGS-B') return result.x params_t = fit_t_dist(returns) # Расчет VaR для различных уровней доверия confidence_levels = [0.90, 0.95, 0.99, 0.995] var_results = [] for conf in confidence_levels: alpha = 1 - conf # Исторический VaR var_hist = np.percentile(returns, alpha * 100) # Параметрический VaR (Normal) var_norm = norm.ppf(alpha, *params_norm) # Параметрический VaR (Student-t) var_t = student_t.ppf(alpha, *params_t) # CVaR (Expected Shortfall) tail_returns_hist = returns[returns <= var_hist] cvar_hist = np.mean(tail_returns_hist) if len(tail_returns_hist) > 0 else var_hist # CVaR для нормального (аналитический) cvar_norm = params_norm[0] - params_norm[1] * norm.pdf(norm.ppf(alpha)) / alpha var_results.append({ 'Confidence': f"{conf*100:.1f}%", 'VaR_Historical': var_hist * portfolio_value, 'VaR_Normal': var_norm * portfolio_value, 'VaR_Student_t': var_t * portfolio_value, 'CVaR_Historical': cvar_hist * portfolio_value, 'CVaR_Normal': cvar_norm * portfolio_value }) var_df = pd.DataFrame(var_results) print("Сравнение VaR и CVaR (долларовая стоимость):") print(var_df.to_string(index=False)) # Обратное тестирование (backtesting) # Проверка сколько раз фактические убытки превышали VaR print("\n\nОбратное тестирование VaR (95% уровень):") var_95_norm = norm.ppf(0.05, *params_norm) var_95_t = student_t.ppf(0.05, *params_t) var_95_hist = np.percentile(returns, 5) violations_norm = np.sum(returns < var_95_norm) violations_t = np.sum(returns < var_95_t) violations_hist = np.sum(returns < var_95_hist) expected_violations = len(returns) * 0.05 print(f"Ожидаемое число нарушений (5%): {expected_violations:.0f}") print(f"Normal VaR нарушений: {violations_norm} ({violations_norm/len(returns)*100:.2f}%)") print(f"Student-t VaR нарушений: {violations_t} ({violations_t/len(returns)*100:.2f}%)") print(f"Historical VaR нарушений: {violations_hist} ({violations_hist/len(returns)*100:.2f}%)") # Визуализация fig, axes = plt.subplots(2, 2, figsize=(14, 10)) # График 1: Временной ряд с VaR уровнями cumulative_returns = (1 + returns).cumprod() axes[0, 0].plot(cumulative_returns, color='black', linewidth=1, label='Портфель') axes[0, 0].set_title('Динамика портфеля', fontsize=11, fontweight='bold') axes[0, 0].set_xlabel('Торговый день') axes[0, 0].set_ylabel('Кумулятивная доходность') axes[0, 0].grid(alpha=0.3) axes[0, 0].legend() # График 2: Распределение с VaR линиями x_range = np.linspace(returns.min(), returns.max(), 300) axes[0, 1].hist(returns, bins=60, density=True, alpha=0.3, color='gray', edgecolor='black') axes[0, 1].plot(x_range, norm.pdf(x_range, *params_norm), 'r-', linewidth=2, label='Normal') axes[0, 1].plot(x_range, student_t.pdf(x_range, *params_t), 'b-', linewidth=2, label='Student-t') # VaR линии для 95% уровня axes[0, 1].axvline(var_95_norm, color='red', linestyle='--', linewidth=2, alpha=0.7, label='VaR 95% (N)') axes[0, 1].axvline(var_95_t, color='blue', linestyle='--', linewidth=2, alpha=0.7, label='VaR 95% (t)') axes[0, 1].set_title('Распределение с VaR уровнями', fontsize=11, fontweight='bold') axes[0, 1].set_xlabel('Доходность') axes[0, 1].set_ylabel('Плотность') axes[0, 1].legend() axes[0, 1].grid(alpha=0.3) # График 3: Сравнение VaR по уровням доверия conf_labels = ['90%', '95%', '99%', '99.5%'] var_hist_values = [-var_df['VaR_Historical'].iloc[i] for i in range(len(conf_labels))] var_norm_values = [-var_df['VaR_Normal'].iloc[i] for i in range(len(conf_labels))] var_t_values = [-var_df['VaR_Student_t'].iloc[i] for i in range(len(conf_labels))] x_pos = np.arange(len(conf_labels)) width = 0.25 axes[1, 0].bar(x_pos - width, var_hist_values, width, label='Historical', color='gray') axes[1, 0].bar(x_pos, var_norm_values, width, label='Normal', color='red') axes[1, 0].bar(x_pos + width, var_t_values, width, label='Student-t', color='blue') axes[1, 0].set_xticks(x_pos) axes[1, 0].set_xticklabels(conf_labels) axes[1, 0].set_title('VaR по уровням доверия (потери)', fontsize=11, fontweight='bold') axes[1, 0].set_ylabel('VaR, $') axes[1, 0].legend() axes[1, 0].grid(alpha=0.3, axis='y') # График 4: Разница между моделями (%) var_diff_norm_vs_t = [(var_df['VaR_Normal'].iloc[i] - var_df['VaR_Student_t'].iloc[i]) / abs(var_df['VaR_Student_t'].iloc[i]) * 100 for i in range(len(conf_labels))] axes[1, 1].plot(conf_labels, var_diff_norm_vs_t, marker='o', linewidth=2, markersize=8, color='darkred', label='(Normal - Student-t) / Student-t') axes[1, 1].axhline(y=0, color='gray', linestyle='--', linewidth=1) axes[1, 1].set_title('Недооценка риска Normal vs Student-t', fontsize=11, fontweight='bold') axes[1, 1].set_xlabel('Уровень доверия') axes[1, 1].set_ylabel('Разница, %') axes[1, 1].grid(alpha=0.3) axes[1, 1].legend() plt.tight_layout() plt.show() # Симуляция Монте-Карло для оценки портфельного риска print("\n\nСимуляция Монте-Карло (10,000 сценариев, 20 дней):") n_simulations = 10000 n_days_forecast = 20 # Симуляция с нормальным распределением sim_norm = np.random.normal(params_norm[0], params_norm[1], (n_simulations, n_days_forecast)) final_returns_norm = (1 + sim_norm).prod(axis=1) - 1 # Симуляция с t-распределением sim_t = student_t.rvs(df=params_t[0], loc=params_t[1], scale=params_t[2], size=(n_simulations, n_days_forecast)) final_returns_t = (1 + sim_t).prod(axis=1) - 1 # VaR на горизонте 20 дней var_20d_norm_95 = np.percentile(final_returns_norm, 5) * portfolio_value var_20d_t_95 = np.percentile(final_returns_t, 5) * portfolio_value print(f"20-дневный VaR (95%):") print(f" Normal: ${abs(var_20d_norm_95):,.0f}") print(f" Student-t: ${abs(var_20d_t_95):,.0f}") print(f" Разница: ${abs(var_20d_t_95 - var_20d_norm_95):,.0f} ({abs(var_20d_t_95 - var_20d_norm_95)/abs(var_20d_t_95)*100:.1f}%)") # Вероятность потери более 10% капитала prob_loss_10_norm = np.sum(final_returns_norm < -0.10) / n_simulations prob_loss_10_t = np.sum(final_returns_t < -0.10) / n_simulations print(f"\nВероятность потери >10% за 20 дней:") print(f" Normal: {prob_loss_10_norm*100:.2f}%") print(f" Student-t: {prob_loss_10_t*100:.2f}%") Рис. 6: Практическое применение распределений в оценке рисков. Верхняя левая панель показывает кумулятивную доходность портфеля с видимыми периодами просадок. Верхняя правая демонстрирует распределение доходностей с наложением VaR линий: Student-t модель дает более консервативную оценку риска. Нижняя левая сравнивает VaR трех методов на различных уровнях доверия: различия растут с увеличением уровня доверия. Нижняя правая показывает процентную недооценку риска нормальной моделью относительно Student-t: на уровне 99.5% разница достигает 20-30% Сравнение VaR и CVaR (долларовая стоимость): Confidence VaR_Historical VaR_Normal VaR_Student_t CVaR_Historical CVaR_Normal 90.0% -14761.067001 -22936.670666 -15435.393578 -31993.287823 -31518.092518 95.0% -21594.224242 -29521.882206 -23164.201023 -46466.808243 -37095.995844 99.0% -56891.666690 -41874.643882 -49322.216545 -92951.526038 -48016.933759 99.5% -83910.827163 -46396.743157 -66306.050098 -114796.447425 -52126.720209 Обратное тестирование VaR (95% уровень): Ожидаемое число нарушений (5%): 125 Normal VaR нарушений: 85 (3.40%) Student-t VaR нарушений: 118 (4.72%) Historical VaR нарушений: 125 (5.00%) Симуляция Монте-Карло (10,000 сценариев, 20 дней): 20-дневный VaR (95%): Normal: $121,414 Student-t: $115,872 Разница: $5,542 (4.8%) Вероятность потери >10% за 20 дней: Normal: 9.13% Student-t: 7.30% Сравнение VaR между моделями показывает ключевую разницу в оценке рисков: На уровне 99% доверия нормальная модель может недооценивать VaR на 20-30% по сравнению с t-распределением. Для портфеля в $1M это означает разницу в несколько десятков тысяч долларов в оценке маржинальных требований. Обратное тестирование (backtesting) проверяет адекватность модели VaR. Для 95% VaR ожидается, что фактические убытки превысят VaR примерно в 5% случаев. Если нарушений существенно больше (7-8%), модель недооценивает риски. Если существенно меньше (2-3%), модель слишком консервативна и требует избыточного капитала. Биномиальный тест формально проверяет, соответствует ли частота нарушений заявленному уровню доверия. CVaR (Expected Shortfall) измеряет средний убыток при условии превышения VaR, что дает более полную картину хвостового риска. Для нормального распределения CVaR имеет аналитическую формулу, для других распределений вычисляется численно или через симуляции. CVaR всегда больше VaR по абсолютной величине и предпочтительнее как мера риска: он когерентен и учитывает не только квантиль, но и форму хвоста за ним. Симуляции Монте-Карло позволяют оценить многопериодный риск портфеля, учитывая сложные зависимости и нелинейности. Генерация тысяч сценариев будущих доходностей из подобранного распределения создает эмпирическое распределение конечного капитала. По этому распределению вычисляются VaR, CVaR и вероятности различных событий. Ключевое преимущество данного подхода: он универсален и работает для портфелей с опционами, деривативами и нелинейными выплатами, где аналитические формулы недоступны. Выбор между историческим, параметрическим и симуляционным методом расчета VaR зависит от доступных данных и характеристик портфеля: Исторический метод не требует предположений о распределении, но зависит от репрезентативности выборки и не экстраполирует за пределы наблюдаемых данных; Параметрический метод эффективен при достаточной выборке для подгонки распределения и позволяет экстраполировать в хвосты; Симуляционный подход универсален, но вычислительно затратен и требует корректной спецификации модели генерации сценариев. Заключение Статистические распределения формируют единый базис, с помощью которого в финансовом анализе описывается неопределенность и риск. Выбор между нормальным, t-распределением, GED или экстремальными моделями определяет не только численные значения метрик риска, но и качество управленческих решений. Недооценка хвостовых рисков из-за неадекватной модели приводит к недостаточным капитальным резервам и уязвимости перед кризисами. Практическое применение требует баланса между точностью модели и ее сложностью: T-распределение с 3-4 параметрами часто достаточно для корректного описания финансовых доходностей, обеспечивая существенное улучшение над нормальной моделью без избыточного усложнения. Для анализа экстремальных событий EVT предоставляет теоретически обоснованный математический аппарат, фокусирующийся именно на хвостах — зоне максимального риска. Комбинация методов — параметрические модели для центральной части распределения, EVT для хвостов, обратное тестирование для валидации — создает устойчивую систему риск-менеджмента, адаптированную к реальности финансовых рынков с их асимметрией, кластеризацией волатильности и неожиданными скачками во временных рядах. ### Важность признаков полученных из ML-моделей (Feature Importance) Важность признаков (Feature Importance) - инструмент для понимания того, какие переменные вносят наибольший вклад в предсказания модели. Методы оценки важности признаков решают несколько практических задач: отбор релевантных предикторов, детекцию утечек данных, снижение размерности для ускорения инференса. Выбор метода зависит от типа модели, структуры данных и требований к интерпретируемости. Разные подходы к оценке важности дают разные результаты: Встроенные методы древовидных моделей быстры, но смещены к высококардинальным признакам; Метод Permutation importance универсален, но чувствителен к корреляциям; SHAP предоставляет теоретически обоснованные оценки, но требует больших вычислительных ресурсов. Понимание этих различий позволяет выбрать оптимальный подход для конкретной задачи. Методы оценки важности признаков Встроенные методы древовидных моделей Древовидные алгоритмы (Random Forest, XGBoost, LightGBM) вычисляют важность признаков в процессе обучения. Метод основан на измерении улучшения критерия разбиения (Gini impurity, Information gain) при использовании признака в узлах дерева. Чем чаще признак выбирается для разбиения и чем больше улучшение метрики, тем выше его важность. Random Forest использует среднее снижение Gini или энтропии по всем деревьям. XGBoost и LightGBM предлагают три варианта подсчета: weight - количество использований признака; gain - среднее улучшение метрики; cover - среднее количество наблюдений в разбиениях. Gain предпочтительнее для интерпретации, так как отражает реальный вклад в качество предсказаний. Основная проблема встроенных методов — смещенность (bias) к признакам с высокой кардинальностью. Переменные с большим количеством уникальных значений получают завышенные оценки важности, даже если их предсказательная способность низка. Категориальные признаки после one-hot encoding также искажают результаты. Для корректной оценки требуется сравнение нескольких методов или использование альтернативных подходов. Давайте рассмотрим это на следующем примере: import yfinance as yf import pandas as pd import numpy as np from sklearn.ensemble import RandomForestRegressor from xgboost import XGBRegressor from lightgbm import LGBMRegressor import matplotlib.pyplot as plt pd.set_option('display.expand_frame_repr', False) # Загрузка данных ticker = yf.Ticker("2330.TW") # TSMC data = ticker.history(period="2y", interval="1d") # Проверка на Multiindex if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) # Создание признаков df = pd.DataFrame() df['returns'] = data['Close'].pct_change() df['volume_change'] = data['Volume'].pct_change() df['high_low_spread'] = (data['High'] - data['Low']) / data['Close'] df['close_open_spread'] = (data['Close'] - data['Open']) / data['Open'] # Лаговые признаки for lag in [1, 2, 3, 5, 10]: df[f'returns_lag_{lag}'] = df['returns'].shift(lag) df[f'volume_lag_{lag}'] = df['volume_change'].shift(lag) # Rolling признаки df['volatility_5d'] = df['returns'].rolling(5).std() df['volatility_20d'] = df['returns'].rolling(20).std() df['volume_ma_ratio'] = data['Volume'] / data['Volume'].rolling(20).mean() # Таргет: доходность через 1 день df['target'] = df['returns'].shift(-1) # Очистка данных df = df.replace([np.inf, -np.inf], np.nan).dropna() # Разделение на признаки и таргет X = df.drop('target', axis=1) y = df['target'] # Обучение моделей rf_model = RandomForestRegressor(n_estimators=100, max_depth=8, random_state=42) xgb_model = XGBRegressor(n_estimators=100, max_depth=6, learning_rate=0.1, random_state=42) lgbm_model = LGBMRegressor(n_estimators=100, max_depth=6, learning_rate=0.1, random_state=42) rf_model.fit(X, y) xgb_model.fit(X, y) lgbm_model.fit(X, y) rf_model.fit(X, y) xgb_model.fit(X, y) lgbm_model.fit(X, y) # Извлечение важности признаков rf_importance = pd.DataFrame({ 'feature': X.columns, 'importance': rf_model.feature_importances_ }).sort_values('importance', ascending=False) xgb_importance = pd.DataFrame({ 'feature': X.columns, 'importance': xgb_model.feature_importances_ }).sort_values('importance', ascending=False) lgbm_importance = pd.DataFrame({ 'feature': X.columns, 'importance': lgbm_model.feature_importances_ }).sort_values('importance', ascending=False) # Визуализация топ-10 признаков для каждой модели fig, axes = plt.subplots(1, 3, figsize=(18, 6)) axes[0].barh(rf_importance.head(10)['feature'], rf_importance.head(10)['importance'], color='#2C3E50') axes[0].set_title('Random Forest Feature Importance') axes[0].invert_yaxis() axes[1].barh(xgb_importance.head(10)['feature'], xgb_importance.head(10)['importance'], color='#2C3E50') axes[1].set_title('XGBoost Feature Importance (Gain)') axes[1].invert_yaxis() axes[2].barh(lgbm_importance.head(10)['feature'], lgbm_importance.head(10)['importance'], color='#2C3E50') axes[2].set_title('LightGBM Feature Importance') axes[2].invert_yaxis() plt.tight_layout() plt.show() # Вывод топ-5 для сравнения print("Random Forest Top 5:") print(rf_importance.head()) print("\nXGBoost Top 5:") print(xgb_importance.head()) print("\nLightGBM Top 5:") print(lgbm_importance.head()) [LightGBM] [Info] Total Bins 2508 [LightGBM] [Info] Number of data points in the train set: 460, number of used features: 17 [LightGBM] [Info] Start training from score 0.002040 Random Forest Top 5: feature importance 13 volume_lag_10 0.138693 0 returns 0.119082 3 close_open_spread 0.111151 6 returns_lag_2 0.102413 8 returns_lag_3 0.058379 XGBoost Top 5: feature importance 13 volume_lag_10 0.134891 4 returns_lag_1 0.093672 6 returns_lag_2 0.079875 8 returns_lag_3 0.068672 11 volume_lag_5 0.063433 LightGBM Top 5: feature importance 3 close_open_spread 98 4 returns_lag_1 80 14 volatility_5d 77 7 volume_lag_2 69 8 returns_lag_3 61 Рис. 1: Визуализация важности признаков по 3 моделям: Random Forest, XGBoost, LightGBM Код загружает дневные котировки TSMC, создает набор технических признаков (лаги доходности и объема, волатильность, спреды) и обучает три модели. После обучения извлекаются встроенные оценки важности и визуализируются топ-10 признаков для каждого алгоритма. Результаты показывают различия между моделями. В целом, 2 из 3 моделей выбрали наиболее важным единый признак - volume_lag_10, что хорошо. Однако в остальном их рейтинги важности существенно отличаются. Это происходит потому, что: Random Forest старается отдавать предпочтение признакам с высокой вариативностью; XGBoost с параметром gain фокусируется на признаках с наибольшим улучшением метрики качества; LightGBM выдает совершенно другой рейтинг важности признаков, что может сигнализировать о том, что был выбран другой способ построения деревьев (leaf-wise vs level-wise). Совет: при работе с финансовыми данными предпочтительнее XGBoost или LightGBM с метрикой gain. Random Forest склонен переоценивать шумные признаки. Для финальных выводов об важности признаков стоит усреднить результаты нескольких моделей или применить дополнительные методы валидации. Метод Permutation Importance Метод Permutation importance измеряет снижение качества модели при случайном перемешивании значений признака. Метод работает следующим образом: Для каждого признака его значения перемешиваются; После чего модель делает предсказания на измененных данных; Затем вычисляется разница в метрике качества. Чем сильнее ухудшается метрика, тем важнее признак. Основное преимущество подхода — универсальность. Метод применим к любым моделям, включая нейросети и ансамбли. В отличие от встроенных методов, permutation importance не зависит от структуры алгоритма и показывает реальное влияние признака на предсказания. Метод учитывает взаимодействия между признаками, так как перемешивание одного признака может затронуть другие. Главная проблема — коррелированные признаки. Если два признака сильно коррелируют, перемешивание одного из них компенсируется другим, и оба получают заниженные оценки важности. Для обнаружения таких ситуаций используется кластеризация признаков по корреляции или анализ variance inflation factor (VIF). У Permutation importance есть еще одна проблема — вычислительная сложность для больших датасетов, так как требуется переобучение модели для каждого признака. Давайте рассмотрим как работает этот метод: from sklearn.inspection import permutation_importance from sklearn.model_selection import train_test_split from sklearn.metrics import mean_squared_error # Разделение данных X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, shuffle=False) # Обучение модели model = XGBRegressor(n_estimators=100, max_depth=6, learning_rate=0.1, random_state=42) model.fit(X_train, y_train) # Baseline метрика y_pred = model.predict(X_test) baseline_mse = mean_squared_error(y_test, y_pred) print(f"Baseline MSE: {baseline_mse:.6f}") # Вычисление permutation importance perm_importance = permutation_importance( model, X_test, y_test, n_repeats=10, # Количество перемешиваний для каждого признака random_state=42, scoring='neg_mean_squared_error' ) # Создание датафрейма с результатами perm_importance_df = pd.DataFrame({ 'feature': X.columns, 'importance_mean': perm_importance.importances_mean, 'importance_std': perm_importance.importances_std }).sort_values('importance_mean', ascending=False) # Визуализация с доверительными интервалами fig, ax = plt.subplots(figsize=(10, 8)) top_features = perm_importance_df.head(10) ax.barh(top_features['feature'], top_features['importance_mean'], xerr=top_features['importance_std'], color='#2C3E50', capsize=5) ax.set_xlabel('Decrease in MSE') ax.set_title('Permutation Importance (Top 10 Features)') ax.invert_yaxis() plt.tight_layout() plt.show() print("\nPermutation Importance Top 10:") print(perm_importance_df.head(10)) Permutation Importance Top 10: feature importance_mean importance_std 13 volume_lag_10 0.000076 0.000017 7 volume_lag_2 0.000010 0.000009 4 returns_lag_1 0.000006 0.000006 8 returns_lag_3 0.000005 0.000007 5 volume_lag_1 0.000005 0.000007 12 returns_lag_10 0.000005 0.000006 10 returns_lag_5 0.000003 0.000008 1 volume_change 0.000003 0.000010 11 volume_lag_5 0.000002 0.000006 0 returns 0.000001 0.000005 Рис. 2: Топ-10 признаков, отобранных методом Permutation Importance. Отрыв признака volume_lag_10 становится еще более выраженным, что подтверждает его важность Представленный выше код вычисляет permutation importance для модели XGBoost на тестовой выборке. Параметр n_repeats=10 означает, что каждый признак перемешивается 10 раз для получения стабильной оценки. Метод возвращает среднее снижение метрики и стандартное отклонение. Визуализация включает error bars, отражающие вариативность оценок. Интерпретация следующая: Признаки с высокой вариативностью могут быть нестабильными или взаимодействовать с другими переменными; Низкая вариативность указывает на устойчивое влияние признака. Сравнение с встроенной важностью XGBoost выявляет расхождения. Встроенный метод может переоценивать признаки, часто используемые в ранних разбиениях дерева, но не влияющие на финальные предсказания. Permutation importance фокусируется на реальном вкладе в качество модели на новых данных. Совет: используйте permutation importance для финального отбора признаков после обучения модели. Если признак имеет низкую permutation importance при высокой встроенной важности, это сигнал о переобучении или коллинеарности. Удаление таких признаков упрощает модель без потери качества. Метод SHAP (SHapley Additive exPlanations) Метод SHAP базируется на концепции значений Шепли из теории кооперативных игр. Метод отвечает на вопрос: какой вклад каждого признака в отклонение предсказания от среднего значения? Значения SHAP удовлетворяют свойствам симметрии, эффективности и линейности, что делает их теоретически обоснованным способом интерпретации моделей. Для каждого наблюдения метод SHAP вычисляет вклад каждого признака в предсказание. Сумма всех значений SHAP плюс базовое значение (среднее предсказание по выборке) равна итоговому предсказанию модели. Глобальная важность признака определяется как среднее абсолютных значений SHAP по всем наблюдениям. Существуют разные алгоритмы расчета SHAP: TreeExplainer для древовидных моделей (точный и быстрый); KernelExplainer для любых моделей (медленный, использует аппроксимацию); DeepExplainer для нейросетей. TreeExplainer оптимален для XGBoost, LightGBM, CatBoost — он использует структуру деревьев для точного вычисления за полиномиальное время. import shap # Обучение модели model = XGBRegressor(n_estimators=100, max_depth=6, learning_rate=0.1, random_state=42) model.fit(X_train, y_train) # Создание SHAP explainer для древовидных моделей explainer = shap.TreeExplainer(model) shap_values = explainer(X_test) # Глобальная важность признаков shap_importance = pd.DataFrame({ 'feature': X.columns, 'importance': np.abs(shap_values.values).mean(axis=0) }).sort_values('importance', ascending=False) print("SHAP Feature Importance Top 10:") print(shap_importance.head(10)) # Визуализация 1: Summary plot (показывает распределение SHAP values) shap.summary_plot(shap_values, X_test, show=False) plt.tight_layout() plt.show() # Визуализация 2: Bar plot глобальной важности shap.plots.bar(shap_values, show=False) plt.tight_layout() plt.show() # Визуализация 3: Waterfall plot для конкретного наблюдения # Выбираем наблюдение с наибольшим абсолютным предсказанием max_pred_idx = np.abs(model.predict(X_test)).argmax() shap.plots.waterfall(shap_values[max_pred_idx], show=False) plt.tight_layout() plt.show() # Визуализация 4: Dependence plot для топового признака top_feature = shap_importance.iloc[0]['feature'] shap.dependence_plot(top_feature, shap_values.values, X_test, show=False) plt.tight_layout() plt.show() SHAP Feature Importance Top 10: feature importance 13 volume_lag_10 0.002486 6 returns_lag_2 0.002229 3 close_open_spread 0.001710 14 volatility_5d 0.001490 7 volume_lag_2 0.001375 8 returns_lag_3 0.001370 9 volume_lag_3 0.001143 1 volume_change 0.001053 4 returns_lag_1 0.001043 11 volume_lag_5 0.000802 Рис. 3: Сводный график значений SHAP (summary plot), показывающий распределение влияния каждого признака на предсказания модели XGBoost. Цветовая шкала отражает значения признаков, а положение по оси X — силу и направление их вклада Рис. 4: Столбчатая диаграмма глобальной важности признаков на основе средних абсолютных значений SHAP, иллюстрирующая совокупное влияние факторов на результаты модели Рис. 5: Waterfall-график разложения предсказания для одного наблюдения, демонстрирующий, как каждый признак увеличивает или уменьшает итоговое значение прогноза Рис. 6: График зависимости SHAP для наиболее важного признака, показывающий связь между значением признака и его вкладом в предсказание модели Вышеуказанный код создает TreeExplainer для модели XGBoost и вычисляет значения SHAP для тестовой выборки. Глобальная важность определяется как среднее абсолютных значений по всем наблюдениям. Summary plot показывает распределение значений SHAP для каждого признака: Каждая точка — одно наблюдение, цвет отражает значение признака (красный — высокое, синий — низкое); Горизонтальная позиция точки — SHAP value (вклад в предсказание); Признаки упорядочены по важности сверху вниз. Визуализация позволяет увидеть не только важность, но и характер влияния: положительный или отрицательный, линейный или нелинейный. Waterfall plot показывает декомпозицию предсказания для конкретного наблюдения. Начинается с базового значения (среднее по выборке), затем последовательно добавляются вклады признаков. Финальное значение — предсказание модели. Визуализация отвечает на вопрос "почему модель сделала такое предсказание для этого объекта?". Dependence plot показывает зависимость SHAP value от значения признака: По оси X — значение признака; По оси Y — его SHAP value; Цвет точек может отражать значение другого признака (автоматически выбирается наиболее взаимодействующий). График Dependence plot выявляет нелинейные зависимости и взаимодействия между признаками. Метод Drop Column Importance Метод Drop column importance измеряет важность признака через разницу в качестве модели с признаком и без него. Метод прямолинеен: для каждого признака обучается две модели — с полным набором признаков и с удаленным целевым признаком. Разница в метрике качества на валидационной выборке определяет важность. Метод вычислительно затратен, так как требует обучения N+1 моделей, где N — количество признаков. Для больших датасетов или сложных моделей это непрактично. Тем не менее, данный метод часто используют в прогнозировании, так как он дает наиболее точную оценку реального влияния признака, учитывая все взаимодействия и компенсации. Применение Drop column importance оправдано в следующих случаях: Финальная валидация наиболее важных признаков перед продакшеном; Детекция признаков с marginal value (модель их использует, но их можно удалить без потери качества); Анализ cost-benefit для дорогих в получении признаков (например, альтернативные данные). Для отбора из сотен признаков метод не подходит — используйте встроенные методы или permutation importance для предварительного отсева. Реализация Drop column importance на Python: from sklearn.model_selection import cross_val_score def drop_column_importance(X, y, model, cv=5, scoring='neg_mean_squared_error'): """ Вычисляет важность признаков через удаление каждого признака и измерение изменения качества модели """ # Baseline: качество с полным набором признаков baseline_scores = cross_val_score(model, X, y, cv=cv, scoring=scoring) baseline_mean = baseline_scores.mean() importance_scores = {} for column in X.columns: # Удаляем признак X_dropped = X.drop(columns=[column]) # Оцениваем качество без этого признака dropped_scores = cross_val_score(model, X_dropped, y, cv=cv, scoring=scoring) dropped_mean = dropped_scores.mean() # Важность = насколько ухудшилось качество # Для neg_mean_squared_error: чем меньше значение, тем хуже # Поэтому importance = baseline - dropped importance = baseline_mean - dropped_mean importance_scores[column] = importance return pd.DataFrame({ 'feature': importance_scores.keys(), 'importance': importance_scores.values() }).sort_values('importance', ascending=False) # Используем подвыборку для ускорения (первые 300 наблюдений) X_subset = X.head(300) y_subset = y.head(300) model = XGBRegressor(n_estimators=50, max_depth=5, learning_rate=0.1, random_state=42) drop_importance = drop_column_importance(X_subset, y_subset, model, cv=3) print("Drop Column Importance:") print(drop_importance.head(10)) # Визуализация fig, ax = plt.subplots(figsize=(10, 8)) top_features = drop_importance.head(10) ax.barh(top_features['feature'], top_features['importance'], color='#2C3E50') ax.set_xlabel('Decrease in Score (higher = more important)') ax.set_title('Drop Column Importance (Top 10 Features)') ax.invert_yaxis() plt.tight_layout() plt.show() Drop Column Importance: feature importance 13 volume_lag_10 0.000027 4 returns_lag_1 0.000023 12 returns_lag_10 0.000018 16 volume_ma_ratio 0.000018 11 volume_lag_5 0.000017 8 returns_lag_3 0.000010 6 returns_lag_2 0.000007 3 close_open_spread 0.000007 14 volatility_5d 0.000005 15 volatility_20d 0.000004 Рис. 7: Диаграмма важности признаков, рассчитанная методом исключения признаков (Drop Column Importance). График показывает, насколько ухудшается качество модели XGBoost при удалении каждого признака, что позволяет оценить его вклад в общую предсказательную способность модели Код реализует drop column importance через кросс-валидацию. Для каждого признака модель обучается на данных без этого признака, вычисляется среднее качество по фолдам. Разница между baseline качеством и качеством без признака определяет важность. Метод показывает признаки, без которых модель теряет качество: Отрицательные значения важности означают, что удаление признака улучшило модель — такие признаки вносят шум или переобучение; Положительные значения отражают реальную пользу признака. Сравнение с другими методами выявляет расхождения. Признак может иметь высокую встроенную важность или permutation importance, но низкую drop column importance. Это происходит, когда другие признаки полностью компенсируют его удаление. Такие признаки - кандидаты на удаление для упрощения модели. Практические аспекты применения Стабильность оценок важности Оценки важности признаков варьируются между запусками модели из-за стохастичности обучения. Random Forest и Gradient Boosting используют случайные подвыборки данных и признаков, что приводит к разным деревьям. Для получения устойчивых оценок требуется усреднение по нескольким запускам или фолдам кросс-валидации. Признаки, у которых важность сильно меняется между фолдами называют нестабильными. Их появление - сигнал о проблемах в модели. Причины нестабильности могут быть разными: Коллинеарность с другими признаками; Чувствительность к выбросам; Взаимодействия высокого порядка, которые модель не улавливает стабильно. Такие признаки повышают риск переобучения и непредсказуемого поведения модели в продакшене. from sklearn.model_selection import KFold def cross_validated_importance(X, y, model, cv=5): """ Вычисляет важность признаков на каждом фолде кросс-валидации и возвращает среднее значение и стандартное отклонение """ kfold = KFold(n_splits=cv, shuffle=False) importance_list = [] for train_idx, val_idx in kfold.split(X): X_train_fold, X_val_fold = X.iloc[train_idx], X.iloc[val_idx] y_train_fold, y_val_fold = y.iloc[train_idx], y.iloc[val_idx] model.fit(X_train_fold, y_train_fold) importance_list.append(model.feature_importances_) importance_array = np.array(importance_list) return pd.DataFrame({ 'feature': X.columns, 'importance_mean': importance_array.mean(axis=0), 'importance_std': importance_array.std(axis=0), 'cv_coefficient': importance_array.std(axis=0) / (importance_array.mean(axis=0) + 1e-10) }).sort_values('importance_mean', ascending=False) model = XGBRegressor(n_estimators=100, max_depth=6, learning_rate=0.1, random_state=42) cv_importance = cross_validated_importance(X, y, model, cv=5) print("Cross-Validated Feature Importance:") print(cv_importance.head(15)) # Визуализация с error bars fig, ax = plt.subplots(figsize=(10, 8)) top_features = cv_importance.head(10) ax.barh(top_features['feature'], top_features['importance_mean'], xerr=top_features['importance_std'], color='#2C3E50', capsize=5) ax.set_xlabel('Feature Importance (mean ± std)') ax.set_title('Cross-Validated Feature Importance (Top 10)') ax.invert_yaxis() plt.tight_layout() plt.show() # Выявление нестабильных признаков unstable_features = cv_importance[cv_importance['cv_coefficient'] > 0.5] print(f"\nUnstable features (CV > 0.5): {len(unstable_features)}") print(unstable_features[['feature', 'importance_mean', 'cv_coefficient']]) Cross-Validated Feature Importance: feature importance_mean importance_std cv_coefficient 13 volume_lag_10 0.137225 0.031500 0.229551 4 returns_lag_1 0.081185 0.016949 0.208777 3 close_open_spread 0.074710 0.020138 0.269550 6 returns_lag_2 0.068826 0.006165 0.089578 8 returns_lag_3 0.065704 0.010538 0.160385 14 volatility_5d 0.060929 0.012391 0.203364 7 volume_lag_2 0.060439 0.006705 0.110940 11 volume_lag_5 0.056599 0.010507 0.185639 10 returns_lag_5 0.052230 0.008998 0.172284 16 volume_ma_ratio 0.050730 0.008113 0.159919 12 returns_lag_10 0.049305 0.008984 0.182222 9 volume_lag_3 0.048266 0.006120 0.126796 15 volatility_20d 0.046576 0.012109 0.259983 2 high_low_spread 0.045212 0.006113 0.135202 5 volume_lag_1 0.041862 0.005503 0.131461 Unstable features (CV > 0.5): 0 Empty DataFrame Columns: [feature, importance_mean, cv_coefficient] Index: [] Рис. 8: График важности признаков, рассчитанной с использованием кросс-валидации. Столбцы отражают средние значения важности признаков модели XGBoost по всем фолдам, а горизонтальные линии погрешностей (error bars) показывают стандартное отклонение, характеризующее устойчивость вклада каждого признака в предсказание Представленный пример кода вычисляет важность признаков на каждом фолде кросс-валидации и агрегирует результаты. Метрика cv_coefficient (коэффициент вариации) показывает относительную нестабильность: значения выше 0.5 указывают на высокую вариативность оценок. Если есть признаки с высоким cv_coefficient, то нужно провести дополнительный анализ: Проверка корреляции с другими признаками через матрицу корреляций или VIF. Если признак сильно коррелирует с другими и имеет схожую предсказательную способность, то следует удалить один из них; Проверка влияния выбросов. Если признак нестабилен из-за выбросов, то можно попробовать применить масштабирование или обрезку аномалий по квантилям. Совет: при отборе признаков для продакшена используйте пороговые значения не только по средней важности, но и по стабильности. Признак с importance_mean=0.05 и cv_coefficient=0.2 предпочтительнее признака с importance_mean=0.08 и cv_coefficient=0.8. Стабильность важнее маргинального прироста качества. Интеграция в feature selection Feature selection на основе важности признаков реализуется рекурсивным удалением наименее важных переменных. Процесс итеративный: Обучаем модель; Оцениваем важность; Удаляем N наименее важных признаков; Повторяем. Критерий остановки — заданное количество признаков или падение метрики качества ниже порога. Пороговые значения для отбора зависят от задачи: Для задач с сотнями признаков (например, генетические данные) обычно используют агрессивный отсев: топ 10-20% признаков; Для финансовых данных с десятками признаков предпочтительнее консервативный подход: удаляются только признаки с важностью близкой к нулю. Порог определяется эмпирически через валидацию на hold-out выборке. Альтернативный подход — метод threshold-based selection. Суть его в следующем: установить минимальный уровень важности (например, importance > 0.01) и удалить все признаки ниже порога. Метод быстрее рекурсивного удаления, однако менее точен, так как не учитывает взаимодействия между признаками после удаления части из них. from sklearn.metrics import mean_squared_error, mean_absolute_error def recursive_feature_elimination_custom(X, y, model, min_features=5, step=0.1): """ Рекурсивное удаление признаков на основе важности step: доля признаков для удаления на каждой итерации """ X_current = X.copy() results = [] while X_current.shape[1] >= min_features: # Кросс-валидация kfold = KFold(n_splits=5, shuffle=False) scores = [] for train_idx, val_idx in kfold.split(X_current): X_train_fold = X_current.iloc[train_idx] X_val_fold = X_current.iloc[val_idx] y_train_fold = y.iloc[train_idx] y_val_fold = y.iloc[val_idx] model.fit(X_train_fold, y_train_fold) y_pred = model.predict(X_val_fold) scores.append(mean_squared_error(y_val_fold, y_pred)) mean_mse = np.mean(scores) # Записываем результат results.append({ 'n_features': X_current.shape[1], 'mse': mean_mse, 'features': list(X_current.columns) }) # Обучаем на всех данных для получения важности model.fit(X_current, y) # Определяем количество признаков для удаления n_to_remove = max(1, int(X_current.shape[1] * step)) # Получаем важность и удаляем наименее важные importance = pd.DataFrame({ 'feature': X_current.columns, 'importance': model.feature_importances_ }).sort_values('importance', ascending=True) features_to_remove = importance.head(n_to_remove)['feature'].tolist() X_current = X_current.drop(columns=features_to_remove) print(f"Features: {X_current.shape[1]}, MSE: {mean_mse:.6f}") return pd.DataFrame(results) # Рекурсивное удаление признаков model = XGBRegressor(n_estimators=100, max_depth=6, learning_rate=0.1, random_state=42) rfe_results = recursive_feature_elimination_custom(X, y, model, min_features=5, step=0.15) print("\nRFE Results:") print(rfe_results[['n_features', 'mse']]) # Визуализация зависимости качества от количества признаков fig, ax = plt.subplots(figsize=(10, 6)) ax.plot(rfe_results['n_features'], rfe_results['mse'], marker='o', color='#2C3E50', linewidth=2) ax.set_xlabel('Number of Features') ax.set_ylabel('Cross-Validated MSE') ax.set_title('Model Performance vs Number of Features') ax.grid(True, alpha=0.3) plt.tight_layout() plt.show() # Определение оптимального количества признаков optimal_idx = rfe_results['mse'].idxmin() optimal_n_features = rfe_results.loc[optimal_idx, 'n_features'] optimal_features = rfe_results.loc[optimal_idx, 'features'] print(f"\nOptimal number of features: {optimal_n_features}") print(f"Optimal MSE: {rfe_results.loc[optimal_idx, 'mse']:.6f}") print(f"\nOptimal feature set:") print(optimal_features) Features: 15, MSE: 0.000502 Features: 13, MSE: 0.000497 Features: 12, MSE: 0.000479 Features: 11, MSE: 0.000482 Features: 10, MSE: 0.000478 Features: 9, MSE: 0.000523 Features: 8, MSE: 0.000517 Features: 7, MSE: 0.000514 Features: 6, MSE: 0.000535 Features: 5, MSE: 0.000502 Features: 4, MSE: 0.000538 RFE Results: n_features mse 0 17 0.000502 1 15 0.000497 2 13 0.000479 3 12 0.000482 4 11 0.000478 5 10 0.000523 6 9 0.000517 7 8 0.000514 8 7 0.000535 9 6 0.000502 10 5 0.000538 Optimal number of features: 11 Optimal MSE: 0.000478 Optimal feature set: ['close_open_spread', 'returns_lag_1', 'returns_lag_2', 'volume_lag_2', 'returns_lag_3', 'returns_lag_5', 'volume_lag_5', 'returns_lag_10', 'volume_lag_10', 'volatility_5d', 'volatility_20d'] Рис. 9: График зависимости качества модели от количества используемых признаков, полученный методом рекурсивного исключения признаков (RFE). Кривая иллюстрирует изменение среднеквадратичной ошибки (MSE) при поэтапном удалении наименее значимых признаков, что позволяет определить оптимальное количество признаков для построения модели Код реализует рекурсивное удаление признаков с кросс-валидацией на каждом шаге. Параметр step=0.15 означает удаление 15% наименее важных признаков на каждой итерации. Процесс продолжается до достижения минимального количества признаков (min_features=5). Интересно отметить, что при использовании этого метода наглядно видно как качество улучшается при удалении шумных признаков, достигает минимума, затем начинает ухудшаться при удалении информативных переменных. Оптимальное количество признаков соответствует минимуму MSE. Результаты показывают, что модель часто достигает лучшего качества с 30-50% исходных признаков. Удаление шумных переменных снижает переобучение и улучшает генерализацию. Финальный набор признаков содержит только стабильные, информативные предикторы. Совет: запускайте RFE на обучающей выборке, выбирайте оптимальное количество признаков, финально валидируйте на отложенной тестовой выборке. Если качество на тесте значительно хуже, чем на валидации внутри RFE, это сигнал о переобучении — уменьшите количество признаков или примените регуляризацию. Применение в алгоритмическом трейдинге Feature importance в алгоритмическом трейдинге решает задачу отбора предикторов из множества потенциальных сигналов. Рыночные данные содержат высокий уровень шума, большинство технических индикаторов коррелируют между собой. Модели склонны переобучаться на случайные паттерны, которые не повторяются в будущем. Анализ важности признаков выявляет действительно значимые факторы. Лаговые зависимости — ключевой аспект временных рядов. Feature importance показывает, какие лаги доходности, объема или волатильности наиболее предсказательны. Если модель присваивает высокую важность лагу 1 и низкую лагам 5-10, это указывает на краткосрочную momentum-стратегию. Обратная картина (высокая важность дальних лагов) может сигнализировать о стремлении возврата к среднему (mean reversion). Перед деплоем модели важно провести анализ возможных утечек данных в будущее (look-ahead bias). Look-ahead bias возникает, когда признак содержит информацию из будущего, недоступную в момент принятия торгового решения. Feature importance выявляет такие признаки: если переменная имеет аномально высокую важность (в 2-3 раза выше остальных), проверьте ее расчет на утечку данных. import pandas as pd import numpy as np import yfinance as yf import matplotlib.pyplot as plt from sklearn.model_selection import TimeSeriesSplit from xgboost import XGBRegressor import shap # Загрузка данных tickers = ['JNJ', 'PFE', 'MRK', 'ABT', 'LLY'] # Акции компаний сектора здравоохранения data = yf.download(tickers, start='2022-09-01', end='2025-09-01')['Close'] data = data.dropna(how='all') # Функция генерации признаков def create_features(df, ticker): df = df.copy() df = df.rename(columns={ticker: 'close'}) df['ticker'] = ticker df['return'] = df['close'].pct_change() df['vol'] = df['return'].rolling(5).std() df['momentum'] = df['return'].rolling(10).mean() # Лаги доходности и объема for lag in [1, 2, 3, 5, 10]: df[f'return_lag_{lag}'] = df['return'].shift(lag) df[f'vol_lag_{lag}'] = df['vol'].shift(lag) # Целевая переменная — накопленная доходность на 5 дней вперед df['target'] = df['return'].shift(-5).rolling(5).sum() # Удаление NaN df = df.dropna() return df # Объединение данных по всем тикерам all_data = [] for ticker in tickers: features_df = create_features(data[[ticker]], ticker) if len(features_df) > 100: # пропускаем слишком короткие ряды all_data.append(features_df) combined_df = pd.concat(all_data, ignore_index=True) combined_df = combined_df.replace([np.inf, -np.inf], np.nan).dropna() print("Combined data shape:", combined_df.shape) # Разделение на признаки и цель X = combined_df.drop(columns=['target', 'ticker']) y = combined_df['target'] # Кросс-валидация по временным срезам tscv = TimeSeriesSplit(n_splits=5) feature_importances = [] for fold, (train_idx, val_idx) in enumerate(tscv.split(X)): X_train, X_val = X.iloc[train_idx], X.iloc[val_idx] y_train, y_val = y.iloc[train_idx], y.iloc[val_idx] model = XGBRegressor( n_estimators=100, max_depth=6, learning_rate=0.1, random_state=42, n_jobs=-1 ) model.fit(X_train, y_train) feature_importances.append(model.feature_importances_) # Средняя и стандартная важность признаков importance_array = np.array(feature_importances) cv_importance = pd.DataFrame({ 'feature': X.columns, 'importance_mean': importance_array.mean(axis=0), 'importance_std': importance_array.std(axis=0), 'cv_coefficient': importance_array.std(axis=0) / (importance_array.mean(axis=0) + 1e-10) }).sort_values('importance_mean', ascending=False) print("\nCross-Validated Feature Importance (Top 15)") print(cv_importance.head(15)) # Визуализация важности признаков fig, ax = plt.subplots(figsize=(10, 8)) top_features = cv_importance.head(10) ax.barh(top_features['feature'], top_features['importance_mean'], xerr=top_features['importance_std'], color='#2C3E50', capsize=5) ax.set_xlabel('Feature Importance (mean ± std)') ax.set_title('Cross-Validated Feature Importance (Top 10)') ax.invert_yaxis() plt.tight_layout() plt.show() # SHAP-анализ для последнего фолда explainer = shap.TreeExplainer(model) shap_values = explainer(X_val) # Summary plot shap.summary_plot(shap_values, X_val, show=False) plt.tight_layout() plt.show() # Waterfall для самого значимого наблюдения max_pred_idx = np.abs(model.predict(X_val)).argmax() shap.plots.waterfall(shap_values[max_pred_idx], show=False) plt.tight_layout() plt.show() # Dependence plot для топового признака top_feature = cv_importance.iloc[0]['feature'] shap.dependence_plot(top_feature, shap_values.values, X_val, show=False) plt.tight_layout() plt.show() # Выявление нестабильных признаков unstable_features = cv_importance[cv_importance['cv_coefficient'] > 0.5] print(f"\nUnstable features (CV > 0.5): {len(unstable_features)}") print(unstable_features[['feature', 'importance_mean', 'cv_coefficient']]) data = yf.download(tickers, start='2022-09-01', end='2025-09-01')['Close'] [*********************100%***********************] 5 of 5 completed Combined data shape: (3655, 16) Cross-Validated Feature Importance (Top 15) feature importance_mean importance_std cv_coefficient 13 vol_lag_10 0.097870 0.010513 0.107422 11 vol_lag_5 0.092625 0.002959 0.031942 3 momentum 0.085974 0.003869 0.045004 0 close 0.082631 0.008803 0.106534 9 vol_lag_3 0.082259 0.007485 0.090992 7 vol_lag_2 0.076350 0.010486 0.137343 5 vol_lag_1 0.075557 0.007180 0.095023 2 vol 0.074290 0.006878 0.092582 12 return_lag_10 0.062405 0.003305 0.052961 10 return_lag_5 0.060910 0.011940 0.196028 8 return_lag_3 0.060688 0.006945 0.114433 6 return_lag_2 0.054017 0.009124 0.168915 4 return_lag_1 0.049505 0.006018 0.121571 1 return 0.044920 0.005280 0.117538 Unstable features (CV > 0.5): 0 Empty DataFrame Columns: [feature, importance_mean, cv_coefficient] Index: [] Рис. 10: График средней важности признаков модели XGBoost с временной кросс-валидацией для акций сектора здравоохранения. Отображены средние значения и стандартные отклонения важности по фолдам, что позволяет оценить устойчивость влияния признаков на предсказания модели Рис. 11: Сводный график SHAP (summary plot), показывающий вклад топ-15 признаков в предсказания модели на последнем фолде временной валидации. Цветовая шкала отражает значения признаков, а распределение по оси X — силу их влияния Рис. 12: График зависимости важности лагов доходности от периода лага. Демонстрирует какие лаговые переменные оказывают наибольшее влияние на прогнозируемую доходность Рис. 13: График зависимости важности лагов объема торгов от периода лага, иллюстрирующий, насколько прошлые изменения объема влияют на предсказания модели Давайте разберемся что делает этот код: Сначала загружаются котировки 5 тикеров из сектора здравоохранения, для которых создается набор признаков; Затем обучается модель XGBoost для предсказания доходностей акций на 5 дней вперед. Используется метод TimeSeriesSplit для корректной временной валидации — каждый последующий фолд содержит более поздние данные; Далее происходит оценка важности признаков. Feature importance агрегируется по всем фолдам временной валидации. Стандартное отклонение показывает стабильность признаков во времени. Высокая вариативность может указывать на режимные изменения рынка или нестационарность предикторов. Анализ лаговых зависимостей показывает, какие временные горизонты наиболее предсказательны. Для избежания look-ahead bias в примере выше таргет корректно смещен на 5 дней назад через shift(-5). Заключение Важность признаков, получаемая из ML-моделей (Feature Importance), позволяет определить ключевые факторы, влияющие на результаты машинного обучения, и оценить их вклад в предсказание. Это служит основой для повышения интерпретируемости модели и помогает принять решения о том, какие признаки стоит оптимизировать или исключить для улучшения устойчивости и прозрачности модели. Интерпретация моделей через анализ важности признаков позволяет не только повысить доверие к результатам машинного обучения, но и выявить ключевые факторы, влияющие на целевые показатели. Это делает такие методы ценными не только в исследовательских задачах, но и в прикладных сценариях — например, при оптимизации бизнес-процессов, оценке рисков и стратегическом планировании. ### Экстремальная теория значений (EVT): моделирование хвостовых рисков, оценка VaR и Expected Shortfall Большинство финансовых моделей с той или иной степенью точности надежно описывают регулярную динамику данных. Однако в моменты кризиса они часто дают сбой, недооценивая риск редких и разрушительных событий. Именно здесь на помощь приходит экстремальная теория значений (Extreme Value Theory, EVT) — мощный математический инструмент для анализа самых критических отклонений. В отличие от классических методов, которые пытаются описать распределение целиком, EVT сосредоточена исключительно на экстремумах — самых больших или самых малых значениях. Такой подход особенно важен для риск-менеджмента и финансов, где игнорирование хвостов распределений может стоить миллионов: например, для оценки вероятности экономических кризисов, резких падений фондовых рынков или неожиданных убытков крупных компаний. EVT позволяет не просто описывать «среднее поведение» данных, а точно моделировать редкие, но разрушительные сценарии, делая прогнозы более надежными и практическими для принятия решений в условиях неопределенности. Основы экстремальной теории значений Теорема Фишера-Типпета-Гнеденко и GEV Теорема Фишера-Типпета-Гнеденко утверждает: при определенных условиях распределение максимумов из независимых одинаково распределенных случайных величин сходится к одному из трех типов предельных распределений. Эти три типа объединены в обобщенное экстремальное распределение (Generalized Extreme Value, GEV): G(z) = exp{−[1 + ξ((z − μ)/σ)]^(−1/ξ)} где: μ — параметр положения (location); σ > 0 — параметр масштаба (scale); ξ — параметр формы (shape); z — значение случайной величины. Параметр формы ξ определяет тип распределения и толщину хвоста: При ξ > 0 получаем распределение Фреше с тяжелыми хвостами — это характерно для финансовых рынков, страховых выплат, природных катастроф; При ξ = 0 имеем распределение Гумбеля с экспоненциально убывающими хвостами; При ξ < 0 — распределение Вейбулла с ограниченным хвостом, применимое для процессов с естественным верхним пределом. Распределение GEV применяют для моделирования максимальных просадок портфелей. Типичный кейс: хедж-фонд анализирует историю дневных доходностей за 10 лет, разбивает данные на месячные блоки и извлекает минимальную дневную доходность в каждом блоке. Подгонка GEV к этим месячным минимумам позволяет оценить вероятность катастрофических просадок и рассчитать требуемый резерв капитала. Регуляторы используют аналогичный подход при оценке системных рисков банковских портфелей. Теорема Пикандса-Балкема-де Хаана и GPD Теорема Пикандса-Балкема-де Хаана фокусируется не на максимумах, а на превышениях высокого порога. Она утверждает: распределение превышений над достаточно высоким порогом сходится к обобщенному распределению Парето (Generalized Pareto Distribution, GPD): H(y) = 1 − [1 + ξ(y/σ)]^(−1/ξ) где: y = x − u — превышение над порогом u; σ > 0 — параметр масштаба; ξ — параметр формы (тот же смысл, что в GEV). GPD описывает условное распределение так: какова вероятность того, что убыток превысит определенный уровень, если мы уже знаем, что он превысил порог u? Generalized Pareto Distribution часто используется для оценки рисков деривативов и структурированных продуктов. Кроме того, его используют и в опционной торговле. Например, маркет-мейкер продает путы вне денег (out-of-the-money) на индекс. Основной риск — гэп вниз, когда убыток может многократно превысить собранную премию. GPD позволяет оценить распределение убытков в хвосте и определить требуемый капитал для покрытия хвостового риска. В отличие от исторического моделирования, которое ограничено наблюдавшимися событиями, GPD экстраполирует сценарии за пределы исторических данных, оценивая вероятность событий хуже самого плохого в выборке. Страховые компании используют GPD для моделирования выплат в случае природных катастроф. Порог устанавливается на уровне крупных убытков (например, страховые случаи > $10M), и GPD моделирует распределение сверхкрупных выплат. Это определяет цену перестрахования и размер резервов. Методология оценки хвостовых рисков Block Maxima vs Peaks Over Threshold В EVT выделяют 2 основных подхода, они различаются способом извлечения экстремумов из данных: Block Maxima (BM) разбивает временной ряд на непересекающиеся блоки равной длины и извлекает максимум (или минимум) в каждом блоке. К полученной выборке экстремумов применяется распределение GEV. При этом важно правильно подобрать размер блока: слишком короткие блоки нарушают предпосылки теоремы, слишком длинные дают малую выборку экстремумов. Для дневных финансовых данных типичный выбор — месячные или квартальные блоки. Peaks Over Threshold (POT) выбирает все наблюдения, превышающие заданный порог u, независимо от их положения во времени. К превышениям применяется GPD. Этот метод эффективнее использует информацию: вместо одного экстремума на блок получаем все значимые превышения. Выбор метода зависит от задачи и данных. BM предпочтительнее, когда важна временная структура экстремумов или когда данных много и можно позволить "выбросить" информацию ради простоты. Страховые компании часто используют BM для моделирования годовых максимальных убытков — это естественная временная шкала для бизнес-планирования. POT эффективнее при ограниченных данных и когда нужна максимальная точность хвостовых оценок. Торговые стратегии с коротким track record (2-3 года дневных данных) почти всегда лучше работают с POT: метод извлекает 50-100 превышений вместо 8-12 блочных максимумов, что дает более надежные оценки параметров. Трейдинг-дески, торгующие волатильностью, используют POT для оценки рисков gamma scalping: порог устанавливается на уровне 2-3 стандартных отклонений дневного P&L, и GPD моделирует хвост убытков. Выбор порога в POT Выбор порога u — это, пожалуй, самая сложная проблема POT. Не существует единой методики на этот счет. Между тем, низкий порог включает слишком много наблюдений, которые не относятся к хвосту — это вносит смещение. Высокий порог оставляет мало превышений — оценки становятся неустойчивыми из-за высокой дисперсии. Для подбора оптимальных порогов в POT используют графики: График средних превышений (Mean Excess plot) строится по формуле e(u) = E[X − u | X > u] в зависимости от порога u. Для распределения GPD этот график должен быть примерно линейным выше определенного уровня. Точка, где график выравнивается, указывает на подходящий порог. Метод достаточно интуитивен, однако требует визуальной оценки, и есть доля субъективизма насчет того, что считать «приближенно линейным». График Хилла (Hill plot) строится на основе оценки параметра формы ξ для разных порогов. На нем видно, как меняется оценка ξ в зависимости от числа превышений. Оптимальный порог соответствует области, где значения 𝜉 стабилизируются. Этот подход более формален, но все равно требует экспертного суждения для определения того, что считать «стабильным». Выбор порога оказывает существенное влияние на оценку капитала. Если порог завышен и превышений мало, оценка 99%-го VaR может иметь чрезвычайно широкий доверительный интервал — иногда ±30–50% от самой точки оценки. В таких условиях риск-метрика теряет практическую ценность для принятия решений. При слишком низком пороге, напротив, хвостовой риск систематически недооценивается на 10–20%, что может привести к недокапитализации и повышенной уязвимости стратегии к экстремальным событиям. На практике применяют комбинированный подход. Обычно строят график средних превышений и график Хилла, оценивают несколько кандидатов на порог с помощью тестов качества аппроксимации (Anderson-Darling, Kolmogorov-Smirnov) и выбирают тот порог, который дает стабильные оценки VaR при небольших изменениях. Консервативная стратегия предполагает использование диапазона порогов и взятие наихудших оценок риска, что обеспечивает дополнительную защиту от неожиданно больших потерь. Оценка параметров После того как выбран порог для метода превышений (POT), следующим важным шагом становится оценка параметров распределения экстремумов. От корректности этих оценок напрямую зависит точность прогнозов редких событий и риск-метрик, таких как VaR. Даже небольшие ошибки в параметрах распределения могут приводить к значительным колебаниям оценок капитала, поэтому выбор метода оценки и проверка устойчивости крайне важны. Метод максимального правдоподобия (ML) Метод максимального правдоподобия (ML) является стандартным инструментом для оценки параметров GEV и GPD. Он находит такие значения параметров, при которых вероятность наблюдаемых данных максимальна. Метод асимптотически эффективен и позволяет строить доверительные интервалы через информационную матрицу Фишера. Метод взвешенных моментов (PWM) Метод взвешенных моментов (PWM) представляет собой альтернативный подход, основанный на взвешивании моментов распределения. Он более робастен к выбросам и смещению в малых выборках. Для GPD с тяжелыми хвостами (𝜉 > 0.5) метод максимального правдоподобия может давать нестабильные оценки, особенно при небольшом числе превышений (20–50). В таких случаях PWM показывает более надежные результаты. Для риск-менеджмента важно еще проверить робастность оценок. Неустойчивые оценки параметра формы ξ напрямую транслируются в неустойчивые оценки VaR и требуемого капитала. Портфель с оценкой ξ = 0.3 ± 0.2 (95% доверительный интервал) дает разброс 99.9% VaR от $5M до $15M — с такой неопределенностью планировать инвестиции невозможно. На практике часто используют следующий подход: Сначала получают первичную оценку с помощью PWM; Затем проверяют устойчивость через бустраппинг. Для этого генерируют 1000 бутсрап-выборок из превышений, для каждой оценивают параметры и рассчитывают VaR; Если 95% оценок VaR находятся в пределах ±20% от исходной оценки, модель считается достаточно устойчивой. Если разброс превышает этот диапазон, рекомендуется либо увеличить выборку (снизить порог), либо закладывать более консервативные лимиты риска. VaR и Expected Shortfall через призму EVT Ограничения классического VaR Value at Risk (VaR) — наиболее распространенная мера риска в индустрии. VaR на уровне α отвечает на вопрос: какой максимальный убыток не будет превышен с вероятностью α? Например, дневной 99% VaR = $1M означает, что в 99% дней убыток не превысит $1M. Проблема VaR — отсутствие субаддитивности. Это означает, что VaR портфеля может превышать сумму VaR отдельных позиций: VaR(X + Y) > VaR(X) + VaR(Y). Это нарушает базовый принцип диверсификации и создает стимулы к фрагментации портфелей для обхода лимитов риска. Математически, VaR не является когерентной мерой риска. VaR также не дает информации о величине убытка в случае превышения порога. Две стратегии могут иметь одинаковый 99% VaR = $1M, но у одной убытки в худшем 1% случаев составят $1.1-1.5M, а у другой — $5-10M. VaR этого не различает. Регуляторы осознали проблему. Basel III с 2016 года требует от банков расчет Expected Shortfall вместо VaR для рыночных рисков. Это фундаментальный сдвиг в регулировании, отражающий уроки кризиса 2008 года. Оценка VaR методами EVT Экстремальная теория значений имеет свои формулы для VaR через параметры распределений GEV и GPD. Для GPD оценка VaR на уровне α при заданном пороге u рассчитывается так: VaR_α = u + (σ/ξ)[(n/N_u × (1−α))^(−ξ) − 1] где: n — общее количество наблюдений; N_u — количество превышений порога u; σ, ξ — параметры GPD. Формула позволяет экстраполировать сценарии за пределы наблюдаемых данных. Например, если исторически наихудший дневной убыток составил $2 млн, EVT-оценка 99.9% может дать VaR $5 млн. То есть, отразить вероятность событий, которые еще не происходили, но математически ожидаемы на основании хвоста распределения. Сравнение с историческим VaR показывает систематическое расхождение. Исторический 99%-й VaR берет 1-й процентиль выборки — при 1000 наблюдений это 10-й худший результат. EVT использует всю информацию о хвосте распределений (например, 50–100 превышений порога) и дальше ее экстраполирует, что особенно важно для рынков с «толстыми хвостами». В таких случаях исторический VaR может недооценивать риск на 20–40% относительно EVT-оценки. Параметрический VaR, рассчитанный на основе нормального распределения, обычно дает оценки еще дальше от реальности. Для стратегий с отрицательной асимметрией, таких как продажа опционов или carry trades, нормальный 99%-й VaR может недооценивать риск в 2–3 раза. EVT же корректно учитывает толщину хвоста через параметр ξ, обеспечивая более надежную оценку экстремальных убытков. Expected Shortfall и его преимущества Ожидаемый убыток (Expected Shortfall, ES), также называемый Conditional VaR или Average VaR, измеряет средний убыток при условии превышения VaR. Математически он выражается следующими образом: ES_α = E[X | X > VaR_α] Интерпретация: Если 99% VaR = $1M, то 99% ES — это средний убыток в худших 1% случаев. Преимущество ES в том, что это когерентная мера риска. Это означает что: Метрика ведет себя логично при объединении нескольких активов в портфель — суммарный риск не окажется меньше суммы рисков отдельных активов; Увеличение позиций пропорционально увеличивает общий риск; Если один из активов становится более рискованным, общий риск растет. Благодаря этим свойствам ES удобно использовать для установки лимитов риска на уровне всей компании и для сравнения разных портфелей по потенциальным экстремальным потерям. Для GPD формула ES выглядит следующим образом: ES_α = VaR_α/(1−ξ) + (σ − ξu)/(1−ξ) Expected Shortfall (ES) учитывает всю информацию о хвосте распределения за порогом VaR. Это значит, что две стратегии с одинаковым VaR, но разными хвостами, будут иметь разные значения ES. Такой подход особенно важен при определении размера позиций и управлении капиталом, так как учитывает вероятность экстремальных потерь, которые обычный VaR игнорирует. Показатель ES часто используют для более точного сайзинга ордеров. Вместо того чтобы задавать фиксированный процент капитала на позицию, размер позиции рассчитывается так, чтобы ES не превышал установленную долю капитала. Например, если ES одной позиции в два раза выше, чем у другой при одинаковом VaR, то размер первой позиции автоматически уменьшается в два раза. Таким образом стратегия сама учитывает различия в хвостах распределений и потенциальную экстремальную волатильность. Еще одно важное применение ES — управление капиталом на макро-уровне. Общий резервный капитал формируется как сумма ES всех стратегий плюс дополнительный буфер. Поскольку ES является субаддитивной метрикой, этот подход корректно учитывает эффект диверсификации и не создает стимулов искусственно дробить портфель для уменьшения риска. Ограничения EVT и практические проблемы Экстремальная теория значений (EVT) мощна для оценки редких событий, однако при ее применении важно учитывать ряд ограничений и практических трудностей: Предположение о стационарности: EVT предполагает, что параметры распределения хвостов остаются постоянными во времени. На практике рынки нестационарны: волатильность меняется, корреляции активов увеличиваются в кризисы, а рыночные режимы сменяют друг друга. Решение: использовать скользящие окна (rolling windows) длиной 500–1000 наблюдений для адаптации оценок к текущему режиму; Альтернатива — моделирование смены режимов через Markov-switching модели, где для каждого режима задаются свои параметры GPD. Такой подход сложнее, но точнее в периоды структурных изменений. Малые выборки в хвостах: По определению, экстремальные события редки. Например, при пороге 95-го процентиля из 1000 наблюдений мы получаем лишь 50 превышений. Для оценки 99.9%-го VaR этого количества слишком мало, и доверительные интервалы становятся широкими. Частичное решение: объединение данных (pooling) из нескольких схожих активов или стратегий. Например, 10 коррелированных акций с похожими хвостовыми свойствами можно объединить в одну выборку. Важно убедиться, что хвостовые свойства действительно однородны. Временная зависимость хвостов: EVT предполагает независимость наблюдений, но экстремальные события часто кластеризуются: один большой убыток может сопровождаться еще несколькими. Это связано с GARCH-эффектами и "эффектами обратной связи" на рынках. Игнорирование зависимости приводит к недооценке риска последовательных потерь. Решение: декластеризация данных методом runs declustering. Если несколько последовательных наблюдений превышают порог, в выборку включают только максимум из кластера. Это восстанавливает приближенную независимость ценой потери части информации. Случаи, когда EVT неприменима: Рынки с манипулированием; Активы с крайне низкой ликвидностью; Структурные изменения, например смена регулирования или делистинг. В таких ситуациях исторические данные не отражают будущие риски, и статистические модели, включая EVT, становятся ненадежными. Здесь необходимо использовать оценку рисков по сценариям (scenario analysis) и экспертное суждение, а не полагаться исключительно на математические модели. Заключение Экстремальная теория значений переводит управление хвостовыми рисками распределений из сферы эвристик и эмпирических правил в область строгой количественной оценки. Вместо предположений о нормальности, которые систематически недооценивают редкие катастрофические события, EVT предоставляет инструменты для корректной оценки вероятности и масштаба таких событий. Модели GEV и GPD позволяют точно учитывать тяжелые хвосты реальных распределений, метрика ES превосходит VaR как когерентная мера риска, а подходы POT и Block Maxima дают практические способы извлечения информации из ограниченных данных о редких событиях. Интеграция EVT в профессиональный риск-менеджмент обогащает его инструментарий генерацией сценариев на основе количественных данных о редких, но потенциально разрушительных событиях. Она помогает корректно устанавливать лимиты риска, определять размер капитала для покрытия экстремальных потерь и выстраивать стратегии хеджирования, учитывающие такие сценарии. ### Return on Investment (ROI) и Return on Invested Capital (ROIC) Метрики доходности образуют фундамент количественного анализа инвестиций. ROI и ROIC решают разные задачи: первая оценивает эффективность конкретной инвестиции, вторая — операционную эффективность бизнеса. Понимание различий между этими показателями определяет корректность их применения в стратегиях отбора активов. Показатель ROI (Return on Investment) используется для оценки отдельных сделок и портфелей. Показатель ROIC (Return on Invested Capital) применяется в фундаментальном анализе для выявления компаний с устойчивым конкурентным преимуществом. Метрики дополняют друг друга: ROI оценивает эффективность инвестора, ROIC — эффективность бизнес-модели компании. ROI: механика расчета и применение Формула и компоненты ROI следующие: ROI = (Current Value − Initial Investment) / Initial Investment где: Current Value — текущая стоимость инвестиции; Initial Investment — первоначальный капитал. ROI выражается в процентах или долях единицы. Значение 0.25 означает доходность 25%. Метрика учитывает все денежные потоки: дивиденды, купоны, прирост капитала. Для акций Current Value включает рыночную цену плюс накопленные дивиденды. Расчет игнорирует временную стоимость денег. Инвестиция с ROI 50% за год и за пять лет дает одинаковое значение метрики, хотя экономический смысл различается радикально. Временной аспект и annualized ROI Annualized ROI устраняет проблему сравнения инвестиций с разными сроками. Он рассчитывается по формуле: Annualized ROI = [(1 + ROI)^(1/n) − 1] где: ROI — совокупная доходность; n — количество лет владения активом. Пример: Инвестиция $10,000 выросла до $16,000 за 3 года. Simple ROI = 0.60 (60%). Annualized ROI = (1.60)^(1/3) − 1 = 0.169 или 16.9% годовых. Эта нормализация позволяет корректно сравнивать краткосрочные и долгосрочные позиции. Для непрерывного начисления используется логарифмическая форма: Continuous ROI = ln(Current Value / Initial Investment) / n Эта формула удобна при работе с ценовыми рядами в логарифмическом масштабе, частом подходе исследований в количественных финансах. Ограничения метрики ROI не учитывает риск. Две инвестиции с ROI 30% могут иметь волатильность 10% и 40% соответственно; Метрика не отражает путь достижения результата: плавный рост или серию резких колебаний; Отсутствует учет альтернативных издержек. ROI 8% годовых выглядит положительно, но при ставке risk-free 5% реальная премия составляет только 3%. Для корректной оценки необходим расчет альфа доходности относительно бенчмарка; Метрика чувствительна к выбору точек измерения. При волатильном активе ROI зависит от момента фиксации результата. Использование скользящих окон и множественных точек оценки снижает этот эффект. ROIC: операционная эффективность компании ROIC измеряет способность компании генерировать прибыль из операционного капитала. Метрика изолирует операционную деятельность от структуры финансирования. В отличие от ROI показатель ROIC менее зависим от удачи инвестора и больше нацелен на оценку качества работы менеджмента корпорации, стоящей за акцией. ROIC использует операционную прибыль после налогов (NOPAT), исключая влияние процентных расходов. Это позволяет сравнивать компании с разным уровнем долговой нагрузки. Метрика показывает эффективность использования всех источников капитала: собственного и заемного. Формула и компоненты ROIC: ROIC = NOPAT / Invested Capital NOPAT = Operating Income × (1 − Tax Rate) Invested Capital = Total Assets − Non-Interest-Bearing Current Liabilities Альтернативный расчет Invested Capital: Invested Capital = Shareholders' Equity + Total Debt − Cash and Cash Equivalents Компоненты NOPAT: Operating Income — операционная прибыль (EBIT); Tax Rate — эффективная налоговая ставка. Компоненты Invested Capital: Total Assets — совокупные активы; Non-Interest-Bearing Current Liabilities — беспроцентные краткосрочные обязательства (кредиторская задолженность, начисления); Cash and Cash Equivalents — денежные средства (при использовании второй формулы) Исключение избыточных денежных средств из капитальной базы улучшает точность метрики. Компании часто держат кэш для операционных нужд, но крупные остатки искажают ROIC вниз, так как денежные средства на счетах, без участия в операционной деятельности, как правило, приносят минимальную доходность. Интерпретация значений ROIC выше стоимости капитала (WACC) указывает на создание экономической стоимости. Спред ROIC − WACC определяет масштаб конкурентного преимущества. Значение 15% при WACC 8% означает, что каждый доллар инвестированного капитала генерирует $0.07 экономической прибыли. Пороговые значения варьируются по отраслям. Технологические компании демонстрируют ROIC 20-30%, капиталоемкие производства — 8-12%. Сравнение ROIC внутри сектора исключает структурные отраслевые различия. Динамика ROIC информативнее абсолютных значений. Растущий тренд сигнализирует об улучшении операционной эффективности или усилении конкурентной позиции. Падающий ROIC при росте выручки указывает на проблемы масштабирования или снижение маржинальности. Сравнительный анализ метрик Таблица различий ROI vs ROIC представлена ниже: Характеристика ROI ROIC Объект измерения Доходность инвестиции Операционная эффективность Перспектива Инвестор Компания Числитель Прирост стоимости NOPAT Знаменатель Начальная инвестиция Invested Capital Учет структуры капитала Да Нет Временной горизонт Любой Годовой (обычно) Применение Оценка сделок, портфелей Фундаментальный анализ, скрининг Чувствительность к долгу Высокая Низкая Итак, вот что важно понимать: ROI обычно применяется для оценки результативности трейдинга и инвестиционных решений. Данная метрика отвечает на вопрос: насколько выгодной оказалась позиция? Используется для сравнения альтернативных вложений, расчета эффективности стратегий, оценки портфельных управляющих. ROIC обычно используется в скрининге акций для выявления качественных компаний. Эта метрика входит в модели оценки стоимости бизнеса (DCF, EVA). Применяется для анализа устойчивости конкурентных преимуществ и выявления value traps — компаний с низкой оценкой, но слабой операционной эффективностью. Иногда ROI и ROIC комбинируют друг с другом. Данное решение нередко усиливает аналитическую базу. Хотя, тут важно понимать, что высокий ROIC компании не гарантирует положительный ROI инвестору, если акции торгуются с премией к справедливой стоимости. И также может быть обратная ситуация: низкий ROIC может сочетаться с высоким ROI при покупке недооценных активов с последующей переоценкой рынком. Взаимосвязь с другими финансовыми показателями Показатель ROIC (Return on Invested Capital) коррелирует с некоторыми метриками прибыльности, к примеру ROE (Return on Equity). Однако, в отличие от последнего, показывает эффективность использования капитала, исключая влияние долгового рычага. Высокий ROE при низком ROIC указывает на то, что доходность собственного капитала создается в основном за счет заемного финансирования, а не операционной эффективности. Для более точной оценки полезно сочетать ROIC с рыночными мультипликаторами, такими как P/E и EV/EBITDA. Вот как это работает: компания с относительно умеренным ROIC, но низким P/E, может быть более привлекательной, чем компания с высоким ROIC, но чрезмерно дорогой. Например, небольшая биотехнологическая компания с ROIC 14% и P/E 10 может представлять большую инвестиционную ценность, чем крупный технологический гигант с ROIC 25% и P/E 40. Несмотря на то, что у первой компании ROIC ниже, рынок недооценил ее прибыльность, и цена акции позволяет получить более высокую доходность на вложенный капитал. Кроме того, свободный денежный поток (Free Cash Flow) тесно связан с ROIC через капитальные затраты. Высокий ROIC при низких капитальных расходах (Capex) характерен для asset-light моделей с сильным денежным потоком, тогда как низкий ROIC при высоких Capex встречается в капиталоемких отраслях, где высокая стоимость активов и конкуренция снижают отдачу на капитал. Практическая реализация: расчет на Python Давайте рассмотрим как можно рассчитать показатели ROI и ROIC на примере доходностей акций Apple за последние 5 лет: import yfinance as yf import pandas as pd import numpy as np pd.set_option('display.expand_frame_repr', False) def get_financial_data(ticker, period='5y'): stock = yf.Ticker(ticker) # Ценовые данные prices = stock.history(period=period) # Финансовые отчеты income_stmt = stock.financials balance_sheet = stock.balance_sheet cashflow = stock.cashflow return { 'prices': prices, 'income_stmt': income_stmt, 'balance_sheet': balance_sheet, 'cashflow': cashflow, 'info': stock.info } # Пример загрузки данных ticker = 'AAPL' data = get_financial_data(ticker) data {'prices': Open High Low Close Volume Dividends Stock Splits Date 2020-10-12 00:00:00-04:00 116.784482 121.764799 116.025763 121.006081 240226800 0.0 0.0 2020-10-13 00:00:00-04:00 121.852359 121.969088 116.385689 117.796127 262330500 0.0 0.0 ... 2025-10-09 00:00:00-04:00 257.809998 258.000000 253.139999 254.039993 38322000 0.0 0.0 2025-10-10 00:00:00-04:00 254.940002 256.380005 244.000000 245.270004 61782400 0.0 0.0 [1256 rows x 7 columns], 'income_stmt': 2024-09-30 2023-09-30 2022-09-30 2021-09-30 Tax Effect Of Unusual Items 0.000000e+00 0.000000e+00 0.000000e+00 0.000000e+00 Tax Rate For Calcs 2.410000e-01 1.470000e-01 1.620000e-01 1.330000e-01 Normalized EBITDA 1.346610e+11 1.258200e+11 1.305410e+11 1.231360e+11 Net Income From Continuing Operation Net Minori... 9.373600e+10 9.699500e+10 9.980300e+10 9.468000e+10 Reconciled Depreciation 1.144500e+10 1.151900e+10 1.110400e+10 1.128400e+10 Reconciled Cost Of Revenue 2.103520e+11 2.141370e+11 2.235460e+11 2.129810e+11 EBITDA 1.346610e+11 1.258200e+11 1.305410e+11 1.231360e+11 EBIT 1.232160e+11 1.143010e+11 1.194370e+11 1.118520e+11 Net Interest Income NaN -1.830000e+08 -1.060000e+08 1.980000e+08 Interest Expense NaN 3.933000e+09 2.931000e+09 2.645000e+09 Interest Income NaN 3.750000e+09 2.825000e+09 2.843000e+09 Normalized Income 9.373600e+10 9.699500e+10 9.980300e+10 9.468000e+10 Net Income From Continuing And Discontinued Ope... 9.373600e+10 9.699500e+10 9.980300e+10 9.468000e+10 Total Expenses 2.678190e+11 2.689840e+11 2.748910e+11 2.568680e+11 Total Operating Income As Reported 1.232160e+11 1.143010e+11 1.194370e+11 1.089490e+11 Diluted Average Shares 1.540810e+10 1.581255e+10 1.632582e+10 1.686492e+10 Basic Average Shares 1.534378e+10 1.574423e+10 1.621596e+10 1.670127e+10 Diluted EPS 6.080000e+00 6.130000e+00 6.110000e+00 5.610000e+00 Basic EPS 6.110000e+00 6.160000e+00 6.150000e+00 5.670000e+00 Diluted NI Availto Com Stockholders 9.373600e+10 9.699500e+10 9.980300e+10 9.468000e+10 Net Income Common Stockholders 9.373600e+10 9.699500e+10 9.980300e+10 9.468000e+10 Net Income 9.373600e+10 9.699500e+10 9.980300e+10 9.468000e+10 ... 'info': {'address1': 'One Apple Park Way', 'city': 'Cupertino', 'state': 'CA', 'zip': '95014', 'country': 'United States', 'phone': '(408) 996-1010', 'website': 'https://www.apple.com', 'industry': 'Consumer Electronics', 'industryKey': 'consumer-electronics', 'industryDisp': 'Consumer Electronics', 'sector': 'Technology', 'sectorKey': 'technology', 'sectorDisp': 'Technology', ... 'dividendRate': 1.04, 'dividendYield': 0.42, 'exDividendDate': 1754870400, 'payoutRatio': 0.1533, 'fiveYearAvgDividendYield': 0.54, 'beta': 1.094, 'trailingPE': 37.16212, 'forwardPE': 29.515041, 'volume': 61156139, 'regularMarketVolume': 61156139, 'averageVolume': 54836700, 'averageVolume10days': 43155290, 'averageDailyVolume10Day': 43155290, ... Представленная функция извлекает исторические цены и финансовую отчетность по акциям корпорации Apple. Как вы можете убедиться выше, библиотека Yahoo Finance предоставляет бесплатно не только котировки акций, но и множество показателей, включая годовые и квартальные данные компаний. Теперь давайте напишем функции расчета ROI и ROIC: def calculate_roi(initial_price, current_price, dividends=0): """ Расчет ROI для инвестиции Parameters: initial_price: цена покупки current_price: текущая цена dividends: накопленные дивиденды Returns: ROI в долях единицы """ total_return = (current_price - initial_price + dividends) roi = total_return / initial_price return roi def calculate_annualized_roi(roi, years): """ Расчет годовой доходности Parameters: roi: совокупный ROI years: период владения в годах Returns: Annualized ROI """ return (1 + roi) ** (1 / years) - 1 def calculate_roic(operating_income, tax_rate, total_assets, current_liabilities, cash_equivalents=0): """ Расчет ROIC компании Parameters: operating_income: операционная прибыль (EBIT) tax_rate: эффективная налоговая ставка total_assets: совокупные активы current_liabilities: краткосрочные обязательства cash_equivalents: денежные средства (опционально) Returns: ROIC в долях единицы """ # NOPAT nopat = operating_income * (1 - tax_rate) # Invested Capital # Упрощенный подход: Total Assets - Non-Interest-Bearing Liabilities invested_capital = total_assets - current_liabilities - cash_equivalents roic = nopat / invested_capital return roic def extract_roic_from_financials(data): """ Извлечение ROIC из финансовых данных Parameters: data: словарь с финансовыми данными из get_financial_data Returns: pandas Series с ROIC по годам """ income_stmt = data['income_stmt'] balance_sheet = data['balance_sheet'] # Извлечение необходимых показателей operating_income = income_stmt.loc['Operating Income'] total_assets = balance_sheet.loc['Total Assets'] current_liabilities = balance_sheet.loc['Current Liabilities'] cash = balance_sheet.loc['Cash And Cash Equivalents'] # Эффективная налоговая ставка tax_provision = income_stmt.loc['Tax Provision'] pretax_income = income_stmt.loc['Pretax Income'] tax_rate = tax_provision / pretax_income # Расчет ROIC для каждого года roic_series = {} for date in operating_income.index: if date in total_assets.index: roic = calculate_roic( operating_income[date], tax_rate[date], total_assets[date], current_liabilities[date], cash[date] ) roic_series[date] = roic return pd.Series(roic_series) Этот набор функций реализует полный цикл расчета доходности инвестиций и эффективности использования капитала компании: Функции calculate_roi и calculate_annualized_roi позволяют оценить доходность конкретной инвестиции с учетом изменения цены и накопленных дивидендов, а также привести ее к годовой норме, что удобно для сравнения разных инвестиций с различными сроками владения. Функция calculate_roic предназначена для оценки ROIC — ключевого показателя операционной эффективности компании. Функция extract_roic_from_financials автоматизирует процесс получения ROIC по годам из финансовых отчетов компании. Она объединяет данные из отчета о прибыли и убытках и баланса, рассчитывает эффективную налоговую ставку и строит серию ROIC для каждого года. Вместе эти функции создают инструмент для комплексной оценки как индивидуальных инвестиций, так и качества бизнеса компании, что служит фундаментом для более сложных стратегий. Вот как можно провести анализ исторических значений доходности Apple с помощью Python: def analyze_roic_trend(ticker): """ Анализ динамики ROIC компании Parameters: ticker: тикер компании Returns: DataFrame с метриками и статистикой """ data = get_financial_data(ticker, period='5y') roic_series = extract_roic_from_financials(data) # Статистика stats = { 'mean_roic': roic_series.mean(), 'median_roic': roic_series.median(), 'std_roic': roic_series.std(), 'min_roic': roic_series.min(), 'max_roic': roic_series.max(), 'trend': np.polyfit(range(len(roic_series)), roic_series.values, 1)[0] } # CAGR ROIC (для оценки тренда) if len(roic_series) > 1: years = len(roic_series) - 1 cagr = (roic_series.iloc[-1] / roic_series.iloc[0]) ** (1/years) - 1 stats['roic_cagr'] = cagr return pd.DataFrame([stats]), roic_series # Пример анализа ticker = 'AAPL' stats, roic_history = analyze_roic_trend(ticker) print(f"ROIC Statistics for {ticker}:") print(stats.T) print(f"\nHistorical ROIC:") print(roic_history) ROIC Statistics for AAPL: 0 mean_roic 0.551608 median_roic 0.560621 std_roic 0.040718 min_roic 0.495623 max_roic 0.589567 trend -0.026010 roic_cagr -0.056216 Historical ROIC: 2024-09-30 0.589567 2023-09-30 0.549754 2022-09-30 0.571487 2021-09-30 0.495623 Функция вычисляет статистические характеристики ROIC. Параметр trend показывает наклон линейной регрессии: положительное значение указывает на улучшение операционной эффективности. CAGR ROIC отражает темп изменения метрики, что полезно для выявления устойчивых трендов. Интеграция в инвестиционные стратегии Скрининг компаний по ROIC Скрининг компаний по ROIC — это метод отбора акций, при котором инвесторы анализируют компании по показателю рентабельности инвестированного капитала. ROIC показывает, насколько эффективно компания использует вложенные средства для генерации прибыли. Чем выше ROIC, тем лучше бизнес превращает капитал в прибыль, что часто указывает на устойчивое конкурентное преимущество, грамотное управление и финансовую эффективность. Такой скрининг помогает выделить компании с высоким качеством бизнеса и потенциалом для долгосрочного роста. def screen_high_roic_stocks(tickers, min_roic=0.15, min_years=3): """ Скрининг компаний с устойчиво высоким ROIC Parameters: tickers: список тикеров для анализа min_roic: минимальный средний ROIC min_years: минимальное количество лет с высоким ROIC Returns: DataFrame с отфильтрованными компаниями """ results = [] for ticker in tickers: try: data = get_financial_data(ticker, period='10y') roic_series = extract_roic_from_financials(data) # Фильтры mean_roic = roic_series.mean() years_above_threshold = (roic_series > min_roic).sum() if mean_roic >= min_roic and years_above_threshold >= min_years: current_price = data['prices']['Close'].iloc[-1] results.append({ 'ticker': ticker, 'mean_roic': mean_roic, 'median_roic': roic_series.median(), 'roic_stability': roic_series.std(), 'years_above_threshold': years_above_threshold, 'current_price': current_price, 'latest_roic': roic_series.iloc[-1] }) except Exception as e: print(f"Error processing {ticker}: {e}") continue df = pd.DataFrame(results) return df.sort_values('mean_roic', ascending=False) # Пример использования sp500_sample = ['AAPL', 'MSFT', 'GOOGL', 'META', 'NVDA', 'AMD', 'TSLA', 'AMZN', 'INTC', 'AVGO', 'ADBE', 'CRM', 'NFLX', 'ORCL', 'CSCO', 'QCOM', 'TXN', 'IBM'] high_roic_stocks = screen_high_roic_stocks(sp500_sample, min_roic=0.20) print(high_roic_stocks) ticker mean_roic median_roic roic_stability years_above_threshold current_price latest_roic 0 AAPL 0.551608 0.560621 0.040718 4 245.270004 0.495623 4 NVDA 0.471256 0.433481 0.302081 3 183.160004 0.260158 6 ADBE 0.376594 0.347580 0.095766 4 337.510010 NaN 8 TXN 0.323862 0.337555 0.141368 3 171.699997 NaN 7 QCOM 0.322180 0.328132 0.084291 4 153.589996 0.388586 1 MSFT 0.256285 0.252694 0.021098 4 510.959991 0.283194 3 META 0.255257 0.275448 0.068184 3 705.299988 NaN 2 GOOGL 0.248278 0.242699 0.020901 4 236.570007 0.240671 5 TSLA 0.213033 0.224882 0.100028 3 413.489990 NaN Представленный скрипт реализует скрининг акций с устойчиво высоким ROIC за последние 10 лет. В примере использован список ведущих технологических компаний S&P 500, а пороговое значение средней доходности на инвестированный капитал установлено на уровне 20%. Результаты показывают, что: Лидерами по устойчивости и уровню ROIC являются Apple (AAPL) и NVIDIA (NVDA), демонстрирующие выдающуюся эффективность использования капитала при средней рентабельности 55% и 47% соответственно; Высокие позиции также занимают Adobe (ADBE), Texas Instruments (TXN), Qualcomm (QCOM) и Microsoft (MSFT), что отражает стабильную бизнес-модель и конкурентные преимущества этих компаний. Показатель roic_stability (стандартное отклонение) служит индикатором предсказуемости эффективности: чем он ниже, тем более устойчивы операционные результаты. Например, Apple и Microsoft демонстрируют минимальную волатильность ROIC, что говорит о стабильном управлении капиталом и высокой предсказуемости прибыли, в то время как у NVIDIA наблюдается большая изменчивость, характерная для быстрорастущих инновационных компаний. Комбинирование с momentum-факторами Комбинирование ROIC с momentum-факторами позволяет оценивать компании не только по качеству бизнеса, но и по динамике рыночных цен. Такой подход объединяет фундаментальный и технический анализ: ROIC отражает эффективность использования капитала, а momentum — текущую силу тренда. Это помогает выделить компании, которые одновременно демонстрируют высокую операционную эффективность и находятся в фазе устойчивого роста на рынке. def roic_momentum_strategy(tickers, lookback_period=252): """ Стратегия комбинирования ROIC и price momentum Parameters: tickers: список тикеров lookback_period: период для расчета momentum (торговые дни) Returns: DataFrame с ранжированными акциями """ results = [] for ticker in tickers: try: data = get_financial_data(ticker) roic_series = extract_roic_from_financials(data) # Последнее значение ROIC latest_roic = roic_series.iloc[-1] # Price momentum prices = data['prices']['Close'] if len(prices) >= lookback_period: momentum = (prices.iloc[-1] / prices.iloc[-lookback_period] - 1) else: continue # Комбинированный скор # Z-score нормализация для сопоставимости results.append({ 'ticker': ticker, 'roic': latest_roic, 'momentum': momentum, 'current_price': prices.iloc[-1] }) except Exception as e: continue df = pd.DataFrame(results) # Z-score нормализация df['roic_zscore'] = (df['roic'] - df['roic'].mean()) / df['roic'].std() df['momentum_zscore'] = (df['momentum'] - df['momentum'].mean()) / df['momentum'].std() # Комбинированный ранг (равные веса) df['combined_score'] = (df['roic_zscore'] + df['momentum_zscore']) / 2 return df.sort_values('combined_score', ascending=False) # Пример стратегии portfolio = roic_momentum_strategy(sp500_sample) print(portfolio[['ticker', 'roic', 'momentum', 'combined_score']].head(10)) ticker roic momentum combined_score 0 AAPL 0.495623 0.073501 0.431181 12 NFLX 0.180482 0.677247 0.347418 2 GOOGL 0.240671 0.468017 0.217562 4 NVDA 0.260158 0.381172 0.145850 1 MSFT 0.283194 0.233163 -0.009346 15 QCOM 0.388586 -0.077388 -0.150050 11 CRM 0.007601 -0.155519 -1.506148 3 META NaN 0.198147 NaN 5 AMD NaN 0.256578 NaN 6 TSLA NaN 0.715370 NaN Результаты комбинированного скрининга показывают, что: Лидирующие позиции по интегральному показателю combined_score занимают компании Apple (AAPL) и Netflix (NFLX); Apple демонстрирует высокое качество бизнеса с ROIC около 0.50 при умеренном положительном momentum, что указывает на устойчивую прибыльность и стабильный рост котировок; Netflix, напротив, имеет более скромный ROIC, но сильный импульс цены, что отражает растущий интерес инвесторов и эффект восстановления после предыдущих коррекций; Компании Google (GOOGL) и NVIDIA (NVDA) занимают промежуточные позиции — они сочетают средний уровень рентабельности с уверенным восходящим трендом, что делает их привлекательными для сбалансированных портфелей; Отрицательные значения комбинированного скоринга у Qualcomm (QCOM) и Salesforce (CRM) сигнализируют о слабом ценовом импульсе, несмотря на приемлемые фундаментальные показатели. Это типичный пример, когда рынок временно игнорирует фундаментальное качество, что может указывать либо на коррекцию, либо на структурные изменения в бизнесе. Таким образом, комбинирование ROIC и momentum позволяет получать более комплексную оценку инвестиционной привлекательности компаний: ROIC выявляет внутреннее качество бизнеса; Momentum — внешний интерес инвесторов и фазу рыночного цикла. Совместное использование факторов повышает устойчивость отбора и снижает вероятность попадания в value traps — ситуации, когда компания выглядит дешевой по мультипликаторам, но продолжает терять стоимость. Оптимальное соотношение весов факторов (например, 60% фундамент, 40% momentum) может варьироваться в зависимости от рыночной фазы: в периоды неопределенности акцент смещается в пользу фундаментальных показателей, тогда как в фазе роста большую роль играют факторы теханализа. Учет отраслевой специфики Различия в бизнес-моделях и капиталоемкости отраслей делают прямое сравнение ROIC между компаниями из разных секторов некорректным. Например, технологические компании обычно демонстрируют значительно более высокий ROIC, чем предприятия энергетического или финансового сектора, просто из-за различий в структуре активов и уровнях маржинальности. Поэтому при анализе важно учитывать отраслевую специфику, нормируя показатели относительно медианных значений внутри каждой отрасли. Такой подход позволяет выявить компании, которые действительно превосходят своих прямых конкурентов по эффективности использования капитала, а не просто выигрывают за счет особенностей сектора. def sector_adjusted_roic_screening(tickers_with_sectors): """ Скрининг с учетом отраслевых медиан ROIC Parameters: tickers_with_sectors: dict {ticker: sector} Returns: DataFrame с отраслевыми ранжированием """ results = [] for ticker, sector in tickers_with_sectors.items(): try: data = get_financial_data(ticker) roic_series = extract_roic_from_financials(data) latest_roic = roic_series.iloc[-1] results.append({ 'ticker': ticker, 'sector': sector, 'roic': latest_roic }) except: continue df = pd.DataFrame(results) # Расчет медианы по секторам sector_medians = df.groupby('sector')['roic'].median() df['sector_median_roic'] = df['sector'].map(sector_medians) # Относительный ROIC df['roic_vs_sector'] = df['roic'] / df['sector_median_roic'] # Ранжирование внутри сектора df['sector_rank'] = df.groupby('sector')['roic'].rank(ascending=False) return df.sort_values('roic_vs_sector', ascending=False) # Пример с секторами tickers_sectors = { 'AAPL': 'Technology', 'MSFT': 'Technology', 'JPM': 'Financials', 'BAC': 'Financials', 'XOM': 'Energy', 'CVX': 'Energy' } sector_screen = sector_adjusted_roic_screening(tickers_sectors) print(sector_screen) ticker sector roic sector_median_roic roic_vs_sector sector_rank 0 AAPL Technology 0.495623 0.389408 1.272759 1.0 2 XOM Energy 0.065874 0.061259 1.075344 1.0 3 CVX Energy 0.056643 0.061259 0.924656 2.0 1 MSFT Technology 0.283194 0.389408 0.727241 2.0 Результаты отраслевого скрининга подтверждают, что Apple (AAPL) и Microsoft (MSFT) сохраняют лидерство внутри технологического сектора, но с разной степенью превосходства относительно своих конкурентов: Apple демонстрирует ROIC на 27% выше медианного уровня по сектору (1.27×), что отражает выдающуюся операционную эффективность и высокий возврат на капитал в сравнении с типичной технологической компанией; Microsoft, напротив, имеет ROIC ниже отраслевой медианы (0.73×), что может быть связано с более капиталоемкой структурой бизнеса и масштабными инвестициями в облачные решения; В энергетическом секторе ExxonMobil (XOM) немного опережает медиану (1.08×); Chevron (CVX) находится чуть ниже среднего уровня (0.92×), что отражает естественные различия в структуре активов и стоимости добычи. Отраслевая нормализация - частый метод для корректного сравнения акций из разных секторов, и показатели доходности тут не исключение. Технологические компании структурно демонстрируют ROIC в диапазоне 20–30%, финансовый сектор — 10–15%, а энергетика — 5–10%. Без учета этих различий абсолютные значения ROIC приводят к систематическому перекосу в пользу отраслей с капиталоемкостью ниже средней по рынку. Показатель roic_vs_sector элегантно решает эту проблему: он показывает относительное превосходство компании над конкурентами внутри ее сектора. Например, значение 1.5 означает, что ROIC компании на 50% выше медианного уровня по отрасли, что указывает на наличие устойчивого конкурентного преимущества — будь-то эффективная бизнес-модель, сильная маржинальность, или высокая финансовая дисциплина. Важно учитывать, что сезонность ROIC также варьируется по отраслям. Например, ритейл и потребительские товары демонстрируют выраженные колебания в праздничные периоды, а энергетика — в зависимости от циклов цен на сырье. Использование годовых данных помогает сгладить такие эффекты, предоставляя более стабильную и достоверную картину операционной эффективности компаний внутри каждого сектора. Заключение Метрики доходности ROI и ROIC представляют две стороны инвестиционного анализа. ROI измеряет результат с позиции владельца капитала, ROIC раскрывает способность компании трансформировать ресурсы в прибыль. Эти метрики не гарантируют будущую доходность, однако существенно повышают вероятность отбора компаний с устойчивой генерацией стоимости. Правильное применение обеих метрик формирует комплексное понимание инвестиционной привлекательности актива. Устойчиво высокий ROIC — индикатор "ширины экономического рва вокруг замка", который построил бизнес. Компании с ROIC выше стоимости капитала на протяжении нескольких лет имеют больше ресурсов на защиту своих преимуществ и маржи от конкурентного давления. Учет данного фактора в фундаментальном анализе совместно с учетом тренда, моментума движения котировок и отраслевой нормализацией создает надежную базу для построения долгосрочных портфелей. ### Ускорение численных вычислений в Python: Numba, JIT на примерах из Data Science Python остается доминирующим языком в Data Science, однако его интерпретируемая природа создает узкие места при работе с большими объемами данных. Цикл длительных симуляций, обработки миллиона строк может занимать минуты там, где компилируемые языки справляются за секунды. Numba решает эту проблему через JIT-компиляцию, транслируя Python-код в машинный код во время выполнения. Библиотека особенно эффективна для задач с интенсивными вычислениями: расчет технических индикаторов на исторических данных, симуляции Монте-Карло для оценки рисков портфеля, обработка тиковых данных в реальном времени. В этих сценариях ускорение достигает 10-200x по сравнению с чистым Python, при этом синтаксис кода практически не меняется. Принципы работы JIT-компиляции Python интерпретирует код построчно во время выполнения. Каждая операция требует проверки типов, поиска методов в объектах, управления памятью через подсчет ссылок. Эти накладные расходы незначительны для логики приложений, но критичны для численных циклов с миллионами итераций. Статическая компиляция (AOT - ahead-of-time) транслирует весь написанный код в чистый машинный код перед запуском. Компилятор анализирует типы, оптимизирует последовательности операций, генерирует эффективные инструкции процессора. Минус подхода: отсутствие гибкости Python, необходимость явного объявления типов, сложность интеграции с динамическими библиотеками. JIT-компиляция объединяет преимущества обоих подходов. Numba анализирует функцию при первом вызове, выводит типы аргументов, компилирует оптимизированную версию и кеширует результат. Последующие вызовы с теми же типами используют скомпилированный код напрямую. При изменении типов происходит рекомпиляция — механизм называется специализацией. Роль LLVM в процессе компиляции Numba использует LLVM как бэкенд для генерации машинного кода. LLVM — модульная компиляторная инфраструктура с промежуточным представлением (IR), независимым от исходного языка и целевой платформы. Процесс состоит из трех этапов: Numba транслирует Python-байткод в Numba IR — упрощенное представление с типизированными переменными; Numba IR преобразуется в LLVM IR с применением оптимизаций: развертывание циклов, векторизация SIMD-инструкциями, устранение избыточных вычислений; LLVM генерирует машинный код для конкретной архитектуры процессора (x86-64, ARM). Такая архитектура позволяет Numba достигать производительности, сопоставимой с кодом на C, оставаясь в экосистеме Python. Вывод типов и специализация Numba автоматически определяет типы переменных через анализ операций и аргументов функции: Для массива NumPy библиотека извлекает dtype и размерность; Для скаляров — тип из значения при вызове; Если функция вызывается с float64 и int32, компилятор создает специализированную версию под эти типы. При следующем вызове с float32 и int64 генерируется новая версия. Специализация повышает производительность, но увеличивает потребление памяти при большом количестве вариантов типов. Numba хранит все скомпилированные версии функции. Для ограничения числа специализаций используется явная сигнатура типов в декораторе. Базовые возможности Numba Базовый декоратор @jit запускает компиляцию при первом вызове функции. Параметр nopython=True (сокращенно @njit) заставляет Numba работать без интерпретатора Python, что дает максимальное ускорение. Если компиляция невозможна из-за неподдерживаемых конструкций, Numba выбрасывает ошибку вместо fallback на интерпретацию. Режим nopython требует, чтобы весь код функции транслировался в машинный без вызовов Python API. Это исключает использование dict, list comprehensions с условиями, а также динамическое создание классов. Взамен достигается ускорение в 50-100x для циклов с арифметическими операциями. import numpy as np from numba import njit import time def python_volatility(prices, window): n = len(prices) volatility = np.zeros(n) for i in range(window, n): returns = np.diff(np.log(prices[i-window:i+1])) volatility[i] = np.std(returns) * np.sqrt(252) return volatility @njit def numba_volatility(prices, window): n = len(prices) volatility = np.zeros(n) for i in range(window, n): returns = np.diff(np.log(prices[i-window:i+1])) volatility[i] = np.std(returns) * np.sqrt(252) return volatility # Генерация синтетических данных np.random.seed(42) prices = 100 * np.exp(np.cumsum(np.random.randn(100000) * 0.02)) # Прогрев JIT-компилятора _ = numba_volatility(prices[:1000], 20) # Замеры производительности start = time.perf_counter() vol_python = python_volatility(prices, 20) time_python = time.perf_counter() - start start = time.perf_counter() vol_numba = numba_volatility(prices, 20) time_numba = time.perf_counter() - start print(f"Python: {time_python:.3f}s") print(f"Numba: {time_numba:.3f}s") print(f"Ускорение: {time_python/time_numba:.1f}x") Python: 4.496s Numba: 0.058s Ускорение: 77.0x Код вычисляет скользящую волатильность на основе логарифмических доходностей. Numba-версия идентична Python, но работает в 50-80 раз быстрее на массиве из 100 тысяч элементов. Первый вызов numba_volatility с малым массивом прогревает компилятор — без этого замер включил бы время компиляции. Результаты обеих функций численно совпадают, что подтверждает корректность оптимизации. Поддерживаемые типы и операции Numba поддерживает подмножество Python и NumPy, достаточное для большинства численных задач. Базовые типы включают скаляры (int, float, complex, bool), массивы NumPy всех стандартных dtype, кортежи с фиксированными типами элементов. Словари и списки работают в ограниченном режиме: поддерживаются только гомогенные типы, определяемые при первой вставке. Математические операции покрывают: стандартную библиотеку math; функции NumPy для работы с массивами (sum, mean, std, dot, transpose); линейную алгебру через numpy.linalg (ограниченный набор); поддерживаются срезы массивов, индексирование, broadcasting правила NumPy; также поддерживаются условные операторы if/else, циклы for/while работают без ограничений. Не поддерживаются операции с объектами Python: вызовы методов классов (кроме специально аннотированных); обращения к атрибутам динамических объектов; исключения с пользовательскими классами; строки поддерживаются базово: конкатенация, сравнение, но без регулярных выражений; нет поддержки датафреймов pandas напрямую — требуется извлечение NumPy массивов через .values. Ограничения и обработка ошибок Основное ограничение Numba — статическая типизация на уровне компиляции. Функция не может возвращать разные типы в зависимости от условий. Попытка вернуть float в одной ветке и массив в другой приведет к ошибке компиляции. Решение: рефакторинг логики или использование контейнеров фиксированного типа. Рекурсия в Numba поддерживается, но без оптимизации хвостовых вызовов. Глубокая рекурсия приводит к переполнению стека быстрее, чем в интерпретируемом Python. Для задач типа вычисления чисел Фибоначчи или обхода деревьев предпочтительны итеративные алгоритмы. Память управляется вручную для сложных структур. NumPy массивы освобождаются автоматически, но при работе с внешними библиотеками через cffi требуется явный контроль выделения и освобождения. Также важно учитывать, что утечки памяти в скомпилированном Numba-коде сложнее диагностировать, чем в Python. Практические примеры оптимизации Расчет максимальной просадки портфеля Максимальная просадка (maximum drawdown) показывает наибольшее падение стоимости портфеля от локального максимума. Метрика критична для оценки рисков стратегий — просадка более 20% неприемлема для большинства институциональных инвесторов. Наивная реализация через вложенные циклы имеет сложность O(n²), что делает расчет на длинных историях неэффективным. import pandas as pd import numpy as np from numba import njit import yfinance as yf @njit def calculate_drawdown(equity_curve): """ Вычисляет максимальную просадку для кривой доходности. Параметры: equity_curve: массив значений портфеля во времени Возвращает: max_dd: максимальная просадка в процентах dd_duration: длительность просадки в барах """ n = len(equity_curve) running_max = equity_curve[0] max_dd = 0.0 max_duration = 0 current_duration = 0 for i in range(1, n): if equity_curve[i] > running_max: running_max = equity_curve[i] current_duration = 0 else: drawdown = (running_max - equity_curve[i]) / running_max current_duration += 1 if drawdown > max_dd: max_dd = drawdown max_duration = current_duration return max_dd * 100, max_duration @njit def rolling_sharpe(returns, window): """ Скользящий коэффициент Шарпа для оценки доходности с поправкой на риск. Параметры: returns: массив дневных доходностей window: размер окна в днях Возвращает: sharpe: массив значений коэффициента Шарпа """ n = len(returns) sharpe = np.zeros(n) for i in range(window, n): window_returns = returns[i-window:i] mean_return = np.mean(window_returns) std_return = np.std(window_returns) if std_return > 1e-6: sharpe[i] = (mean_return / std_return) * np.sqrt(252) else: sharpe[i] = 0.0 return sharpe # Загрузка данных для портфеля из нескольких активов tickers = ['TSM', 'ASML', 'LRCX'] data = yf.download(tickers, start='2023-09-01', end='2025-09-01', progress=False) # Проверка на MultiIndex и извлечение Close if isinstance(data.columns, pd.MultiIndex): prices = data['Close'] else: prices = data[['Close']] # Равновзвешенный портфель weights = np.array([1/3, 1/3, 1/3]) portfolio_value = (prices / prices.iloc[0]).values @ weights returns = np.diff(np.log(portfolio_value)) # Расчет метрик max_dd, dd_duration = calculate_drawdown(portfolio_value) sharpe = rolling_sharpe(returns, 60) print(f"Максимальная просадка: {max_dd:.2f}%") print(f"Длительность просадки: {dd_duration} дней") print(f"Средний Sharpe (60д): {np.mean(sharpe[sharpe > 0]):.2f}") Максимальная просадка: 37.79% Длительность просадки: 187 дней Средний Sharpe (60д): 2.23 Функция calculate_drawdown проходит массив один раз, отслеживая текущий максимум и просадку от него. Алгоритм имеет сложность O(n) и работает за миллисекунды на истории из нескольких тысяч баров. Переменная running_max обновляется только при достижении нового пика, что исключает лишние сравнения. Функция rolling_sharpe вычисляет коэффициент Шарпа в скользящем окне — метрика показывает избыточную доходность на единицу риска. Проверка std_return > 1e-6 предотвращает деление на ноль в периоды нулевой волатильности. Множитель √252 аннуализирует дневные доходности, предполагая 252 торговых дня в году. Для портфеля из акций полупроводниковых компаний (TSM, ASML, LRCX) код загружает данные через yfinance и вычисляет эквити равновзвешенного портфеля. Проверка на MultiIndex необходима, так как yfinance возвращает разные структуры для одного и нескольких тикеров. Результаты показывают типичную просадку 30-40% для технологического сектора в период 2023-2025. Векторизация вычислений для множества инструментов Бэктестинг стратегий на широкой вселенной инструментов требует одновременного расчета индикаторов для сотен тикеров. Pandas apply тут работает ожидаемо медленно из-за накладных расходов на каждую итерацию. Numba позволяет обработать матрицу цен напрямую, применяя операции к срезам массива. import numpy as np from numba import njit import yfinance as yf import pandas as pd @njit def calculate_momentum_matrix(prices, lookback): """ Вычисляет momentum для матрицы инструментов × время. Параметры: prices: матрица (n_instruments, n_days) lookback: период расчета в днях Возвращает: momentum: матрица доходностей за период lookback """ n_instruments, n_days = prices.shape momentum = np.zeros((n_instruments, n_days)) for i in range(n_instruments): for j in range(lookback, n_days): momentum[i, j] = (prices[i, j] / prices[i, j-lookback]) - 1.0 return momentum @njit def rank_instruments(momentum, top_n): """ Ранжирует инструменты по momentum на каждую дату. Параметры: momentum: матрица momentum (n_instruments, n_days) top_n: количество лучших инструментов для отбора Возвращает: ranks: матрица рангов, топ инструменты = 1, остальные = 0 """ n_instruments, n_days = momentum.shape ranks = np.zeros((n_instruments, n_days)) for j in range(n_days): # Сортировка индексов по momentum для текущего дня sorted_indices = np.argsort(momentum[:, j])[::-1] # Отметка топ-N инструментов for k in range(top_n): ranks[sorted_indices[k], j] = 1.0 return ranks # Загрузка данных для корзины акций производителей процессоров, чипов, полупроводников tickers = ['TSM', 'INTC', 'AMD', 'QCOM', 'TXN', 'AVGO', 'NXPI', 'MCHP'] data = yf.download(tickers, start='2023-09-01', end='2025-09-01', progress=False) # Извлечение Close цен if isinstance(data.columns, pd.MultiIndex): prices_df = data['Close'] else: prices_df = data # Преобразование в numpy для Numba prices = prices_df.values.T # Транспонирование для (n_instruments, n_days) # Расчет momentum momentum = calculate_momentum_matrix(prices, lookback=60) # Отбор топ-3 инструментов на каждую дату ranks = rank_instruments(momentum, top_n=3) # Вычисление доходности равновзвешенного портфеля из топовых инструментов portfolio_weights = ranks / ranks.sum(axis=0) # Нормализация весов portfolio_returns = np.sum(portfolio_weights[:, 1:] * np.diff(prices) / prices[:, :-1], axis=0) cumulative_return = np.prod(1 + portfolio_returns) - 1 print(f"Доходность momentum стратегии: {cumulative_return*100:.2f}%") print(f"Среднее количество смен позиций: {np.sum(np.diff(ranks, axis=1) != 0) / ranks.shape[1]:.1f}") Доходность momentum стратегии: 269.75% Среднее количество смен позиций: 0.5 Давайте разберемся что тут происходит: Функция calculate_momentum_matrix обрабатывает матрицу цен целиком. Внешний цикл итерирует по инструментам, внутренний — по датам; Для каждой ячейки вычисляется относительное изменение цены за период lookback. Такая структура эффективна для кеширования — процессор загружает строки матрицы блоками; Функция rank_instruments определяет топовые инструменты по momentum на каждую дату; Функция np.argsort с параметром [::-1] возвращает индексы в порядке убывания momentum; Цикл по top_n помечает лучшие инструменты единицей в матрице рангов. Результат используется для формирования весов портфеля — каждую дату держим равные доли в топ-3 акциях. Для корзины из 8 полупроводниковых компаний стратегия ротации по 60-дневному momentum показывает очень высокую доходность - в 200+ %! Частота смен позиций (около 0.5 ребалансировок на инструмент в месяц) указывает на слабую торговую активность, а значит - минимальные торговые издержки. Numba позволяет бэктестить такую логику на тысячах инструментов за секунды вместо минут с pandas! Параллелизация вычислений Многопоточность через prange Numba поддерживает автоматическую параллелизацию циклов через замену range на prange (parallel range) при установленном флаге parallel=True. Компилятор анализирует зависимости между итерациями и распределяет работу по потокам, если итерации независимы. Это работает эффективно только для задач, которые хорошо поддаются параллелизации — таких как расчет индикаторов для разных инструментов, симуляции Монте-Карло и обработка батчей данных. import numpy as np from numba import njit, prange import time @njit(parallel=True) def monte_carlo_var(returns, portfolio_value, n_simulations, horizon): """ Оценка Value-at-Risk через симуляции Монте-Карло. Параметры: returns: исторические дневные доходности portfolio_value: текущая стоимость портфеля n_simulations: количество симуляций horizon: горизонт прогноза в днях Возвращает: var_95: VaR на уровне 95% var_99: VaR на уровне 99% """ mean_return = np.mean(returns) std_return = np.std(returns) simulated_returns = np.zeros(n_simulations) # Параллельная генерация симуляций for i in prange(n_simulations): path_return = 0.0 for j in range(horizon): # Генерация случайной доходности из нормального распределения random_return = np.random.randn() * std_return + mean_return path_return += random_return simulated_returns[i] = path_return # Вычисление убытков simulated_values = portfolio_value * (1 + simulated_returns) losses = portfolio_value - simulated_values # Квантили для VaR var_95 = np.percentile(losses, 95) var_99 = np.percentile(losses, 99) return var_95, var_99 @njit(parallel=True) def parallel_rolling_beta(returns_asset, returns_market, window): """ Параллельный расчет скользящей беты актива к рынку. Параметры: returns_asset: доходности актива returns_market: доходности рыночного индекса window: размер окна Возвращает: beta: массив скользящих значений беты """ n = len(returns_asset) beta = np.zeros(n) # Параллелизация по окнам for i in prange(window, n): asset_window = returns_asset[i-window:i] market_window = returns_market[i-window:i] # Ковариация и дисперсия covariance = np.mean((asset_window - np.mean(asset_window)) * (market_window - np.mean(market_window))) variance = np.var(market_window) if variance > 1e-8: beta[i] = covariance / variance else: beta[i] = 0.0 return beta # Генерация синтетических данных np.random.seed(42) n_days = 2000 market_returns = np.random.randn(n_days) * 0.01 asset_returns = 1.2 * market_returns + np.random.randn(n_days) * 0.005 # VaR симуляция portfolio_value = 1_000_000 start = time.perf_counter() var_95, var_99 = monte_carlo_var(asset_returns, portfolio_value, 100_000, 10) time_parallel = time.perf_counter() - start print(f"VaR 95%: ${var_95:,.0f}") print(f"VaR 99%: ${var_99:,.0f}") print(f"Время симуляции: {time_parallel:.2f}s") # Rolling beta beta = parallel_rolling_beta(asset_returns, market_returns, 60) print(f"Средняя бета: {np.mean(beta[beta > 0]):.2f}") VaR 95%: $61,967 VaR 99%: $89,717 Время симуляции: 2.62s Средняя бета: 1.20 Что делает этот код? Функция monte_carlo_var оценивает максимальные потери портфеля с заданной вероятностью через симуляции будущих траекторий доходности; Цикл prange распределяет симуляции по всем доступным ядрам процессора. Каждая итерация независима — генерирует случайный путь, накапливает доходность, сохраняет результат; После завершения всех симуляций вычисляются квантили распределения убытков. Параллелизация дает ускорение близкое к числу физических ядер (4-8x на типичных рабочих станциях). Для 100 тысяч симуляций на 10-дневном горизонте время выполнения падает с 8-12 секунд до 2-3 секунд на 8-ядерном процессоре. Практическая ценность этого кода выражается в следующем: VaR 95% показывает убыток, который не превысится в 95% сценариев — метрика используется банками для расчета резервов под рыночные риски; Функция parallel_rolling_beta вычисляет чувствительность актива к рыночному индексу в скользящем окне. Бета больше 1 означает, что актив волатильнее рынка — рост/падение индекса на 1% приводит к изменению актива более чем на 1%. Параллелизация по окнам эффективна, так как каждое окно обрабатывается независимо: извлекаются срезы массивов, вычисляются ковариация и дисперсия, сохраняется результат. Управление потоками и производительностью Numba использует все доступные ядра по умолчанию. Количество потоков контролируется переменной окружения NUMBA_NUM_THREADS или программно через numba.set_num_threads(). Оптимальное число потоков зависит от характера задачи: для compute-bound операций лучше использовать число физических ядер; для memory-bound — меньше, чтобы избежать конкуренции за кеш. Параллелизация имеет накладные расходы на создание потоков, синхронизацию, распределение работы. Для малых массивов (менее 10 тысяч элементов) накладные расходы превышают выигрыш от параллелизма. Порог эффективности зависит от сложности операций внутри цикла — чем больше вычислений на итерацию, тем меньше критический размер данных. Проблема false sharing возникает, когда разные потоки модифицируют соседние элементы массива, находящиеся в одной линии кеша процессора (обычно 64 байта). Запись одним потоком инвалидирует кеш для других потоков, даже если они работают с разными элементами. Решение: добавление padding между элементами или реструктуризация алгоритма для минимизации записей в соседние ячейки. Продвинутые техники Создание универсальных функций через vectorize Декоратор @vectorize создает NumPy ufunc — функцию, применимую к массивам поэлементно с автоматическим broadcasting. Ufunc работают с массивами любой размерности и поддерживают параллелизацию через параметр target='parallel'. Подход эффективен для комплексных вычислений, не покрываемых стандартными NumPy функциями. import numpy as np from numba import vectorize, float64 import yfinance as yf @vectorize([float64(float64, float64, float64)], target='parallel') def calculate_kelly_fraction(win_rate, avg_win, avg_loss): """Вычисляет оптимальный размер позиции по критерию Келли.""" if avg_loss <= 0 or win_rate <= 0 or win_rate >= 1: return 0.0 b = avg_win / avg_loss q = 1.0 - win_rate kelly = (win_rate * b - q) / b return max(0.0, min(kelly, 0.25)) # Векторизованный расчет оптимальных размеров позиций n_strategies = 10000 win_rates = np.random.uniform(0.3, 0.7, n_strategies) avg_wins = np.random.uniform(0.01, 0.05, n_strategies) avg_losses = np.random.uniform(0.01, 0.03, n_strategies) kelly_fractions = calculate_kelly_fraction(win_rates, avg_wins, avg_losses) # Анализ распределения print(f"Средняя Kelly fraction: {np.mean(kelly_fractions):.3f}") print(f"Медианная Kelly fraction: {np.median(kelly_fractions):.3f}") print(f"Стратегий с Kelly > 0.15: {np.sum(kelly_fractions > 0.15)}") # Практический пример: расчет критерия Келли для исторических сделок def analyze_trading_history(returns): """Анализирует историю сделок для расчета параметров Kelly.""" winning_trades = returns[returns > 0] losing_trades = returns[returns < 0] if len(winning_trades) == 0 or len(losing_trades) == 0: return 0.0, 0.0, 0.0 win_rate = len(winning_trades) / len(returns) avg_win = np.mean(winning_trades) avg_loss = np.abs(np.mean(losing_trades)) return win_rate, avg_win, avg_loss # Загрузка данных и симуляция торговых сигналов ticker_data = yf.download('MU', start='2023-09-01', end='2025-09-01', progress=False) if ticker_data.empty: raise ValueError("Нет данных для указанного периода.") prices = ticker_data['Close'] # Если это DataFrame с мультииндексом — взять первый столбец if isinstance(prices, pd.DataFrame): prices = prices.iloc[:, 0] prices = prices.values.flatten() if len(prices) < 2: raise ValueError("Недостаточно данных для расчета доходностей.") returns = np.diff(prices) / prices[:-1] # Простая momentum стратегия для демонстрации signals = np.sign(returns[:-1]) strategy_returns = signals * returns[1:] # Расчет параметров Kelly win_rate, avg_win, avg_loss = analyze_trading_history(strategy_returns) optimal_kelly = calculate_kelly_fraction( np.array([win_rate]), np.array([avg_win]), np.array([avg_loss]) )[0] print(f"\nАнализ momentum стратегии на MU:") print(f"Win rate: {win_rate:.2%}") print(f"Avg win: {avg_win:.2%}") print(f"Avg loss: {avg_loss:.2%}") print(f"Optimal Kelly fraction: {optimal_kelly:.3f}") Средняя Kelly fraction: 0.134 Медианная Kelly fraction: 0.151 Стратегий с Kelly > 0.15: 5016 Анализ momentum стратегии на MU: Win rate: 51.41% Avg win: 2.45% Avg loss: 2.24% Optimal Kelly fraction: 0.070 Что тут важно отметить: Декоратор `@vectorize` требует явного указания сигнатуры типов в формате `[output_type(input_type1, input_type2, ...)]`; Параметр `target='parallel'` включает многопоточность для обработки больших массивов; Функция `calculate_kelly_fraction` реализует критерий Келли — математическую формулу для определения оптимального размера ставки при известных вероятностях выигрыша и проигрыша. Критерий Келли максимизирует логарифмический рост капитала. Ограничение на 25% капитала защищает от чрезмерного риска — полный Kelly часто агрессивен для реальной торговли, практики используют половину или четверть расчетного значения. Векторизованная функция применяется к массивам из 10 тысяч комбинаций параметров за миллисекунды. Для каждой гипотетической стратегии вычисляется оптимальный размер позиции. Результаты показывают, что большинство стратегий с win rate 40-60% и реалистичным соотношением прибыль/убыток дают Kelly fraction в диапазоне 0.1-0.2. Практический пример анализирует простую momentum стратегию на акциях Micron Technology (MU). Функция `analyze_trading_history` извлекает параметры из фактических сделок: частоту прибыльных сделок, средние размеры прибылей и убытков. Типичная momentum стратегия на волатильных техакциях показывает win rate около 45-50% с положительным математическим ожиданием за счет асимметрии прибылей и убытков. Eager compilation и кеширование По умолчанию Numba компилирует функции при первом вызове (lazy compilation). Для продакшен систем предпочтительна eager compilation — компиляция при загрузке модуля через явное указание сигнатур типов. Это переносит задержку компиляции с момента первого использования на импорт модуля, упрощает отладку ошибок типизации. Ниже пример такой реализации: from numba import jit, float64, int64 @jit(float64(float64[:], int64), nopython=True, cache=True) def optimized_function(data, window): # Реализация pass Параметр cache=True сохраняет скомпилированную функцию на диск в директории __pycache__. При следующем запуске программы Numba загружает готовый машинный код вместо рекомпиляции. Кеш инвалидируется при изменении исходного кода функции или версии Numba. Механизм часто используется для сложных функций с долгой компиляцией, поскольку ускоряет старт приложения в десятки раз. Сигнатура float64(float64[:], int64) означает: функция принимает одномерный массив float64 и скаляр int64, возвращает float64. Квадратные скобки [:] обозначают одномерный массив, [:,:] — двумерный. Явная типизация устраняет overhead вывода типов и позволяет Numba генерировать оптимальный код сразу. Профилирование и диагностика Numba предоставляет инструменты для анализа производительности скомпилированных функций. Метод .inspect_types() показывает, какие типы вывел компилятор для каждой переменной. Метод .inspect_llvm() выводит промежуточное представление LLVM — полезно для понимания, какие оптимизации применены. from numba import njit @njit def example_function(x): result = 0.0 for i in range(len(x)): result += x[i] * x[i] return result # Компиляция с конкретными типами import numpy as np data = np.random.randn(100) _ = example_function(data) # Вывод информации о типах print(example_function.inspect_types()) example_function (Array(float64, 1, 'C', False, aligned=True),) -------------------------------------------------------------------------------- # File: /tmp/ipython-input-2551717955.py # --- LINE 3 --- # label 0 # x = arg(0, name=x) :: array(float64, 1d, C) @njit # --- LINE 4 --- def example_function(x): # --- LINE 5 --- # result = const(float, 0.0) :: float64 # result.2 = result :: float64 # del result result = 0.0 # --- LINE 6 --- # $8load_global.1 = global(range: ) :: Function() # $18load_global.3 = global(len: ) :: Function() # $30call.6 = call $18load_global.3(x, func=$18load_global.3, args=[Var(x, ipython-input-2551717955.py:3)], kws=(), vararg=None, varkwarg=None, target=None) :: (Array(float64, 1, 'C', False, aligned=True),) -> int64 # del $18load_global.3 # $38call.7 = call $8load_global.1($30call.6, func=$8load_global.1, args=[Var($30call.6, ipython-input-2551717955.py:6)], kws=(), vararg=None, varkwarg=None, target=None) :: (int64,) -> range_state_int64 # del $8load_global.1 # del $30call.6 # $46get_iter.8 = getiter(value=$38call.7) :: range_iter_int64 # del $38call.7 # $phi48.0 = $46get_iter.8 :: range_iter_int64 # del $46get_iter.8 # jump 48 # label 48 # $48for_iter.1 = iternext(value=$phi48.0) :: pair # $48for_iter.2 = pair_first(value=$48for_iter.1) :: int64 # $48for_iter.3 = pair_second(value=$48for_iter.1) :: bool # del $48for_iter.1 # $phi52.1 = $48for_iter.2 :: int64 # del $48for_iter.2 # branch $48for_iter.3, 52, 84 # label 52 # del $48for_iter.3 # i = $phi52.1 :: int64 # del $phi52.1 for i in range(len(x)): # --- LINE 7 --- # $60binary_subscr.5 = getitem(value=x, index=i, fn=) :: float64 # $68binary_subscr.8 = getitem(value=x, index=i, fn=) :: float64 # del i # $binop_mul72.9 = $60binary_subscr.5 * $68binary_subscr.8 :: float64 # del $68binary_subscr.8 # del $60binary_subscr.5 # $binop_iadd76.10 = inplace_binop(fn=, immutable_fn=, lhs=result.2, rhs=$binop_mul72.9, static_lhs=Undefined, static_rhs=Undefined) :: float64 # del $binop_mul72.9 # result.1 = $binop_iadd76.10 :: float64 # del $binop_iadd76.10 # result.2 = result.1 :: float64 # del result.1 # jump 48 result += x[i] * x[i] # --- LINE 8 --- # label 84 # del x # del $phi52.1 # del $phi48.0 # del $48for_iter.3 # $88return_value.3 = cast(value=result.2) :: float64 # del result.2 # return $88return_value.3 return result Переменная окружения NUMBA_DEBUG_TYPEINFER=1 включает детальный лог вывода типов. Параметр NUMBA_DUMP_OPTIMIZED=1 сохраняет оптимизированный LLVM IR в файлы. Эти инструменты помогают диагностировать проблемы производительности: непредвиденные boxing/unboxing операции, неоптимальные типы, отсутствие векторизации. Профилировщик line_profiler работает с Numba через специальный режим. Для точного измерения времени отдельных строк внутри скомпилированных функций требуется еще одна компиляция с отладочной информацией через параметр debug=True. Это замедляет выполнение, но позволяет идентифицировать узкие места внутри сложных алгоритмов. Сравнение производительности и рекомендации Бенчмарки: Python vs NumPy vs Numba Эффективность Numba зависит от характера вычислений. Для операций, которые NumPy уже векторизует хорошо (матричное умножение, element-wise операции), выигрыш минимален. Основная польза проявляется в трех сценариях: циклы с зависимостями между итерациями; условная логика внутри циклов; комплексные вычисления без готовых NumPy аналогов. Рис. 1: Сравнительная таблица скорости работы алгоритмов Python, Numpy, Numba Числа получены на процессоре Intel i7-10700K для типичных операций в анализе временных рядов. Скользящее среднее реализовано через явный цикл для честного сравнения — NumPy имеет оптимизированные функции типа np.convolve, но они не всегда применимы к комплексной логике. Для матричного умножения NumPy использует оптимизированные BLAS библиотеки, Numba не превосходит их. Критический порог эффективности Numba — массивы от 1000 элементов. Ниже этого размера накладные расходы JIT-компиляции и вызова скомпилированной функции сопоставимы с временем вычислений. Также важно учитывать: для микробенчмарков на малых данных результаты искажаются — важно тестировать Numba на реальных объемах. Практические рекомендации по применению Используйте Numba для: Обработки временных рядов с комплексной логикой: расчет индикаторов с условиями, детекция паттернов, фильтрация сигналов; Симуляций и стохастических методов: Монте-Карло для оценки рисков, генерация синтетических данных, bootstrap процедуры; Бэктестинга торговых стратегий: пошаговое моделирование, учет проскальзываний и комиссий, управление позициями; Оптимизации гиперпараметров: grid search или random search по сотням комбинаций параметров; Обработки больших объемов тиковых данных: агрегация в бары, расчет микроструктурных метрик. Не используйте Numba для: Простых операций, где NumPy уже эффективен: покомпонентная арифметика, линейная алгебра, базовые статистики; Прототипирования с частыми изменениями кода: каждая правка требует рекомпиляции; Работы с нечисловыми данными: текст, объекты Python, данные с комплексной структурой; Интеграции с библиотеками без Numba-поддержки: операции с датафреймами pandas (используйте .values для NumPy массивов) Оптимальная стратегия: профилируйте код стандартным Python profiler, идентифицируйте узкие места, применяйте Numba точечно к критическим функциям. Преждевременная оптимизация усложняет код без измеримых выгод. Начинайте с чистого Python или NumPy, добавляйте @njit только там, где бенчмарки показывают проблемы производительности. Комбинирование с другими инструментами Numba хорошо интегрируется с экосистемой научного Python: Для задач машинного обучения Numba эффективно комбинируется с PyTorch: предобработка данных в Numba, обучение моделей в PyTorch; Библиотека Dask использует Numba для распределенных вычислений — функции с @njit автоматически параллелятся на кластере; Для работы с GPU используйте @cuda.jit вместо @njit. Бэкенд CUDA требует видеокарту NVIDIA и компилирует функции в ядра CUDA (CUDA kernels). Такой подход эффективен для независимо параллельных задач: обработки миллионов независимых симуляций, расчета опционных цен методом конечных разностей, обучения простых нейросетей; CuPy - это NumPy-подобная библиотека для GPU. Она работает совместно с Numba CUDA. Данные передаются между CPU и GPU через унифицированную память (unified memory) или явные копирования. Для финансовых вычислений GPU дает ускорение 10-100x на задачах типа массовой оценки деривативов, однако требует переработки алгоритмов под SIMT архитектуру. Заключение Numba устраняет исторический компромисс между продуктивностью разработки на Python и производительностью компилируемых языков. JIT-компиляция превращает медленные циклы в машинный код, сопоставимый по скорости с C, сохраняя читаемость и гибкость Python. Для численных задач в Data Science это означает возможность работать с миллионами строк данных интерактивно, без переписывания критичных участков на низкоуровневых языках. Ключевое преимущество библиотеки — минимальный порог входа: Добавление декоратора @njit к существующей функции часто дает 50-200x ускорение без изменения логики; Параллелизация через prange масштабирует вычисления на все ядра одной строкой кода; Векторизация создает эффективные универсальные функции для применения комплексных формул к массивам. Эти инструменты превращают Python в полноценную платформу для высокопроизводительных вычислений, расширяя область применения от прототипирования до продакшен систем, анализирующих данные в реальном времени. ### Чем отличается финансовый ML от других видов машинного обучения Машинное обучение в финансах работает с данными, которые принципиально отличаются от изображений, текстов или табличных данных из других отраслей. Финансовые временные ряды нестационарны, зашумлены и подвержены частым структурным изменениям. Эти особенности требуют специфических подходов к моделированию, валидации и оценке качества. Сегодня стало модно подавать любые данные в трансформеры, большие языковые модели. Однако этот подход, как и многие другие популярные ML-модели, которые отлично зарекомендовали себя в NLP или компьютерном зрении, часто показывает слабые результаты на финансовых данных. Причина в том, что такие модели обучены обобщать одни закономерности, а в реальности сталкиваются с совершенно иными. Непостоянство и нестационарность финансовых данных Нестационарность означает, что статистические свойства временного ряда меняются со временем. В финансах это проявляется через изменение волатильности, корреляций между активами и самих механизмов ценообразования. Так, к примеру, модель, обученная на данных 2024 года, может показывать случайные результаты в 2025 году из-за изменения рыночного режима. В классическом ML предполагается, что обучающая и тестовая выборки происходят из одного распределения. Это предположение — IID (independent and identically distributed, независимые и одинаково распределенные данные) — в финансах систематически нарушается. Стоит Центральному банку изменить процентную ставку, начаться геополитическому кризису или измениться структуре рынка — и распределение доходностей мгновенно сдвигается. Адаптивные подходы к обучению Финансовый ML по своей природе — динамическая система. Такая система должна уметь работать с неопределенностью и учитывать, что любая найденная закономерность со временем может потерять значимость. Метод онлайн-обучения (Online learning) позволяет модели адаптироваться к изменениям во времени за счет постепенного обновления параметров на новых данных: Вместо того чтобы переобучать модель с нуля, выполняется инкрементальное обновление весов. Такой подход помогает модели отслеживать дрейф концепта (concept drift) — ситуацию, когда взаимосвязь между признаками и целевой переменной изменяется со временем; Другой вариант — использование скользящего окна обучения фиксированной длины. В этом случае модель всегда обучается на последних N наблюдениях, автоматически «забывая» устаревшие данные. Размер окна определяется компромиссом: слишком короткое окно дает высокую дисперсию предсказаний, слишком длинное — включает неактуальные данные. Методы детекции изменений режима Даже адаптивные модели со временем начинают терять актуальность. Рыночные закономерности меняются, и без своевременного обновления модель может продолжать опираться на устаревшие зависимости, что приводит к росту ошибок и снижению прибыльности. Детекция изменений режима (Regime change detection) помогает определить момент, когда модель перестает адекватно описывать текущие рыночные условия и требует переобучения. Основные подходы: CUSUM (cumulative sum control chart) — метод, отслеживающий накопленные отклонения предсказаний от реальных значений; Тест Пейджа–Хинкли (Page-Hinkley test) — статистический критерий, выявляющий изменения в среднем значении временного ряда; Мониторинг скользящей производительности модели на последних данных; Анализ распределения ошибок предсказаний во времени, позволяющий заметить систематические сдвиги. Резкий рост показателя CUSUM указывает на возможный структурный сдвиг (structural break) — сигнал к переобучению модели или переходу на альтернативную стратегию. Проблема соотношения сигнал/шум Финансовые временные ряды характеризуются крайне низким соотношением сигнал/шум. Доходности активов на коротких временных горизонтах близки к случайному блужданию. Предсказуемая компонента часто составляет лишь доли процента, в то время как остальная часть — это шум, вызванный микроструктурой рынка, случайными сделками и особенностями ликвидности. И это еще одна особенность финансового ML. В таких областях, как компьютерное зрение или обработка речи, сигнал обычно доминирует: кошка на изображении остается кошкой, даже если изображение слегка искажено. В финансах же малейшее движение цены может быть как началом тренда, так и случайным всплеском. Это делает задачу прогнозирования финансовых временных рядов принципиально сложной и требует специальных подходов для выделения сигнала на фоне шума. Инжиниринг признаков как инструмент извлечения сигнала Основным инструментом извлечения сигнала из шума в финансовой аналитике является инжиниринг признаков (feature engineering). Вместо использования сырых цен создаются производные признаки, которые лучше отражают динамику рынка и скрытые закономерности. Примеры таких признаков: Колебания / волатильность доходностей; Отношения объемов торгов к средним значениям (volume ratios); Спреды между связанными инструментами; Микроструктурные индикаторы (например, order flow imbalance); Относительные характеристики активов внутри секторов; Агрегированные метрики рыночной активности; Проприетарные технические индикаторы (не путать с популярными типа RSI, MACD); Временные лаги и разности цен, отражающие динамику изменений; Факторные признаки (например, beta, размер компании, стоимость) и т. д. Эти признаки, как правило, содержат больше информации о будущих движениях цены, чем сами котировки. Однако и тут не все просто: информационные коэффициенты (IC) между признаками и будущими доходностями редко превышают 0.1-0.5 в абсолютном значении. Это означает, что даже комбинация лучших признаков обычно объясняет лишь небольшую долю вариации доходностей. Тем не менее, в финансовом ML такие небольшие сигналы важны: правильная комбинация множества слабых признаков может дать значимую предсказательную силу на портфельном уровне. Регуляризация и отбор признаков После того как признаки созданы, возникает еще одна ключевая задача: как извлечь из них сигнал, не подстраиваясь под шум. Для этого применяются методы регуляризации и отбора признаков (feature selection). Регуляризация помогает предотвратить переобучение на шум. Как правило используется один из следующих методов: L1-регуляризация (Lasso) автоматически отбирает наиболее информативные признаки, обнуляя веса нерелевантных; L2-регуляризация (Ridge) снижает влияние мультиколлинеарности, которая часто встречается в финансовых данных из-за высоких корреляций между родственными признаками; Elastic Net комбинирует оба подхода, объединяя преимущества L1 и L2. Методы отбора признаков включают: Рекурсивное исключение признаков (RFE, Recursive Feature Elimination) — итеративное удаление наименее значимых переменных; Важность признаков из моделей (feature importance) — показывает реальную значимость переменных; Кросс-валидация с учетом временной структуры — для оценки стабильности признаков; Тестирование на независимых временных периодах — для проверки устойчивости (робастности) модели. Комбинируя множество слабых сигналов через ансамбли нескольких моделей и тщательно отбирая признаки, можно получить предсказательную силу, достаточную для потенциально прибыльной торговли. Архитектуры моделей: что работает, а что нет в финансах Несмотря на успехи глубокого обучения (deep learning) в других областях, оно не стало стандартом в финансовом ML. Все потому, что глубокие нейронные сети требовательны к большим объемах данных и стабильных паттернов для обучения, которых в финансовых рядах зачастую просто нет. Даже минутные бары дают десятки тысяч наблюдений, но не миллионы, а нестационарность движения рядов делает исторические данные малополезными для обучения. Другие проблемы deep learning в финансах: Склонность к переобучению из-за высокой емкости моделей относительно доступного объема информативных примеров; Необходимость тонкой настройки и тестирования большого количества слоев, их архитектуры, подбора оптимального числа нейронов; Низкая интерпретируемость предсказаний; Долгая обучаемость и высокие вычислительные требования при ограниченной пользе. Техники dropout или batch normalization частично помогают, однако не решают фундаментальную проблему: сигнал в финансовых рядах слишком слаб, чтобы оправдать сложность архитектуры. Градиентные бустинги В условиях низкого соотношения сигнал/шум более простые модели и ансамбли часто оказываются эффективнее сложных глубоких сетей. В частности, градиентный бустинг (gradient boosting) сегодня продолжает доминировать в финансовых приложениях. Модели XGBoost, LightGBM и CatBoost демонстрируют лучшие результаты на структурированных финансовых данных, благодаря встроенной регуляризации и способности обрабатывать нелинейные взаимодействия признаков. Эти модели менее склонны к переобучению и требуют меньше данных для достижения хорошей обобщающей способности (генерализации). Преимущества градиентных бустингов: Встроенная регуляризация через глубину деревьев и скорость обучения (learning rate); Автоматическая обработка нелинейных взаимодействий признаков; Робастность к выбросам и пропущенным значениям, что особенно важно для финансовых временных рядов; Интерпретируемость через важность признаков и значения SHAP; Эффективность обучения на табличных данных среднего размера. Интерпретируемость в финансах важна не только для отладки моделей, но и для соблюдения регуляторных требований. В отличие от «черного ящика» deep learning, градиентные бустинговые модели позволяют объяснять решения стратегии и облегчают аудит. LSTM и рекуррентные архитектуры Архитектуры LSTM и GRU имеют ограниченную применимость в финансах. Теоретически рекуррентные сети должны хорошо извлекать долгосрочные зависимости из временных рядов, однако на практике они нестабильны: на реальных финансовых данных LSTM и GRU редко превышают результаты градиентных бустингов. Учитывая более высокие временные и вычислительные затраты приоритет тут отдается последним. Исключения, где LSTM могут быть эффективны: Моделирование высокочастотных данных с микросекундным разрешением, где важна последовательность событий; Анализ настроений (sentiment analysis) на текстовых данных, например, новостей и отчетов компаний; Обработка последовательностей ордеров в микроструктурных моделях; Задачи, где важна краткосрочная временная зависимость (несколько шагов вперед). Для большинства задач прогнозирования доходностей на основе исторических цен и объемов использование моделей XGBoost, LightGBM и CatBoost остается более надежным и интерпретируемым выбором. Линейные модели с инжинирингом признаков Это удивительно, но даже в таких сложных задачах, как прогнозирование финансовых временных рядов, простые линейные модели с тщательно подобранными признаками (feature engineering) иногда превосходят трансформеры и прочие сложные нелинейные архитектуры. Например, Ridge regression — линейная модель с L2-регуляризацией с правильно сконструированными признаками часто обучают как бейзлайн (baseline, базовый уровень качества), который сложные модели не всегда могут превзойти. Причина в низком соотношении сигнал/шум: простота модели помогает избежать подгонки под шум. Еще один плюс линейных моделей - они учатся крайне быстро и так же быстро можно проверять различные гипотезы. Однако профессионалы редко используют только линейные модели для прогнозирования. Чаще всего они так и остаются бейзлайном, либо используются в стекинге. Комбинирование моделей разной природы через стекинг (stacking) позволяет повысить качество и устойчивость предсказаний. Каждая архитектура улавливает разные аспекты данных: Линейные модели — глобальные тренды; Деревья в бустингах — локальные нелинейности. В ансамбль можно включить и нейронные сети, в т. ч. трансформеры, но пока это оправдано только в тех сферах, где они доказали свою силу — например, в анализе сентимента и прогнозировании на основе текстовой информации. Таким образом формируется ансамбль моделей, который снижает риск того, что слабость одной модели приведет к провалу стратегии, и позволяет использовать преимущества разных подходов одновременно. Утечка будущей информации и временная валидация В финансовом ML важны не только разнообразие и важность признаков, но и время их появления. Утечка будущей информации (Look-ahead bias) возникает, когда модель использует данные, недоступные на момент предсказания. Это одна из самых частых причин провала стратегий при переходе от бэктестов к реальной торговле. Использование будущих данных для вычисления признаков, некорректная нормализация или утечка информации через таргет создают иллюзию предсказательной силы. Типичные источники look-ahead bias: Использование будущих цен для нормализации или вычисления статистик (например, скользящих средних); Признаки, рассчитанные на всем датасете без учета временной последовательности (например, стандартное отклонение доходностей за весь период вместо скользящего окна); Некорректное заполнение пропусков с использованием будущих значений; Утечка информации через таргет при конструировании признаков (использование будущей доходности для создания бинарного индикатора движения цены). В классическом ML случайное разделение данных на train/test допустимо благодаря предположению IID (independent and identically distributed). В финансах же данные упорядочены во времени, и случайное разделение гарантирует утечку информации из будущего в прошлое, создавая иллюзию более высокой предсказательной мощности модели. Скользящая валидация через Walk-forward Метод Walk-forward validation разделяет данные строго по времени. Модель обучается на исторических данных до момента T, затем тестируется на периоде [T, T+W], после чего окно сдвигается вперед. Такой подход имитирует реальный процесс торговли: модель постепенно обновляется на новых данных и делает предсказания на неизвестном будущем. Основные параметры walk-forward validation: Размер обучающего окна (training window): фиксированный или расширяющийся; Размер тестового окна (test window): обычно 10–20% от размера обучающего окна; Частота переобучения (retraining frequency): ежедневно, еженедельно или ежемесячно; Тип окна: anchored (фиксированное начало) или rolling window (скользящее окно). Расширяющееся окно использует все доступные исторические данные, тогда как скользящее окно фиксированной длины отбрасывает старые наблюдения. Выбор зависит от предположений о стационарности рынка: в нестационарных условиях скользящее окно обычно предпочтительнее, так как оно лучше адаптируется к текущим рыночным условиям. Исключение перекрытий (Purging) и временной буфер между обучением и тестом (Embargo period) Метод исключения перекрытий Purging решает проблему пересечения временных интервалов между обучающей и тестовой выборками. Если признаки рассчитываются с помощью скользящего окна (например, 20-дневная волатильность), то данные из тестовой выборки могут частично зависеть от информации, использованной в обучении. Чтобы избежать этого, purging удаляет из обучающей выборки все наблюдения, временные окна которых пересекаются с тестовым периодом. Embargo period — это дополнительный временной “буфер” между train и test. После окончания обучающей выборки исключается промежуток длиной E рядов перед началом тестовой выборки. Это делается для того, чтобы учесть возможные признаки, которые могут косвенно содержать информацию о будущем (так называемые forward-looking признаки). Я рекомендую устанавливать Embargo period в размере 1–5% от длины обучающей выборки, чтобы снизить риск утечки данных. А Purging применять ко всем признакам, которые рассчитываются с использованием временных окон. Комбинация purging и embargo обеспечивает “чистую” и честную валидацию модели. Разумеется, метрики при такой валидации будут немного хуже (пессимистичнее), но они лучше отражают реальную производительность модели в продакшене и позволят избежать неожиданных провалов стратегии при ее запуске. Конструирование таргетов и работа с дисбалансом Конструирование таргета в финансах принципиально отличается от классических задач ML. В компьютерном зрении таргет известен: изображение содержит кошку или собаку. В финансах же нужно явно определить, что считать успешным исходом. К тому же, фиксированный временной горизонт (к примеру, доходность через 5 дней) создает произвольность: почему 5 дней, а не 3 или 10? Проблемы фиксированных горизонтов: Игнорирование внутрипериодной динамики цены; Невозможность зафиксировать прибыль при достижении цели до истечения горизонта; Отсутствие управления риском через stop-loss; Зависимость результатов от выбора конкретного горизонта. Эти проблемы приводят к зашумленным таргетам, которые не отражают реальную торговую логику. Метод Triple barrier Метод Triple barrier решает эту проблему через определение 3-х условий выхода из позиции: верхний барьер (take-profit), нижний барьер (stop-loss) и временной лимит. Таргет определяется как первое достигнутое условие. Это отражает реальную торговую логику: позиция закрывается при достижении цели прибыли, ограничения убытка или истечении времени удержания. Параметры метода: Барьеры могут быть симметричными (±2%) или асимметричными (take-profit на +3%, stop-loss на -1.5%); Динамические барьеры адаптируются к текущей волатильности; Временной лимит предотвращает бесконечное удержание позиций; В периоды высокой волатильности барьеры расширяются автоматически. Временной лимит важен, так как отсутствие движения также содержит информацию: если цена не двигается, базовый сигнал был слабым. Включение таких меток в обучающую выборку улучшает способность модели различать сильные и слабые сигналы. Fractional differentiation Большинство моделей прогнозирования работают лучше на стационарных рядах. Нестационарные ряды, как правило дифференцируют. Такой подход помогает сгладить дисперсию, устранить тренд и сезонность, делая ряд более пригодным для анализа и прогнозирования. Обычное дифференцирование (разности цен) полностью решает задачу стационарности, но делает это «жестко»: ряд становится стационарным, но теряет долгосрочную информацию. Метод дробного дифференцирования (fractional differentiation) обеспечивает баланс между стационарностью и сохранением долгосрочной памяти временного ряда. Чтобы сохранить часть памяти о прошлом и одновременно уменьшить нестабильность, используют дробное дифференцирование порядка d, где 0 < d < 1. Параметр d подбирается через минимизацию ADF statistic при сохранении максимальной корреляции с исходным рядом. Для финансовых данных значения d в диапазоне 0.3–0.6 часто оптимальны: ряд становится достаточно стационарным для моделирования, но сохраняет информацию о трендах и долгосрочных зависимостях. Специфика дисбаланса классов Дисбаланс классов в финансах отличается от классического понимания в машинном обучении. В трейдинге прибыльных сделок может быть 48%, убыточных 52% — формально классы почти сбалансированы. Но важна не частота, а величина выигрышей и проигрышей. Так, к примеру, стратегия с 40% прибыльных сделок может быть успешной, если средний выигрыш вдвое превышает средний проигрыш. Вот еще несколько отличий финансового дисбаланса от классического ML: Важна величина результата (outcome), а не только его знак; Редкие события могут быть особенно ценными (например, крупные движения рынка); Временная зависимость меток создает дополнительные сложности для обучения; Стоимость ошибок асимметрична и зависит от размера позиции и риска. Классические методы борьбы с дисбалансом, такие как методы увеличения минорного класса (SMOTE), либо наоборот уменьшения преобладающего (undersampling), не учитывают этих особенностей и могут быть неэффективны для финансовых временных рядов. Мета-лейбелинг и веса наблюдений Мета-лейбелинг (meta-labeling) лучше подходит для решения проблемы дисбаланса, потому что в трейдинге важна не только частота сигналов, но и их качество. Редкие, но сильные сигналы могут быть ценнее частых слабых. Идея мета-лейбелинга простая: Первичная модель генерирует торговые сигналы (long/short); Вторичная мета-модель предсказывает, стоит ли открывать позицию по каждому сигналу, оценивая его «качество» — уверенность предсказания, волатильность, ликвидность и другие характеристики рынка. Так фильтруются слабые сигналы, а капитал концентрируется на самых перспективных возможностях. Для работы с перекрывающимися сигналами применяются веса наблюдений (sample weights). При методе тройного барьера (triple barrier method) одно наблюдение может участвовать в нескольких метках. Те наблюдения, что встречаются реже, получают больший вес при обучении, что помогает модели концентрироваться на уникальных событиях. Плюсы такого подхода: Снижение дублирования сигналов; Повышение устойчивости и точности модели; Фокус на наиболее значимых событиях; Уменьшение переобучения на повторяющихся паттернах; Возможность интеграции мета-лейбелинга в существующие стратегии без полной перестройки. Метрики оценки качества: почему accuracy не работает в финансовом ML Показатель верности прогнозов Accuracy почти никто не использует в финансовом ML, в отличие от других сфер. Он мало говорит о том, насколько модель прибыльна на рынке: предсказание направления движения цены с точностью 52% может приносить прибыль, только если движение рынка по верным прогнозам в пунктах больше, чем по ошибочным. Кроме того, Accuracy не учитывает асимметрию выигрышей и проигрышей. На самом деле, проблема касается не только Accuracy. Многие популярные метрики машинного обучения в действительности не отражают экономическую эффективность стратегии: Precision / Recall измеряют качество классификации, но игнорируют величину прибыли и убытков, возникающих при ошибках; MSE / MAE минимизируют среднюю ошибку предсказаний, не учитывая, что в финансах малые ошибки на крупных движениях важнее, чем большие ошибки на мелких; AUC-ROC оценивает способность модели различать классы, но не коррелирует с доходностью или риском стратегии, и может вводить в заблуждение при принятии торговых решений. В финансовом машинном обучении метрики должны отражать реальную цель стратегии: максимизацию доходности с учетом риска. Коэффициент Шарпа (Sharpe ratio) Коэффициент Шарпа измеряет доходность с поправкой на риск. Это отношение средней избыточной доходности к стандартному отклонению доходностей: SR = E[R - Rf] / σ[R] где: R — доходность стратегии; Rf — безрисковая ставка; σ[R] — волатильность доходностей; E[·] — математическое ожидание. Sharpe ratio показывает, сколько избыточной доходности получает стратегия на единицу принятого риска. Значения выше 1.0 считаются хорошим результатом, выше 2.0 — отличным. Этот коэффициент напрямую связан с практической ценностью стратегии: инвесторы оценивают не только доходность, но и волатильность. Резкие просадки неприемлемы даже при высокой средней доходности. Sharpe ratio балансирует эти аспекты. Так, модель с accuracy 53% и Sharpe ratio 1.5 предпочтительнее модели с accuracy 58% и Sharpe ratio 0.8. Precision at top K Показатель точности среди К лучших предсказаний (Precision at top K) оценивает качество ранжирования сигналов. В финансовых стратегиях модель может генерировать предсказания для сотен активов, но торговать реально можно лишь по ограниченному числу — например, по топ-50 сигналам из-за ограничений капитала и ликвидности. Precision at K измеряет долю прибыльных позиций среди K лучших предсказаний, показывая, насколько хорошо модель выбирает наиболее перспективные активы. Эта метрика особенно важна для long-short стратегий, поскольку позволяет оценить способность модели одновременно выделять лучшие активы для покупки и худшие для продажи. Например, precision at top 10% и bottom 10% показывает, насколько надежны крайние предсказания: в средних прогнозах уверенность небольшая, однако экстремальные должны быть максимально точными. В отличие от общей точности, Precision at top K отражает реальные условия торговли, где важно не угадывать каждый сигнал, а правильно выделять ограниченное число лучших возможностей. Directional accuracy и Cost-sensitive learning Показатель Directional accuracy учитывает правильность предсказания направления движения цены. В отличие от обычной accuracy, этот показатель можно взвешивать по величине движения (в трейдинге правильное предсказание движения на 5% важнее правильного предсказания движения на 2.5%): Weighted Directional Accuracy = Σ (wᵢ × 1(sign(yᵢ) = sign(ŷᵢ))) / Σ wᵢ где: wᵢ — величина движения (например, процентное изменение цены); yᵢ — фактическое изменение цены; ŷᵢ — предсказанное изменение цены; 1(·) — индикатор правильного предсказания (1, если знак предсказания совпадает с фактом, 0 иначе). Взвешенная directional accuracy коррелирует с прибыльностью стратегии лучше, чем невзвешенная. Показатель Cost-sensitive learning включает транзакционные издержки в функцию потерь. Каждая сделка несет комиссии и проскальзывание — разницу между ожидаемой и реализованной ценой исполнения. Модель, предсказывающая множество слабых сигналов с высокой частотой смены позиций, может быть убыточной из-за накопленных издержек, даже при хороших Precision и Directional accuracy. Формула модифицированной функции потерь: L = -∑(profit_i - cost_i) где: profit_i — прибыль от i-й сделки; cost_i — транзакционные издержки; L — итоговая функция потерь для минимизации. Модель оптимизирует не чистую точность предсказаний, а прибыль после издержек. Это приводит к генерации меньшего количества более уверенных сигналов. Улучшение прогнозов через ансамбли моделей и стекинг В финансовом ML ансамбли моделей и стекинг применяются значительно чаще, чем в других областях машинного обучения. Причина в изменчивости рынка. Разные модели по-разному обобщают рыночные данные: одни хорошо работают в трендовых периодах, другие — в фазах коррекции, третьи — в периоды высокой волатильности. В таких условиях комбинация моделей становится естественным шагом: ансамбли и стекинг (stacking) позволяют объединять сильные стороны разных подходов, снижать зависимость от ошибок отдельной модели и повышать устойчивость стратегии на реальных рынках. Преимущества ансамблирования: Снижение дисперсии итогового предсказания через усреднение независимых ошибок; Робастность к изменениям рыночного режима; Автоматическая адаптация к текущим условиям через взвешивание; Диверсификация источников сигнала. Простое усреднение предсказаний нескольких моделей уже дает улучшение. Если модели делают независимые ошибки, усреднение снижает дисперсию итогового предсказания. Разумеется, гарантий прироста метрик нет. Кроме того, в биржевом анализе ошибки моделей часто коррелируют друг с другом (все модели в той или иной степени начинаются больше ошибаться в кризисы), однако частичная независимость все равно приносит пользу. Подходы к комбинированию моделей Одним из самых простых и эффективных способов комбинирования моделей является взвешенное усреднение (weighted averaging). Каждой модели назначается вес пропорционально ее исторической производительности. Веса обычно пересчитываются на скользящем окне: модели, показавшие лучшие результаты в последние N периодов, получают больший вес. Такой подход позволяет ансамблю адаптироваться к изменяющимся рыночным условиям, автоматически усиливая влияние моделей, наиболее подходящих текущему режиму. Основные методы назначения весов: Равные веса (baseline) — все модели имеют одинаковый вклад, простой и устойчивый метод; Inverse-variance weighting — модели с меньшей дисперсией ошибок получают больший вес; На основе Sharpe ratio — учитывает не только точность, но и риск модели; Оптимизация через квадратичное программирование — минимизация дисперсии ансамбля с учетом ограничений; Динамическое взвешивание через экспоненциальное сглаживание — вес модели плавно изменяется в зависимости от последних результатов. Выбор метода зависит от стабильности производительности базовых моделей и объема данных, доступного для оценки весов. В хедж-фондах иногда используют комбинацию нескольких подходов для повышения робастности моделей. Стекинг и мета-модели Стекинг позволяет мета-модели учиться комбинировать базовые модели, учитывая их сильные и слабые стороны в разных рыночных условиях: Базовые модели (уровень 0) генерируют предсказания, которые затем становятся признаками для мета-модели (уровень 1); Мета-модель учится оптимально комбинировать эти предсказания, добавляя больше веса сильным сигналам и уменьшая слабым. Для предотвращения переобучения рекомендуется использовать предсказания на отложенных фолдах (out-of-fold predictions). Если же обучать мета-модель на предсказаниях, полученных на обучающей выборке базовых моделей, она повторяет ошибки и артефакты этих моделей. Out-of-fold предсказания получают через кросс-валидацию: каждая базовая модель делает предсказания на фолдах, на которых она не обучалась. Процесс построения стекинга: Разделить данные на K фолдов с учетом временной структуры; Для каждого фолда обучить базовые модели на остальных K-1 фолдах; Сгенерировать out-of-fold предсказания для каждой базовой модели; Использовать эти предсказания как признаки для обучения мета-модели; Обучить финальные базовые модели на всех данных для продакшена. Практические аспекты внедрения ансамблей Ансамбли моделей требуют большей вычислительной мощности и усложняют инфраструктуру. В продакшене необходимо поддерживать несколько моделей одновременно, синхронизировать их обновления и агрегировать предсказания в реальном времени. Основные инфраструктурные требования: Параллельное выполнение предсказаний базовых моделей; Версионирование моделей и синхронизация обновлений; Мониторинг производительности каждой компоненты ансамбля; Механизмы отката к прежним версиям при сбое отдельных моделей; Логирование предсказаний для последующего анализа и улучшения модели. Несмотря на дополнительные ресурсы и сложность, использование ансамблей оправдано при профессиональному подходе к финансовому ML. Они повышают устойчивость системы, снижают риски критичных ошибок в прогнозах и позволяют автоматически адаптироваться к изменяющимся рыночным условиям через перевзвешивание компонентов. Заключение Финансовый ML принципиально отличается от классических применений машинного обучения. Нестационарность, низкое соотношение сигнал/шум, изменчивость рынков и необходимость учитывать множество факторов, включая "черных лебедей", делают прямое применение стандартных подходов часто неэффективным. Модели, которые показывают выдающиеся результаты в computer vision или NLP, на финансовых данных могут полностью провалиться. Чтобы успешно применять ML в финансах, недостаточно просто использовать стандартные алгоритмы. Необходимо учитывать, что закономерности в данных меняются со временем, а одна и та же стратегия может работать в одних рыночных условиях и полностью проваливаться в других. Ключевыми аспектами становятся правильный выбор метрик, корректная временная валидация, учет дисбаланса и экономической значимости ошибок, а также адаптивность моделей через регуляризацию, ансамбли и перевзвешивание. ### Показатели ликвидности акций и методы их расчета Ликвидность определяет возможность быстро купить или продать актив без существенного влияния на его цену. Для алгоритмической торговли это критический параметр: низкая ликвидность увеличивает издержки исполнения, ограничивает размер позиций и повышает риски проскальзываний. Количественная оценка ликвидности позволяет фильтровать торгуемую вселенную, оптимизировать исполнение ордеров и лучше управлять рисками портфеля. Ликвидность имеет несколько измерений: объем торгов, ширину спреда, глубину рынка и воздействие на цену. Каждое измерение требует специфических метрик и методов расчета. Основные показатели ликвидности Объемные метрики Average Daily Volume (ADV) — базовый показатель, отражающий среднее количество акций, торгуемых за день. Расчет выполняется как простое (SMA) или экспоненциальное скользящее среднее (EMA) за период от 20 до 90 торговых дней: ADV = (V₁ + V₂ + ... + Vₙ) / n где: Vᵢ — объем торгов в день i (в акциях); n — количество торговых дней в периоде; ADV — средний дневной объем (акции). Метрика показывает типичную активность по инструменту, но не учитывает стоимость акций. Акция с ценой $10 и объемом 1 млн акций имеет ту же ADV, что и акция за $100 с тем же объемом, хотя ликвидность в денежном выражении различается в 10 раз. Показатель Dollar Volume устраняет эту проблему, измеряя ликвидность в денежном эквиваленте. Формула расчета: Dollar Volume = ADV × Price где: Price — обычно используется средняя цена за период или цена закрытия; Dollar Volume — средний дневной оборот в долларах. Для институциональной торговли релевантен именно Dollar Volume. Позиция в $10 млн составляет 10% от дневного оборота при Dollar Volume $100 млн, что создает значительный риск влияния на рынок (price impact) независимо от количества акций. Еще одна часто используемая объемная метрика ликвидности — Free Float. Это доля акций, доступных для свободного обращения (исключая стратегические пакеты инсайдеров, государства, акции с ограничениями на продажу, которые нельзя продавать в течение определенного периода времени). Показатель Free Float важен для оценки реальной глубины рынка. Формула его расчета: Free Float Ratio = Free Float Shares / Total Shares Outstanding где: Free Float Shares — акции в свободном обращении; Total Shares Outstanding — общее количество выпущенных акций; Free Float Ratio — доля свободно торгуемых акций (обычно 0.3-0.9). Низкий Free Float при высоком ADV сигнализирует о потенциальной повышенной волатильности, поскольку при малом числе акций в обращении даже небольшой дисбаланс спроса и предложения приводит к резким ценовым движениям, частым гэпам и проскальзываниям. Спред и его производные Bid-Ask Spread — это разница между лучшей ценой покупки и продажи в книге заявок (order book). Абсолютный спред измеряется в пунктах цены: Absolute Spread = Ask − Bid где: Ask — лучшая цена продажи; Bid — лучшая цена покупки; Absolute Spread — ширина спреда в валюте котировки. Относительный спред (Relative Spread) нормализует метрику для сравнения инструментов с разными ценами. Он измеряется в процентах от средней цены и рассчитывается по формуле: Relative Spread = (Ask − Bid) / Midpoint × 100% Здесь Midpoint равен (Ask + Bid) / 2. Относительный спред 0.05% считается узким для ликвидных акций, 0.5% и выше указывает на низкую ликвидность. Метрика чувствительна к микроструктуре рынка: в периоды высокой волатильности спред расширяется даже для ликвидных инструментов. Еще один показатель - эффективный спред (Effective Spread) измеряет реальные издержки исполнения сделки относительно средних цен в момент размещения ордера: Effective Spread = 2 × |Trade Price − Midpoint| / Midpoint × 100% где: Trade Price — цена исполнения сделки; Midpoint — среднее значение между ценами покупки (bid) и продажи (ask) на момент сделки; Множитель 2 нормализует метрику к формату затрат на полный цикл сделки (round-trip cost). Effective Spread часто оказывается меньше котируемого спреда благодаря тому, что сделка исполняется по более выгодной цене, чем текущая лучшая котировка bid или ask. Это может происходить при исполнении ордеров внутри спреда, либо при предоставлении дополнительной ликвидности маркет-мейкерами. Для алгоритмической эффективный спред позволяет получить наиболее точную оценку транзакционных издержек. Еще одна метрика - реализованный спред (Realized Spread) отражает фактическую прибыль маркет-мейкера после учета изменения рыночной цены в течение короткого периода после сделки. Метрика показывает, насколько выгодной оказалась сделка для поставщика ликвидности с учетом последующего движения цены — то есть после того, как рынок скорректировался к новому равновесию. Формула расчета реализованного спреда следующая: Realized Spread = 2 × Side × (Trade Price − Midpoint_t+Δ) где: Side может принимать значения +1 для покупки, −1 для продажи; Midpoint_t+Δ — средняя цена через интервал Δ после сделки (обычно 1-5 минут). Разница между Effective Spread и Realized Spread отражает так называемую стоимость неблагоприятного отбора (adverse selection cost) — то есть потенциальные убытки маркет-мейкера, возникающие при торговле с информированными участниками рынка. Высокое значение adverse selection cost указывает на повышенный уровень информированного потока ордеров (informed flow) или активность высокочастотных трейдеров (HFT), которые способны предвосхищать будущие изменения цен. В таких условиях маркет-мейкеры чаще несут убытки, поскольку их котировки используются информированными участниками для извлечения выгоды из ожидаемого движения цены. Показатели глубины рынка Глубина стакана заявок (Order Book Depth) — это показатель, отражающий объем заявок на покупку и продажу на определенных ценовых уровнях или в пределах спреда. Он характеризует ликвидность рынка и способность участников исполнять крупные ордера без существенного влияния на цену. Формула расчета Order Book Depth: Depth at Level k = ∑(Volume_i) for i ∈ [1, k] где: Volumeᵢ — объем заявок на уровне i ордербука; k — количество учитываемых уровней (например, top 5 или top 10); Depth — совокупный объем, выраженный в акциях или денежном эквиваленте. Чем глубже стакан, тем более устойчив рынок к крупным сделкам и тем меньше проскальзывание при исполнении ордеров. Глубокий стакан повышает рыночную ликвидность и снижает волатильность котировок. Однако видимая глубина не всегда отражает реальную ликвидность. На практике она может быть искажена: Скрытыми (iceberg) ордерами — когда часть объема намеренно не отображается в публичных котировках; Динамическим управлением котировками маркет-мейкеров, которые быстро обновляют заявки в ответ на изменение рыночных условий. Поэтому при анализе глубины ордербука важно учитывать, что фактическая ликвидность может отличаться от наблюдаемой. Дисбаланс глубины рынка (Market Depth Imbalance) — это индикатор, оценивающий соотношение спроса и предложения в ордербуке. Он показывает, какая сторона: покупатели или продавцы доминирует на рынке в данный момент. Imbalance принимает значения в диапазоне от −1 до +1, а его формула расчета следующая: Imbalance = (Bid Volume − Ask Volume) / (Bid Volume + Ask Volume) где: Bid Volume — совокупный объем заявок на покупку (обычно учитываются топ 5–10 уровней стакана); Ask Volume — совокупный объем заявок на продажу.. Положительное значение Imbalance (преобладание заявок на покупку) указывает на повышенный спрос и нередко предшествует росту цены. Отрицательное значение (преобладание заявок на продажу) отражает избыточное предложение и может сигнализировать о снижении цены. Эта метрика широко используется в краткосрочных торговых стратегиях и execution-алгоритмах, где важно точно определить тайминг входа и выхода из позиции. Высокие значения дисбаланса помогают алгоритмам прогнозировать вероятное направление движения цены и оптимизировать момент исполнения ордеров. Кумулятивный профиль глубины (Cumulative Depth Profile) строится путем суммирования объемов заявок по мере удаления от midpoint — средней цены между лучшими bid и ask. Его формула: Cumulative Depth(p) = ∑ Volume_i   для всех уровней на расстоянии p от midpoint где: p — ценовое расстояние от midpoint (в пунктах или процентах); Cumulative Depth — совокупный объем, доступный для исполнения при движении цены на величину p. График Cumulative Depth позволяет визуализировать, какой объем можно исполнить при заданном price impact. Резкие перепады на графике часто указывают на крупные лимитные ордера, которые могут служить уровнями краткосрочной поддержки или сопротивления. Такой анализ помогает оценить рыночную ликвидность, плюс понять, насколько легко можно реализовать крупные сделки, и прогнозировать потенциальное влияние больших ордеров на цену в ближайшей перспективе. Метрики воздействия на цену Коэффициент неликвидности Amihud (Amihud Illiquidity Ratio) измеряет влияние объема торгов на движение цены, показывая, насколько сильно цена реагирует на каждую единицу объема. Рассчитывается он следующим образом: Amihud = (1/n) × ∑(|Return_i| / Dollar Volume_i) где: Return_i — дневная доходность в день i (обычно |log(P_i / P_i-1)|); Dollar Volume_i — оборот в долларах в день i; n — количество дней в периоде (обычно 20-250). Метрика показывает, насколько чувствительна цена к объему торгов. Высокий Amihud Ratio указывает, что даже относительно небольшие сделки могут вызывать существенное движение цены, что сигнализирует о низкой ликвидности выбранного актива. Коэффициент Amihud особенно полезен для сравнения ликвидности различных инструментов и оценки изменений ликвидности во времени, позволяя аналитикам и трейдерам корректно оценивать риск исполнения крупных ордеров и потенциальное проскальзывание. Показатель Price Impact per Million оценивает изменение цены при исполнении ордера на $1 млн. Что позволяет оценить транзакционные издержки, связанные с влиянием объема на цену: Price Impact = β × (Order Size / ADV)^γ где: Order Size — размер ордера в долларах; ADV — средний дневной оборот в долларах; β — коэффициент impact (зависит от инструмента и рынка); γ — показатель степени (эмпирически около 0.5-0.7). Нелинейная зависимость отражает реальную микроструктуру рынка: x2 размера ордера не приводит к x2 price impact. Параметры β и γ обычно калибруются на исторических данных по исполнению ордеров или берутся из моделей transaction cost analysis (TCA). Учет данного показателя позволяет трейдерам и аналитикам прогнозировать стоимость входа и выхода из позиции, корректно учитывая влияние больших ордеров на рынок и потенциальное проскальзывание. Метрика Kyle’s Lambda (λ) определяет чувствительность цены к потоку ордеров (order flow) и отражает информационную составляющую price impact — то, насколько рынок воспринимает торговые потоки как сигнал о будущей цене: λ = Cov(ΔPrice, Order Flow) / Var(Order Flow) где: ΔPrice — изменение midpoint за период; Order Flow — подписанный объем сделок (положительный для покупок, отрицательный для продаж); λ (lambda) — коэффициент Kyle (размерность: $/акция). Высокое значение λ указывает на то, что рынок интерпретирует order flow как информационный сигнал, и цены корректируются в соответствии с этим. Лямбда широко используется в моделях оптимального исполнения (optimal execution models) для прогнозирования временного влияния ордеров на цену (temporary price impact) и планирования стратегии разбиения крупных сделок на части с минимальными издержками. Методы расчета показателей ликвидности Расчет на основе биржевых данных Большинство метрик ликвидности вычисляется на основе 3-х типов рыночных данных: OHLCV (Open, High, Low, Close, Volume); Тиковые данные (каждая сделка); Снимки книги заявок (Order book snapshots). OHLCV данных достаточно для расчета базовых метрик ликвидности, таких как: Average Daily Volume (ADV), Dollar Volume, Amihud Illiquidity Ratio. Расчет обычно выполняется путем агрегации дневных значений с использованием скользящих окон. Например, для ADV типично применяют окно 20–60 дней, а для Amihud Ratio — 20–250 дней, в зависимости от временного горизонта стратегии. Тиковые данные необходимы для вычисления Effective Spread и Realized Spread. При этом каждая сделка содержит: временные метки (timestamp); цену исполнения; объем; направление (aggressor side). Midpoint на момент сделки восстанавливается из синхронизированных данных книги заявок, либо аппроксимируется как среднее между ценами последовательных сделок противоположного направления. Данные ордербука используются для расчета метрик глубины рынка и присутствующего дисбаланса: Level 1 (best bid/ask) — достаточно для расчета спредов; Level 2 (топ 5–10 уровней) — для глубинных метрик (Depth, Cumulative Depth); Полный order book (Level 3) — для детального анализа микроструктуры рынка. Снимки книги заявок - это история изменений ордербука. Частота таких снимков может варьироваться от 1 секунды до 100 миллисекунд в зависимости от целей анализа. Важно не забывать про синхронизацию данных из разных источников. Данные по сделкам и из ордербука должны иметь общую временную метку с точностью до десятков миллисекунд, поскольку асинхронность приводит к искажению метрик (например, Effective Spread может быть рассчитан относительно устаревшего midpoint, что завышает оценку транзакционных издержек). Временные горизонты измерения По временным горизонтам метрики ликвидности различают на: Внутридневные (intraday); Дневные; Недельные; Месячные. Внутридневные метрики измеряют ликвидность в течение торговой сессии. Интересно отметить, что сезонность присутствует и здесь. Например, минимальная ликвидность наблюдается в первые и последние 30 минут торгов (market open и close), максимальная — в середине сессии. Для корректной оценки расчет выполняется отдельно для разных временных сегментов. Типичная декомпозиция торговой сессии: Opening auction (первые 5-10 минут): широкие спреды, высокая волатильность; Morning session (10:00-12:00): стабильная ликвидность; Lunch period (12:00-14:00): снижение активности на некоторых рынках; Afternoon session (14:00-15:30): пиковая ликвидность; Closing auction (последние 30 минут): расширение спредов, рост объемов. Дневные метрики агрегируют данные за полную торговую сессию. В этом случае ADV и Dollar Volume рассчитываются как среднее за n дней, при этом выбор n зависит от целей: 20 дней для краткосрочной торговли, 60-90 дней для портфельных стратегий. Использование простого скользящего среднего предпочтительнее экспоненциального для объемных метрик, так как дает более стабильную оценку без переоценки недавних аномалий. Для долгосрочного анализа трендов ликвидности часто используют недельные и месячные агрегации. Такой подход позволяет выявлять сезонные паттерны и циклы активности на рынке, в том числе: снижение ликвидности перед праздниками; летний период с низкой торговой активностью; квартальные пики, связанные с ребалансировкой индексных фондов. Для сглаживания краткосрочных аномалий и выявления устойчивых трендов применяются скользящие окна с перекрытием. Например, 4-недельное скользящее среднее с недельным шагом позволяет нивелировать случайные всплески активности и получить более точное представление о динамике ликвидности. Такой метод анализа особенно полезен для портфельных стратегий, риск-менеджмента и прогнозирования транзакционных издержек, где важно учитывать как краткосрочные, так и сезонные колебания ликвидности. Нормализация и сравнение Абсолютные значения метрик ликвидности иногда несопоставимы при торговле разными инструментами: из-за различий в ценах, капитализации и отраслевой специфике. Для корректного сравнения и построения рейтингов применяют нормализацию, которая приводит метрики к единой шкале и позволяет формировать композитный скор ликвидности. Наиболее популярный метод нормализации - Z-score. Z-скор нормализует данные через стандартные отклонения от среднего и вычисляется по формуле: Z = (X − μ) / σ где: X — значение метрики для инструмента; μ — среднее значение по группе (например, весь рынок или сектор); σ — стандартное отклонение; Z — нормализованное значение. Интерпретируется этот скор так: инструмент с Z = 2 имеет ликвидность на два стандартных отклонения выше среднего. Важно учитывать, что данный метод чувствителен к выбросам: один неликвидный инструмент смещает среднее и искажает Z-scores остальных. В этом случае можно заменить среднее на медиану, а стандартное отклонение на MAD (Median Absolute Deviation). Метод Percentile ranking присваивает каждому инструменту процентиль (от 0 до 100) в распределении метрики: Percentile = (Rank − 1) / (N − 1) × 100 где: Rank — порядковый номер инструмента при сортировке по метрике; N — общее количество инструментов. Интерпретация: инструмент в 90-м процентиле ликвиднее 90% остальных. Такой процентильный рейтинг устойчив к выбросам и не требует каких-либо предположений о свойствах распределений, однако может терять информацию о величине различий: инструменты с близкими значениями могут попасть в разные процентили. Метод Cross-sectional Standardization нормализует метрики внутри групп (например, по секторам, размеру капитализации или географическому региону). Его обычно используют при оценке ликвидности активов в разных секторах рынка. К примеру, сравнивать акции банковского сектора и технологического сектора по абсолютным значениям Dollar Volume некорректно, так как финансовый сектор по своей природе обладает более высокой ликвидностью. Группировка инструментов по группе сопоставимых компаний (peer group) устраняет структурные различия и позволяет оценивать относительную ликвидность внутри группы. Метод Time-series Normalization учитывает историческую динамику метрики для каждого инструмента. Формула метода: Z_t = (X_t − μ_historical) / σ_historical ​ где: X_t — текущее значение метрики; μ_historical — среднее значение за исторический период (например, 1–2 года); σ_historical — стандартное отклонение за тот же период; Z_t — отклонение текущей ликвидности от типичного уровня. Метод позволяет выявлять аномалии и резкие изменения ликвидности. Например, Z_t < −2 сигнализирует о внезапном снижении ликвидности относительно нормального уровня для данного инструмента. Такой подход полезен для мониторинга рисков, так как резкое падение ликвидности может предшествовать корпоративным событиям или кризисным ситуациям на рынке. Composite Liquidity Score Отдельные метрики ликвидности измеряют различные аспекты: объем отражает активность, спред — издержки, depth — устойчивость к крупным ордерам, price impact — информационную эффективность. Composite score агрегирует несколько метрик в единый показатель для ранжирования инструментов. Простейший подход — расчет сводного показателя ликвидности, представляющего собой равновзвешенное среднее нормализованных метрик: Liquidity Score = (1/k) × ∑ Z_i здесь: Z_i — нормализованное значение метрики i (Z-score или percentile); k — количество метрик. Метод предполагает, что все метрики в равной степени важны. На практике релевантность зависит от стратегии: для HFT критичны спред и order book depth, для портфельного управления — Dollar Volume и price impact. Взвешенный composite score присваивает метрикам различные веса: Liquidity Score = ∑ (w_i × Z_i) где: w_i — вес метрики i (∑ w_i = 1); Z_i — нормализованное значение метрики i. Веса определяются на основе корреляции метрик с целевой переменной (например, реальные издержки исполнения) или экспертных оценок. Типичное распределение для портфельных стратегий: Dollar Volume (30%), Amihud Ratio (25%), Relative Spread (25%), Market Depth (20%). Еще для оценки ликвидности можно использовать метод PCA (Principal Component Analysis). Он извлекает главные компоненты из набора метрик ликвидности, то есть компоненты объясняющие максимальную дисперсию. Формула расчета PCA: PC1 = ∑ (loading_i × Z_i) где: loading_i — нагрузка метрики i на первую главную компоненту; PC1 — первая главная компонента (обычно объясняет 50-70% дисперсии). Первая компонента часто интерпретируется как общий фактор ликвидности, вторая — как контраст между объемными метриками и метриками спреда. Метод PCA хорошо устраняет лишний шум, размерность, устраняет мультиколлинеарность, однако усложняет интерпретацию результата. Использование оценки ликвидности в алгоритмической торговле Фильтрация торгуемой вселенной Ликвидность часто выступает первичным фильтром при отборе инструментов для алгоритмической торговли. Все потому, что неликвидные акции (фьючерсы, активы) создают существенные риски: Высокие транзакционные издержки; Невозможность быстро закрыть позицию; Значительные ценовые разрывы (gaps) при исполнении крупных ордеров. Минимальные критерии для отбора инструментов: Dollar Volume > $5–10 млн для внутридневных стратегий; Dollar Volume > $20–50 млн для портфельных стратегий; Relative Spread < 0.2–0.5% в зависимости от периода холда в позиции; Amihud Ratio в нижнем квинтиле распределения по рынку. Пороговые значения зависят от размера фонда, задач фонда и таймфрейма стратегии. К примеру, если это высокочастотная HFT-стратегия, то для нее нужны активы с первоклассной ликвидностью с Dollar Volume > $100 млн и спредами до 0.05%. В то же время, long-only equity фондам ликвидность уже не так важна, в них допускаются более свободные критерии, и ключевым фильтром становится капитализация компаний. Есть еще так называемый подход динамической фильтрации. Он заключается в пересмотре торгуемой вселенной по мере изменения ликвидности инструментов. Резкое падение объемов или расширение спредов триггерит исключение инструмента из портфеля. Подход динамической фильтрации реализуется с помощью скользящих проверок (rolling checks) с частотой от дневной до недельной и с использованием пороговых сигналов (threshold alerts), срабатывающих при отклонении метрик за пределы допустимого диапазона. Это позволяет своевременно исключать неликвидные инструменты и минимизировать транзакционные риски при изменении рыночных условий. Влияние ликвидности на сайзинг ордеров Ликвидность ограничивает максимальный размер позиции, который можно накопить или ликвидировать без существенного price impact, т. е. влияния на рынок и котировки. Например, консервативное правило 5–10% от ADV позволяет оценить безопасный размер позиции: Max Position Size = k × ADV × Days где: k — доля дневного объема (обычно 0.05-0.10); Days — период накопления/ликвидации (обычно 1-5 дней); Max Position Size — максимальный размер позиции в акциях. Интерпретация: позиция в 5% от ADV позволяет полностью войти или выйти за один день с минимальным воздействием на цену. Превышение этого порога требует растягивания исполнения сделки на несколько дней, что увеличивает рыночный риск (market risk) и временной риск (timing risk). Для портфельных стратегий размер позиции масштабируется с учетом ликвидности по формуле: Position Size_i = Capital × Weight_i × Liquidity Adjustment_i где: Weight_i — целевой вес инструмента i в портфеле; Liquidity Adjustment_i — коэффициент корректировки (0–1) на основе метрик ликвидности; Position Size_i — скорректированный размер позиции. Неликвидные инструменты получают пониженный вес, даже при высокой ожидаемой альфа-доходности. Альтернативный подход — исключать инструменты с Liquidity Adjustment < 0.5, концентрируя капитал в более ликвидной части торговой вселенной. Влияние ликвидности на алгоритмы исполнения (execution algorithms) Метрики ликвидности напрямую влияют на выбор алгоритмов исполнения и их настройку. Они помогают определить, какой метод лучше использовать, чтобы минимизировать влияние на рынок, проскальзывание и транзакционные издержки. Показатель VWAP (Volume Weighted Average Price) оптимален для ликвидных инструментов с предсказуемым профилем объемов внутри дня (intraday volume profile). Его формула: VWAP Target = ∑ (Price_i × Volume_i) / ∑ Volume_i где: Price_i — цена в интервале i; Volume_i — объем в интервале i; VWAP Target — целевая средневзвешенная цена. Алгоритм VWAP распределяет исполнение ордера пропорционально историческому внутридневному профилю объема: больше ордеров исполняется в часы пиковой активности, меньше — в периоды низкой ликвидности. Такая стратегия позволяет минимизировать влияние на котировки, однако требует, чтобы Dollar Volume инструмента значительно превышал размер ордера (обычно в 20 раз и более). Еще одна часто используемая метрика - TWAP (Time Weighted Average Price). С помощью нее оценивается равномерное распределение входа и выхода в позицию по времени, игнорируя профиль объема (volume profile): TWAP Slice Size = Total Order Size / Number of Intervals где: Total Order Size — полный размер ордера; Number of Intervals — количество временных отрезков (слайсов); TWAP Slice Size — размер каждого слайса. Метод подходит для инструментов с нестабильным volume profile, либо при необходимости скрыть информацию о размере ордера. Однако он менее эффективен из-за более высоких транзакционных издержек при выраженной внутридневной сезонности ликвидности. Еще один интересный алгоритм исполнения - Implementation Shortfall (IS). Его задача - минимизировать общие транзакционные издержки, включая market impact и opportunity cost, особенно при больших ордерах относительно объема рынка. Алгоритм IS балансирует между скоростью исполнения (aggressiveness) и ценовым воздействием (market impact) — медленное исполнение уменьшает влияние на цену, но увеличивает риск движения рынка против позиции. Общие издержки исполнения по IS оцениваются как: IS = ∑ (Pᵢ − P_decision) × Qᵢ где: Pᵢ — цена исполнения i-го блока ордера; P_decision — цена на момент принятия торгового решения; Qᵢ — объем i-го блока ордера. Особенности расчета метрик ликвидности для различных рынков Микроструктура рынка напрямую влияет на интерпретацию и методы расчета метрик ликвидности. Различия между NYSE и NASDAQ NYSE использует официальных маркет-мейкеров с обязательствами по котированию, что обеспечивает более стабильные спреды и предсказуемость ликвидности. NASDAQ работает через конкурирующих дилеров, что создает глубокие книги заявок для акций с высокими торгуемыми объемами, однако спреды могут быть более изменчивыми. Emerging markets (развивающиеся рынки) Ликвидность на развивающихся рынках часто фрагментирована, поскольку большая часть объема концентрируется в нескольких крупнейших акциях, остальная часть рынка слаболиквидна, либо неликвидна вообще. Многие метрики требуют адаптации: например, Dollar Volume $1 млн может быть достаточным для локальных стратегий, а Amihud Ratio корректно сравнивать только внутри страны. Торговля в пре-маркет и пост-маркет Рынки в пре-маркете и в заключительные часы обычно имеют сниженную ликвидность по сравнению с основной сессией. Спреды могут быть в 3–10 раз шире, а торговые объемы составляют лишь 5–15% от объемов регулярной сессии (regular session). В таких условиях метрики ликвидности, такие как spread, depth, Dollar Volume и price impact, необходимо рассчитывать отдельно для регулярной сессии и для остальных часов, в случае если стратегия охватывает оба периода. Это важно для корректного сайзинга позиций, оценки транзакционных издержек и выбора алгоритмов исполнения, так как низкая ликвидность увеличивает риски проскальзывания при крупных ордерах. Аукционные механизмы (Opening и Closing Auctions) В аукционах открытия и закрытия торгов классическая концепция спреда неприменима, так как для всех сделок формируется единая clearing price. В этих условиях оценивается не разница между bid и ask, а динамика формирования цены и объема ордеров. Вместо спреда используются специальные метрики: Indicative match price volatility — волатильность ориентировочной цены аукциона, показывающая, насколько сильно потенциальная цена может изменяться в процессе согласования спроса и предложения; Imbalance metrics — эти метрики измеряют дисбаланс между объемом заявок на покупку и продажу, отражая потенциальное расхождение между индикативной и итоговой аукционной ценой. Эти показатели позволяют оценить рыночное давление и ликвидность в наиболее острые моменты: открытия и закрытия сессии. Их анализ помогает алгоритмам исполнения оптимально размещать ордера и минимизировать ценовое воздействие, особенно для крупных сделок. Таким образом, при расчете и интерпретации метрик ликвидности необходимо учитывать тип рынка, режим торговли и специфические механизмы исполнения, чтобы адаптировать алгоритмы фильтрации и сайзинга позиций к реальной рыночной среде. Временная стабильность метрик ликвидности Ликвидность на рынках нестабильна во времени: кризисы, корпоративные события, смена состава индексов могут вызывать резкие изменения. Мониторинг динамики метрик помогает выявлять структурные сдвиги и аномалии в поведении рынка. Для оценки согласованности различных показателей ликвидности используют скользящую корреляцию (rolling correlation): ρ_t(X, Y) = Cov_t(X, Y) / (σ_t(X) × σ_t(Y)) где: Cov_t(X, Y) — ковариация метрик X и Y в окне вокруг времени t; σ_t — стандартное отклонение в том же окне; ρ_t — скользящая корреляция. При нормальных условиях корреляция между Dollar Volume и обратным коэффициентом Amihud Ratio составляет 0.6–0.8. Снижение до 0.3–0.4 сигнализирует о рыночной дислокации: объемы растут, но чувствительность цены к сделкам усиливается — часто это происходит в периоды внезапных новостей, форсированных ликвидаций, резких падениях рынка. Для автоматического выявления периодов различной ликвидности применяют модели переключений режимов (regime detection) с использованием моделей Маркова или правил на основе порогов (threshold-based rules). Типичные режимы: normal liquidity; stressed liquidity; crisis. Переход между режимами может инициировать корректировку размера позиции (сайзинга), расширение допущений по разнице котировок bid-ask в риск-моделях или даже временную приостановку торговли в экстремальных условиях. Лично я рекомендую отслеживать всплески волатильности в комплексе с объемами, так как они часто предвещают краткосрочные аномалии ликвидности. Например, внезапное увеличение объема без заметного сужения спреда указывает на исполнение крупных ордеров против ограниченной глубины рынка — типичный паттерн институциональной ликвидации или аккумулирования позиций. В таких условиях алгоритмы исполнения должны умерять агрессивность сделок, избегая дополнительного давления на уже перегруженную сторону книги заявок и минимизируя риск значительного ценового воздействия. Заключение Количественная оценка ликвидности преобразует качественное понятие в измеримые метрики, которые могут быть напрямую использованы в алгоритмической торговле. Объемные показатели, спреды, глубина книги заявок и другие метрики отражают разные стороны ликвидности, показывая как доступность объема для торговли, так и стоимость исполнения и устойчивость рынка к большим ордерам. Их комплексный анализ позволяет полноценно оценивать рыночную ликвидность, выявлять слабые места и принимать обоснованные решения для фильтрации инструментов, сайзинга позиций и настройки алгоритмов исполнения. ### Копулы в финансовом моделировании: зависимости между случайными величинами Копулы — это мощный инструмент в финансовом моделировании, позволяющий описывать сложные зависимости между активами, которые выходят далеко за рамки линейной корреляции. Использование корреляций (в частности Пирсона), несомненно, остаются базой в портфельных моделях, но это адекватно лишь при эллиптических распределениях и линейных связях. В реальности же рынки редко подчиняются этим упрощенным предположениям, что приводит к недооценке рисков и искажению представлений о взаимосвязях между активами. Копулы решают эту проблему, разделяя модель на два компонента: Маргинальные распределения отдельных переменных; Структуру зависимости между ними. Такое разделение позволяет строить гибкие многомерные распределения, сочетая разные типы индивидуальных распределений с различными формами зависимости. Благодаря этой универсальности копулы нашли широкое применение в финансах: в риск-менеджменте — для расчета VaR и CVaR с учетом реальных зависимостей; в портфельной оптимизации — при построении эффективных границ за пределами предположений Марковица; в ценообразовании деривативов — для моделирования корзин активов и кредитных дефолтов; в анализе системных рыночных рисков. Сегодня копулы перешли из академических исследований в практику хедж-фондов и риск-подразделений банков, став неотъемлемой частью инструментов количественного анализа. Ограничения корреляции Пирсона Корреляция Пирсона измеряет только линейную связь между переменными. Для двух активов с доходностями X и Y коэффициент вычисляется как: ρ = Cov(X,Y) / (σ_X · σ_Y) где: Cov — ковариация; σ — стандартные отклонения. Этот показатель принимает значения от -1 до 1, где 0 означает отсутствие линейной связи. Корреляция Пирсона легко интерпретируется и сегодня повсеместно применяется в анализе данных. Однако у этого подхода есть несколько ограничений. 1. Чувствительность к выбросам Единичное экстремальное наблюдение искажает оценку корреляции для всей выборки. В период кризиса 2008 года многие пары активов, считавшиеся слабо коррелированными, показали совместное падение. Расчетная корреляция на исторических данных не отражала реальный риск одновременных потерь. 2. Неспособность улавливать нелинейные зависимости Две переменные могут иметь корреляцию близкую к нулю, однако при этом демонстрировать сильную функциональную связь. Классический пример: Y = X² при X с симметричным распределением вокруг нуля дает корреляцию около 0, хотя зависимость детерминированная. 3. Игнорирование хвостовых зависимостей Активы могут вести себя независимо в обычных рыночных условиях, однако синхронно падать в кризисы. Корреляция Пирсона дает единую оценку для всего диапазона значений и не различает поведение в центре распределения от поведения в хвостах. Для риск-менеджмента критична именно зависимость в экстремальных событиях, которую линейная корреляция не отражает. 4. Предположение о нормальности распределения данных Формально коэффициент Пирсона не требует нормального распределения, однако его интерпретация как полной характеристики зависимости справедлива только для многомерного нормального распределения. Финансовые доходности очень часто имеют тяжелые хвосты и асимметрию, что делает эту интерпретацию ошибочной. Что такое копулы? Копула — функция, связывающая многомерное распределение с его одномерными маргинальными распределениями. Теорема Склара (1959) формализует это так: для любого многомерного распределения F с маргинальными распределениями F₁, F₂, ..., Fₙ существует копула C такая, что: F(x₁, x₂, ..., xₙ) = C(F₁(x₁), F₂(x₂), ..., Fₙ(xₙ)). Теорема работает в обе стороны. Имея маргинальные распределения и копулу, можно построить совместное распределение. Имея совместное распределение, можно извлечь копулу и маргинальные распределения. Это разделение — ключевое преимущество подхода. Переменные в копуле: F — совместная функция распределения; F₁, F₂, ..., Fₙ — маргинальные функции распределения; C — копула, отображающая [0,1]ⁿ в [0,1]; x₁, x₂, ..., xₙ — значения случайных величин. Копула описывает структуру зависимости независимо от маргинальных распределений. Можно моделировать зависимость между доходностями акций с распределением Стьюдента, опционными волатильностями с логнормальным распределением и кредитными спредами с произвольным эмпирическим распределением, используя одну и ту же копулу для описания их связи. Ключевое практическое преимущество данного подхода: возможность оценивать маргинальные распределения и структуру зависимости отдельно. Сначала подбираем наилучшее распределение для каждой переменной (нормальное, Стьюдента, скошенное), затем моделируем зависимость через копулу. Это снимает ограничение на использование одного семейства распределений для всех переменных. Второе преимущество — инвариантность к монотонным преобразованиям. Корреляция Пирсона меняется при нелинейных преобразованиях переменных (например, переходе от цен к логарифмическим доходностям). Копула остается неизменной при любых строго возрастающих преобразованиях. Это свойство делает копулы естественным инструментом для моделирования финансовых данных, где выбор шкалы (цены, доходности, логарифмы) часто произволен. Основные семейства копул Эллиптические копулы Гауссова копула извлекается из многомерного нормального распределения. Параметры — корреляционная матрица R размерности n×n. Копула имеет форму: C(u₁, ..., uₙ) = Φ_R(Φ⁻¹(u₁), ..., Φ⁻¹(uₙ)) где: Φ — стандартная нормальная функция распределения; Φ⁻¹ — обратная функция (квантиль). Гауссова копула не имеет хвостовых зависимостей: вероятность совместного экстремального события ниже, чем в реальных данных. Два актива с корреляцией 0.5 по Гауссовой копуле редко падают одновременно на 3+ стандартных отклонения. На практике такие события происходят чаще. t-копула Стьюдента добавляет параметр степеней свободы ν, контролирующий толщину хвостов. При малых ν (3-5) копула демонстрирует сильные хвостовые зависимости. При больших ν (30+) приближается к Гауссовой. Форма копулы Стьюдента следующая: C(u₁, ..., uₙ) = t_{ν,R}(t_ν⁻¹(u₁), ..., t_ν⁻¹(uₙ)) где: t_ν — одномерное распределение Стьюдента; t_{ν,R} — многомерное с корреляционной матрицей R. t-копула захватывает симметричные хвостовые зависимости: активы склонны к совместным экстремальным движениям как вверх, так и вниз. Для большинства пар акций это реалистичное предположение. Для моделирования асимметрии (сильнее падают вместе, чем растут) требуются другие семейства. Архимедовы копулы Архимедовы копулы строятся через генератор φ — строго убывающую выпуклую функцию. Общий вид для двумерного случая: C(u,v) = φ⁻¹(φ(u) + φ(v)) Каждая копула задается выбором генератора. Копула Клэйтона (Clayton) с параметром θ > 0 имеет генератор: φ(t) = (t⁻θ - 1)/θ. Копула Clayton демонстрирует сильную нижнюю хвостовую зависимость и слабую верхнюю. Ее ключевая особенность: активы падают вместе чаще, чем растут. Коэффициент нижней хвостовой зависимости: λ_L = 2⁻¹/θ. При θ = 1 получаем λ_L = 0.5. Применяется для моделирования кредитных дефолтов и падений рынков акций. Копула Гумбеля (Gumbel) с параметром θ ≥ 1 имеет генератор: φ(t) = (-ln t)θ. Копула Gumbel противоположна Clayton: сильная верхняя хвостовая зависимость, слабая нижняя. Коэффициент верхней хвостовой зависимости рассчитывается так: λ_U = 2 - 2¹/θ. Подходит для товарных рынков и валютных пар, где синхронный рост наблюдается чаще совместного падения. Копула Фрэнка (Frank) с параметром θ ∈ ℝ имеет генератор: φ(t) = -ln((e⁻θt - 1)/(e⁻θ - 1)). Это симметричная копула без хвостовых зависимостей, однако она позволяет моделировать широкий спектр корреляций от -1 до 1. При θ = 0 получаем отсутствие взаимосвязи. Копула Frank применяется, когда эмпирические данные не показывают асимметрии в хвостах. Vine-копулы для многомерных случаев Архимедовы копулы в базовой форме ограничены двумя-тремя измерениями. Для портфеля из 10+ активов прямое обобщение не работает или требует предположения об одинаковой парной зависимости между всеми активами. Vine-копулы решают проблему через декомпозицию многомерной плотности на произведение парных копул и условных плотностей. C-vine и D-vine структуры организуют парные зависимости в древовидную иерархию: Первое дерево моделирует безусловные парные зависимости; Второе дерево моделирует условные зависимости первого порядка; Третье — второго порядка. R-vine обобщает C-vine и D-vine, позволяя моделировать произвольную структуру дерева. Выбор структуры основывается на силе зависимостей: наиболее зависимые пары моделируются на первых уровнях. Алгоритм последовательного выбора максимальной корреляции автоматически определяет оптимальную структуру. Vine-копулы обеспечивают гибкость: каждая парная зависимость может использовать свою копулу (Gumbel для одной пары, Clayton для другой). Цена гибкости — вычислительная сложность. Портфель из 20 активов требует оценки 190 парных копул и их параметров. Тип копулы Хвостовая зависимость Симметрия Размерность Применение Гауссова Нет Да Любая Базовый бенчмарк t-Стьюдента Симметричная Да Любая Портфели акций Clayton Нижняя Нет 2-3 Кредитный риск Gumbel Верхняя Нет 2-3 Товарные рынки Frank Нет Да 2-3 Валютные пары Vine Гибкая Настраиваемая Любая Большие портфели Применение в риск-менеджменте Расчет VaR и CVaR для портфеля Value at Risk (VaR) на уровне α — квантиль распределения потерь портфеля. VaR₀.₉₅ = 100 означает, что с вероятностью 95% потери не превысят 100. Conditional VaR (CVaR) — ожидаемые потери при условии превышения VaR. CVaR₀.₉₅ = 150 означает среднюю потерю 150 в худших 5% сценариев. Классический подход предполагает многомерное нормальное распределение доходностей. VaR вычисляется аналитически через ковариационную матрицу. Подход недооценивает риск: нормальное распределение имеет тонкие хвосты, реальные потери в кризис превышают прогнозы. Копульный подход решает эту задачу более точным образом: Сначала подбираем маргинальные распределения для доходностей каждого актива — часто распределение Стьюдента с 3-7 степенями свободы; Затем моделируем зависимость через t-копулу с параметрами, оцененными по историческим данным. Получаем многомерное распределение с тяжелыми хвостами и реалистичными хвостовыми зависимостями; Расчет VaR выполняется через симуляцию Монте-Карло. Генерируем 100000 сценариев из копулы; Преобразуем в доходности активов через обратные маргинальные распределения; Вычисляем P&L портфеля для каждого сценария, берем 95-й перцентиль. CVaR вычисляется как среднее по сценариям хуже VaR. Эмпирические результаты показывают улучшение точности. Бэктест на кризисе 2008: копульный VaR нарушался в 6% дней (близко к теоретическим 5%), нормальный VaR — в 12% дней. Для CVaR разница еще заметнее: копульная оценка давала ошибку предсказания 8%, нормальная — 25%. Моделирование совместных дефолтов Кредитный портфель обычно состоит из облигаций или кредитов множества эмитентов. И нарастание риска здесь происходит не столько от отдельных дефолтов, сколько при их одновременном возникновении. Когда несколько эмитентов не могут выполнить обязательства в одно и то же время, потери оказываются значительно выше, чем при независимых дефолтах: в кризис ликвидация залогов идет с большими дисконтами, и уровень возврата (recovery rate) падает. Структурные модели, такие как модель Мертона, рассматривают дефолт как момент, когда стоимость активов компании опускается ниже определенного порога. Чтобы смоделировать поведение целого портфеля, нужно знать, как связаны между собой стоимости активов разных компаний. В 2000-х управляющие часто использовали гауссовы копулы при оценке рисков, в том числе для сложных кредитных инструментов вроде CDO (Collateralized Debt Obligations). К чему привели "игры с CDO" - общеизвестно, кризис 2008 года. Проблема заключалась в том, что гауссова копула недооценивала вероятность множественных дефолтов. Согласно модели, вероятность потерь по самому надежному, AAA-траншу составляла всего около 0.1%, но на практике убытки возникали в 3–5% случаев. Это расхождение объясняется тем, что гауссова копула не учитывает хвостовые зависимости — ситуацию, когда дефолты становятся более вероятными именно во времена кризисов. Более реалистичные результаты дают t-копула и копула Клейтона. Последняя особенно хорошо описывает поведение компаний в стрессовых условиях, поскольку моделирует сильную зависимость в нижнем хвосте распределения: фирмы с большей вероятностью дефолтят одновременно во время кризиса, чем демонстрируют одновременный рост в спокойные периоды. Параметр зависимости θ при этом можно откалибровать по историческим данным о корпоративных дефолтах или на основе цен CDS (Credit Default Swaps), отражающих восприятие рынка относительно кредитного риска. Практические результаты: портфель из 100 корпоративных облигаций, вероятность дефолта каждой 2% в год. Гауссова копула с корреляцией 0.3 дает вероятность 5+ одновременных дефолтов около 0.5%. Clayton с θ = 1.5 (примерно соответствует той же ранговой корреляции) дает 2.8%. Разница в 5 раз влияет на ценообразование и резервы капитала. Применение в портфельной оптимизации Модель Марковица строит эффективную границу портфелей через минимизацию дисперсии при заданной ожидаемой доходности. Предположения: доходности активов имеют многомерное нормальное распределение, зависимость описывается ковариационной матрицей. Естественно, эти предположения разрушаются о суровую реальность рынков. Копульный подход отличается тем, что сохраняет логику оптимизации, при этом заменяет входные данные. Вместо ковариационной матрицы используем копулу и маргинальные распределения для генерации реалистичных сценариев доходностей. Целевая функция — минимизация CVaR вместо дисперсии, что лучше отражает толерантность к риску. Процедура оптимизации: Выбираем копулу и маргинальные распределения по историческим данным; Генерируем 50000 сценариев будущих доходностей; Для каждого набора весов портфеля вычисляем CVaR по сценариям; Находим веса с минимальным CVaR при ограничении на ожидаемую доходность. Сравнение подходов на портфеле из акций S&P 500 за 2015-2020 годы: Марковиц с нормальным предположением: Sharpe ratio 0.85, максимальная просадка 18%; Копульная оптимизация с t-копулой и маргинальными распределениями Стьюдента: Sharpe ratio 0.92, максимальная просадка 14%. Улучшение достигается за счет снижения весов активов с сильной нижней хвостовой зависимостью. Дополнительное преимущество — учет асимметричных зависимостей. Акции технологического сектора могут иметь умеренную корреляцию с финансовым в обычное время, но высокую в кризис. Копулы Гумбеля и Клейтона захватывают эту асимметрию. Оптимизатор снижает совместные позиции в активах с сильной нижней хвостовой зависимостью, улучшая профиль риска. Ограничение подхода — вычислительная стоимость. Каждая оценка целевой функции требует генерации тысяч сценариев. Для портфеля из 50 активов и 10000 итераций оптимизатора требуется генерация 500 миллионов сценариев. Время расчета на современном сервере — 20-30 минут против секунд для классического Марковица. Оценка параметров и выбор копулы Метод Inference Functions for Margins (IFM) разделяет оценку на два этапа: Первый этап — оценка параметров маргинальных распределений по данным каждой переменной отдельно; Второй этап — оценка параметров копулы по псевдо-наблюдениям, полученным через преобразование данных маргинальными функциями распределения. Псевдо-наблюдения вычисляются как: u_i = F̂(x_i) где: F̂ — оценка маргинального распределения; x_i — исходные наблюдения. Для выборки из 1000 наблюдений получаем 1000 точек в [0,1]ⁿ. По этим точкам оцениваем параметры копулы методом максимального правдоподобия. Метод Canonical Maximum Likelihood (CML) оценивает маргинальные распределения непараметрически через эмпирические функции распределения: F̂(x_i) = rank(x_i) / (n+1) Затем оценивает параметры копулы по псевдо-наблюдениям. Преимущество подхода — устойчивость к неверной спецификации маргинальных распределений. Недостаток — потеря эффективности, если параметрическая форма маргинальных распределений известна. Метод Maximum Likelihood (ML) оценивает все параметры (маргинальные распределения и копула) совместно. Максимизируется полное правдоподобие: L = ∏ c(F₁(x₁), ..., Fₙ(xₙ)) · ∏ f_i(x_i) где: c — плотность копулы; f_i — маргинальные плотности. Метод дает наиболее эффективные оценки, но требует правильной спецификации всех компонентов и вычислительно сложен. Выбор между копулами основывается на информационных критериях: Akaike Information Criterion (AIC) = -2·ln(L) + 2k, где L — максимизированное правдоподобие, k — число параметров; Bayesian Information Criterion (BIC) = -2·ln(L) + k·ln(n), где n — размер выборки. Меньшее значение критерия указывает на лучшую модель. BIC сильнее штрафует сложные модели. Для больших выборок (n > 1000) BIC предпочтителен, поскольку лучше защищает модель от переобучения. Для малых выборок (n < 500) AIC дает лучший баланс между точностью подгонки и сложностью. Тесты согласия проверяют адекватность выбранной копулы. Обычно используют тест Крамера-фон Мизеса. Он сравнивает эмпирическую копулу с теоретической: S_n = ∫ [C_emp(u) - C_θ(u)]² dC_emp(u) Тест основан на бутстрепе: генерируем выборки из подобранной копулы, вычисляем статистику для каждой, сравниваем с наблюдаемым значением. P-value < 0.05 означает что модель нужно отвергнуть. Практические рекомендации по выбору: Начинаем с простых копул (Гауссова, t-Стьюдента), проверяем адекватность через тесты и визуальный анализ зависимости в хвостах; Если симметричные копулы не подходят, тестируем архимедовы (Clayton, Gumbel, Frank). Для портфелей 5+ активов рассматриваем vine-копулы только если простые модели явно неадекватны — высокая вычислительная сложность vine-копул редко оправдана; Для финансовых временных рядов проверяем стационарность зависимости. Оценка скользящим окном параметров копулы на окнах 250-500 наблюдений должна показывать стабильность. Если параметры дрейфуют, рассматриваем time-varying копулы, либо переоцениваем модель чаще. Моделирование копулами: ограничения и подводные камни 1. Вычислительная сложность и масштабируемость Одно из главных ограничений сложных копул (в частности vine) — квадратичный рост вычислительной сложности с числом активов. Например, для портфеля из 50 активов требуется оценить 1225 парных копул. Даже при использовании хорошего железа время расчета одной симуляции может занимать несколько минут, что становится узким местом в задачах, где нужны тысячи симуляций — например, при оптимизации портфеля или бэктестах стратегий. Чтобы сократить сложность, применяют факторные модели. Предполагается, что все активы зависят от общих факторов (например, рыночного и отраслевых индексов) и условно независимы, если эти факторы известны. Такой подход снижает число параметров с O(n²) до O(n⋅k), где k — число факторов. Пример: при 50 активах и 3 факторах количество параметров сокращается с 1225 до 150. 2. Выбор маргинальных распределений Выбор маргинальных распределений оказывает на результат моделирования зачастую большее влияние, чем выбор копулы. Ошибка в хвостах распределений напрямую приводит к ошибкам в оценках VaR и CVaR. В этом случае лучше тестировать несколько семейств распределений: нормальное, Стьюдента, скошенное Стьюдента, обобщенное гиперболическое. Для проверки согласия — особенно в хвостах — используют тесты Колмогорова-Смирнова и Андерсона-Дарлинга. Практический подход: Если Q–Q plot показывает отклонения от нормальности в хвостах, выбираем распределение Стьюдента; Если наблюдается асимметрия (один хвост толще другого), добавляем параметр скошенности; Обобщенное гиперболическое распределение — наиболее гибкое, но требует оценки пяти параметров против двух у Стьюдента. 3. Нестабильность параметров во времени Зависимости между активами нестационарны — они усиливаются в периоды кризисов и ослабевают в спокойные времена. Например, копула, оцененная на данных 2015–2019 годов, давала плохие результаты в марте 2020 во время COVID-кризиса. Для решения этих ограничений можно использовать динамические копулы с параметрами, меняющимися во времени. Например, модели DCC-GARCH для корреляционной матрицы или авторегрессионные процессы для параметра зависимости. Ключевая сложность здесь состоит в необходимости прогнозировать эволюцию параметров. Более простой вариант — переоценка в скользящих окнах с экспоненциальным взвешиванием, где больший вес получают недавние наблюдения. 4. Риск переобучения При использовании vine-копул с произвольной структурой возникает риск переобучения. Например, для 50 активов и 5 лет дневных данных (около 1250 наблюдений) нужно оценить 1225 параметров, то есть меньше одного наблюдения на параметр. Для решения этой проблемы можно попробовать: Ограничить структуру копулы: использовать C-vine или D-vine вместо R-vine; Попробовать привести корреляции к средним значениям. 5. Проблема экстраполяции и стресс-тестирование Копула строится на основе исторических данных, но часто используется для оценки событий, которые выходят за рамки прошлого опыта. Если в данных максимальная просадка доходила до −15%, а модель пытается оценить риск падения на −30%, ее прогнозы будут слабо обоснованы. Чтобы повысить надежность таких оценок, применяют стресс-тестирование. Для этого параметры копулы искусственно изменяют так, чтобы зависимости между активами усиливались — как это бывает во время кризисов. Такой подход помогает определить диапазон возможных потерь и понять, насколько портфель уязвим к системным шокам. Заключение Моделирование рынка копулами — мощный инструмент для описания сложных зависимостей между активами, однако он требует внимательного и осознанного подхода. Важно: контролировать вычислительную сложность и избегать переобучения; тщательно выбирать маргинальные распределения; учитывать временную изменчивость параметров; тестировать модель на устойчивость к стрессовым сценариям. Только при соблюдении этих условий копулы дают реалистичные и надежные оценки риска. Копулы — инструмент сложный не только с точки зрения вычислений, но и в плане интерпретации данных. Однако в задачах, где точность оценки рисков определяет устойчивость стратегии, эта сложность оправдана: она дает глубину анализа, недостижимую для более простых моделей. ### Основы MLOps: как развернуть ML-модель в production В последние годы MLOps стал неотъемлемой частью жизненного цикла машинного обучения. Если раньше работа дата-сайентиста заканчивалась на этапе обучения модели, то сегодня ключевая задача — обеспечить стабильное и масштабируемое развертывание модели в production. MLOps объединяет подходы DevOps и машинного обучения, помогая автоматизировать процесс от подготовки данных и обучения до мониторинга и обновления моделей. Это позволяет бизнесу быстрее внедрять ML-решения, повышать точность прогнозов и минимизировать риски, связанные с деградацией моделей. Архитектура и компоненты ML в продакшен среде Сегодня большинство ML-систем состоят из нескольких взаимосвязанных компонентов: Сервис инференса (Inference Service) обрабатывает входящие запросы и возвращает предсказания модели; Хранилище признаков (Feature Store) сохраняет предобработанные фичи и обеспечивает консистентность данных между этапами обучения и инференса; Реестр моделей (Model Registry) отвечает за хранение версий моделей, их метрик и сопутствующих метаданных; Сервис мониторинга (Monitoring Service) собирает показатели качества, производительности и отслеживает дрейф данных. Дополнительные компоненты продакшн-системы зависят от специфики задачи и уровня автоматизации процессов. Например: Пайплайн обработки данных (Data Pipeline) отвечает за валидацию входных данных и их преобразование в нужный формат; Пайплайн автоматического переобучения (Retraining Pipeline) автоматически переобучает модель при обнаружении снижении качества ее прогнозов ниже определенного порога; Пайплайн инжиниринга признаков (Feature Engineering Pipeline) извлекает и обновляет признаки на основе сырых данных, поддерживая актуальность информации для обучения и инференса. Взаимодействие между компонентами системы обычно реализуется с помощью очередей сообщений (message queues), передаваемых с помощью брокеров сообщений, таких как RabbitMQ или Kafka, либо через REST API. Для пакетных (batch) предсказаний применяются оркестраторы рабочих процессов — Airflow, Prefect или Dagster. Эти инструменты управляют зависимостями между задачами, отслеживают их выполнение и обеспечивают устойчивость системы при возникновении ошибок. Выбор способа развертывания Как правило, используют 3 основных паттерна развертывания ML-моделей: REST API, batch processing и streaming. REST API REST API используется для предсказаний в режиме реального времени. Этот паттерн выбирают для моделей с критичными требованиям по задержке (latency) не более сотен миллисекунд. Клиентское приложение отправляет запрос и получает ответ практически мгновенно. Такой подход часто применяют в рекомендательных системах, обнаружении фрода и динамическом ценообразовании (dynamic pricing). Основной недостаток — высокие требования к инфраструктуре при больших нагрузках. Пакетная обработка (Batch processing) Пакетная обработка при деплое подходит для обработки больших объемов данных с задержкой от нескольких минут до нескольких часов. В этом случае модель применяется сразу к целому набору данных, а результаты сохраняются в базу данных или файловое хранилище. Метод часто используют для скоринга клиентов, генерации рекомендаций для email-рассылок и расчета агрегированных метрик. Его преимущество — эффективное использование ресурсов за счет пакетной обработки. Потоковая обработка (Streaming) Развертывание ML-моделей через потоковую обработку данных обычно используют когда нужно обновлять данные в реальном времени, с задержкой до нескольких секунд. Модель применяется к каждому событию в потоке, что делает этот подход незаменимым в задачах алгоритмической торговли, мониторинга аномалий в IoT и анализа логов. Для реализации обычно используют инструменты Kafka Streams, Flink или Spark Streaming. Выбор паттерна развертывания ML-моделей зависит от бизнес-требований и технических ограничений: Если важна минимальная задержка и небольшие объемы данных — подойдет REST API; При работе с большими массивами без строгих ограничений по времени — batch processing; При необходимости обрабатывать непрерывные потоки с низкой задержкой — streaming. В реальных MLOps-проектах нередко комбинируют несколько подходов одновременно. Например, модель может выдавать онлайн-предсказания через REST API, а результаты периодически агрегируются и анализируются в batch-режиме для обучения обновленных версий модели. Такой гибридный подход позволяет объединить преимущества низкой задержки и стабильности пакетной обработки. Также важно учитывать, что выбор способа развертывания связан с архитектурой инфраструктуры. В продакшн-среде модели часто разворачиваются в контейнерах (Docker, Kubernetes), а обновления управляются через CI/CD-конвейеры. Это обеспечивает воспроизводимость, автоматизацию и простоту масштабирования ML-сервисов. Подготовка модели к продакшену Сериализация и версионирование Перед развертыванием модель необходимо подготовить к использованию в продакшн-среде. Этот этап включает сериализацию — сохранение обученной модели в формате, который можно загрузить и использовать для инференса без повторного обучения. В Python для этого применяются различные инструменты, такие как Pickle, Joblib, ONNX или TorchScript (для PyTorch). Каждый из них имеет свои преимущества и ограничения: Pickle прост в использовании, но не всегда безопасен; Joblib эффективен для моделей с большими массивами NumPy; ONNX обеспечивает переносимость между фреймворками; TorchScript позволяет выполнять модели PyTorch вне Python-среды. Рассмотрим практический пример сериализации модели в Python с использованием наиболее распространенных инструментов — Joblib и Pickle. import pickle import joblib from sklearn.ensemble import RandomForestClassifier # Обучение модели model = RandomForestClassifier(n_estimators=100, random_state=42) model.fit(X_train, y_train) # Pickle - базовый вариант with open('model_v1.pkl', 'wb') as f: pickle.dump(model, f) # Joblib - эффективнее для sklearn и массивов NumPy joblib.dump(model, 'model_v1.joblib') # Загрузка loaded_model = joblib.load('model_v1.joblib') predictions = loaded_model.predict(X_test) Pickle и joblib работают для большинства Python-объектов, но имеют ограничения. Несовместимость версий библиотек приводит к ошибкам при загрузке. Изменения в коде классов ломают десериализацию. Еще Pickle небезопасен — может выполнить произвольный код при загрузке. Для глубоких сетей используются форматы фреймворков. PyTorch сохраняет state dict и архитектуру отдельно: import torch import torch.nn as nn class PricePredictor(nn.Module): def __init__(self, input_dim, hidden_dim): super().__init__() self.fc1 = nn.Linear(input_dim, hidden_dim) self.fc2 = nn.Linear(hidden_dim, 1) self.relu = nn.ReLU() def forward(self, x): x = self.relu(self.fc1(x)) return self.fc2(x) model = PricePredictor(input_dim=50, hidden_dim=128) # Сохранение только весов torch.save(model.state_dict(), 'model_weights.pth') # Сохранение всей модели torch.save(model, 'model_full.pth') # Загрузка весов (требует определения архитектуры) model = PricePredictor(input_dim=50, hidden_dim=128) model.load_state_dict(torch.load('model_weights.pth')) model.eval() Рекомендуется сохранять state_dict, а не полную модель. Это обеспечивает контроль над архитектурой и упрощает миграцию между версиями PyTorch. Перед инференсом вызывается model.eval() для отключения dropout и batch normalization. ONNX (Open Neural Network Exchange) обеспечивает совместимость между фреймворками. Модель экспортируется в ONNX, затем запускается через ONNX Runtime: import torch.onnx # Экспорт PyTorch в ONNX dummy_input = torch.randn(1, 50) torch.onnx.export(model, dummy_input, 'model.onnx', input_names=['features'], output_names=['prediction'], dynamic_axes={'features': {0: 'batch_size'}}) # Инференс через ONNX Runtime import onnxruntime as ort session = ort.InferenceSession('model.onnx') input_name = session.get_inputs()[0].name result = session.run(None, {input_name: X_test.numpy()}) ONNX Runtime обеспечивает задержку (latency) на 30–50% ниже по сравнению с нативным PyTorch благодаря оптимизациям графа вычислений. Он поддерживает работу на GPU и специализированном железе, включая TensorRT для NVIDIA и OpenVINO для Intel. Не менее важным аспектом является версионирование моделей, которое позволяет отслеживать изменения и при необходимости выполнять откаты к предудыщим версиям (rollback). Обычно применяется схема semantic versioning (major.minor.patch), где: Major — изменение архитектуры модели или набора признаков; Minor — корректировка гиперпараметров или данных обучения; Patch — исправление багов без повторного обучения модели. Model registry централизует управление версиями и упрощает контроль над жизненным циклом моделей. Например, MLflow предоставляет удобный API и графический интерфейс (UI) для регистрации, отслеживания и развертывания моделей. import mlflow import mlflow.sklearn mlflow.set_tracking_uri("http://mlflow-server:5000") mlflow.set_experiment("volatility_prediction") with mlflow.start_run(): # Логирование параметров mlflow.log_params({ "n_estimators": 100, "max_depth": 10, "train_samples": len(X_train) }) # Обучение и логирование метрик model.fit(X_train, y_train) train_score = model.score(X_train, y_train) test_score = model.score(X_test, y_test) mlflow.log_metrics({ "train_r2": train_score, "test_r2": test_score }) # Сохранение модели mlflow.sklearn.log_model(model, "model") MLflow Tracking Server сохраняет метрики, параметры обучения и артефакты каждого запуска модели, что позволяет отслеживать эксперименты и сравнивать результаты. Model Registry обеспечивает централизованное управление версиями и позволяет переводить модели между статусами Staging, Production и Archived. Такая формализация процесса деплоя упрощает интеграцию модели в продакшен и обеспечивает возможность быстрого отката (rollback) к предыдущей версии при выявлении проблем. Контейнеризация с Docker Контейнеризация — ключевой элемент MLOps-инфраструктуры. Docker изолирует модель и все зависимости от окружения хоста, включая Python runtime, необходимые библиотеки, модель и код инференса. Это гарантирует, что модель будет вести себя одинаково на локальной машине, тестовом сервере и в продакшен среде. Базовый Dockerfile для ML-сервиса обычно включает: Базовый образ с Python; Установку необходимых библиотек и зависимостей; Копирование модели и кода инференса в контейнер; Указание команды запуска сервиса (например, через FastAPI или Flask). Такой подход обеспечивает воспроизводимость, упрощает масштабирование сервиса и интеграцию с оркестраторами контейнеров вроде Kubernetes. Базовый Dockerfile для ML-сервиса может выглядеть так: FROM python:3.10-slim WORKDIR /app # Копирование requirements и установка зависимостей COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Копирование кода и модели COPY src/ ./src/ COPY models/ ./models/ # Expose порт для API EXPOSE 8000 # Запуск сервиса CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] Использование slim-образа Python позволяет уменьшить размер контейнера на 60–70% по сравнению с полным образом, что ускоряет сборку и деплой. Рекомендуется устанавливать зависимости до копирования исходного кода, чтобы задействовать layer caching в Docker: при изменениях в коде повторная установка библиотек не требуется, что существенно экономит время сборки. Multi-stage builds помогают дополнительно оптимизировать размер финального образа, отделяя этап сборки от этапа запуска. На первом этапе можно устанавливать все инструменты и зависимости для сборки модели, а на финальном — включать только минимальный набор для инференса. # Stage 1: Build FROM python:3.10 as builder WORKDIR /app COPY requirements.txt . RUN pip install --user --no-cache-dir -r requirements.txt # Stage 2: Runtime FROM python:3.10-slim WORKDIR /app # Копирование установленных пакетов из builder COPY --from=builder /root/.local /root/.local ENV PATH=/root/.local/bin:$PATH COPY src/ ./src/ COPY models/ ./models/ EXPOSE 8000 CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] Builder stage содержит все необходимые компиляторы и заголовочные файлы для сборки пакетов. На этапе runtime контейнер получает только скомпилированные бинарники, что позволяет существенно уменьшить размер образа — обычно на 40–50% — и ускоряет запуск приложения. Для задач GPU-инференса базовый образ можно заменить на nvidia/cuda, который включает драйверы и оптимизированные библиотеки для работы с GPU, обеспечивая ускорение вычислений при обработке больших моделей. FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04 # Установка Python RUN apt-get update && apt-get install -y python3.10 python3-pip WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY src/ ./src/ COPY models/ ./models/ EXPOSE 8000 CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] Runtime-версия образа содержит только CUDA runtime без компиляторов, что позволяет сэкономить 2–3 ГБ дискового пространства. Для работы контейнера с GPU требуется nvidia-docker на хосте, который обеспечивает доступ к GPU и необходимым драйверам. Для упрощенного управления многоконтейнерными приложениями, включающими ML-сервис, базы данных и очереди сообщений, используют Docker Compose, который оркестрирует контейнеры и настраивает их взаимодействие. Ниже приведён пример конфигурации Docker Compose в формате YAML, демонстрирующий, как развернуть многоконтейнерное окружение для ML-сервиса с моделью, а также сопутствующую инфраструктуру, такую как Redis, с настройкой портов, переменных окружения, томов и ограничений ресурсов. version: '3.8' services: model-service: build: . ports: - "8000:8000" environment: - MODEL_PATH=/app/models/model_v2.joblib - LOG_LEVEL=INFO volumes: - ./models:/app/models depends_on: - redis deploy: resources: limits: cpus: '2' memory: 4G redis: image: redis:7-alpine ports: - "6379:6379" volumes: - redis-data:/data volumes: redis-data: Volumes позволяют пробросить директорию с моделями внутрь контейнера, благодаря чему обновление модели не требует пересборки образа. Redis используется для кеширования предсказаний и хранения признаков (feature store), обеспечивая быстрый доступ к данным. Параметры resource limits помогают ограничить использование процессора и памяти, предотвращая перегрузку хоста и обеспечивая стабильную работу сервисов. Развертывание в Kubernetes Kubernetes автоматизирует развертывание, масштабирование и управление контейнерами, что делает его незаменимым инструментом для деплоя ML-сервисов в продакшен. Он позволяет: Обеспечивать высокую отказоустойчивость — сервисы продолжают работать даже при сбоях отдельных узлов; Продумывать горизонтальное масштабирование — автоматически увеличивать или уменьшать количество реплик модели в зависимости от нагрузки; Управлять зависимостями и конфигурациями через декларативные манифесты, что упрощает поддержание и обновление сервисов; Интегрировать мониторинг, логирование и стратегии обновления (rolling updates, rollback) без простоя системы; Стандартизировать окружение для разных команд, обеспечивая воспроизводимость и переносимость моделей между разработкой, тестированием и продакшеном. Деплоймент (Deployment) в Kubernetes определяет желаемое состояние приложения: количество реплик, контейнерные образы, конфигурации и стратегии обновления. Это позволяет системе автоматически поддерживать сервис в актуальном состоянии и упрощает управление сложными ML-приложениями в продакшен-среде. Пример Deployment в Kubernetes (YAML) для развертывания ML-модели с настройкой ресурсов и проверками состояния: apiVersion: apps/v1 kind: Deployment metadata: name: ml-model-deployment spec: replicas: 3 selector: matchLabels: app: ml-model template: metadata: labels: app: ml-model spec: containers: - name: model-service image: registry.example.com/ml-model:v2.1 ports: - containerPort: 8000 resources: requests: cpu: "500m" memory: "1Gi" limits: cpu: "2" memory: "4Gi" env: - name: MODEL_PATH value: "/models/model.onnx" livenessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /ready port: 8000 initialDelaySeconds: 5 periodSeconds: 5 В этом коде replicas определяет количество Pod’ов, которые Kubernetes распределяет по узлам кластера. При падении Pod автоматически создается новый, обеспечивая отказоустойчивость. Параметр resources.requests резервирует ресурсы для планировщика, а limits ограничивает потребление CPU и памяти. Контроль работоспособности (livenessProbe) перезапускает Pod при зависании приложения, а проверка готовности (readinessProbe) временно исключает Pod из балансировки нагрузки во время инициализации или обновления. Service обеспечивает сетевой доступ к Pod’ам и позволяет балансировать нагрузку между ними. Ниже приведен пример конфигурации Service в Kubernetes (YAML): apiVersion: v1 kind: Service metadata: name: ml-model-service spec: selector: app: ml-model ports: - protocol: TCP port: 80 targetPort: 8000 type: LoadBalancer LoadBalancer создает внешний IP-адрес и распределяет трафик между Pod’ами. Для внутренних сервисов, доступных только внутри кластера, используется тип ClusterIP. Horizontal Pod Autoscaler (HPA) автоматически масштабирует количество Pod’ов на основе заданных метрик, таких как загрузка CPU или пользовательские показатели. Ниже приведен пример конфигурации HPA для ML-модели: apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: ml-model-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: ml-model-deployment minReplicas: 2 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70 - type: Pods pods: metric: name: request_latency_ms target: type: AverageValue averageValue: "100" HPA автоматически добавляет Pod’ы, если загрузка CPU превышает 70% или средняя задержка запросов превышает 100 мс, и удаляет их при снижении нагрузки. Такой подход обеспечивает баланс между производительностью и затратами на инфраструктуру. ConfigMap и Secret управляют конфигурацией приложений в Kubernetes. apiVersion: v1 kind: ConfigMap metadata: name: model-config data: model_version: "v2.1" batch_size: "32" max_workers: "4" --- apiVersion: v1 kind: Secret metadata: name: model-secrets type: Opaque data: api_key: *************** # base64 encoded ConfigMap хранит неконфиденциальные настройки, такие как версия модели, размер батча или количество воркеров, а Secret — чувствительные данные, например API-ключи или пароли к базе данных. Оба объекта можно монтировать в Pod как переменные окружения или файлы, обеспечивая гибкость и безопасность конфигурации. Мониторинг и управление моделью Метрики качества и производительности Мониторинг ML-моделей в продакшен разделяется на две ключевые категории: качество предсказаний (model quality) и производительность системы (model performance). Любое ухудшение в этих показателях требует оперативного реагирования. Метрики качества зависят от типа задачи: Для классификации отслеживаются accuracy, precision, recall, F1-score. Для регрессии — MAE, RMSE, SMAPE, R². Особенно важно сравнивать показатели модели в продакшн с метриками на этапе обучения и валидации. Расхождение более 5–10% может сигнализировать о проблемах с данными, деградации модели или data drift — изменении данных в продакшн по сравнению с обучающим набором, что снижает точность предсказаний. В таких случаях требуется анализ и возможное переобучение модели. from prometheus_client import Counter, Histogram, Gauge import time # Метрики для Prometheus prediction_counter = Counter('model_predictions_total', 'Total predictions', ['model_version']) prediction_latency = Histogram('model_prediction_latency_seconds', 'Prediction latency') model_accuracy = Gauge('model_accuracy', 'Current model accuracy', ['model_version']) def predict_with_monitoring(model, features, version='v2.1'): start_time = time.time() prediction = model.predict(features) # Логирование метрик latency = time.time() - start_time prediction_latency.observe(latency) prediction_counter.labels(model_version=version).inc() return prediction # Периодическое обновление accuracy def update_accuracy_metric(y_true, y_pred, version): from sklearn.metrics import accuracy_score acc = accuracy_score(y_true, y_pred) model_accuracy.labels(model_version=version).set(acc) Приведенный пример демонстрирует интеграцию Prometheus с ML-сервисом для сбора ключевых метрик. С помощью библиотеки prometheus_client создаются три типа метрик: Counter — считает общее количество предсказаний по версиям модели; Histogram — измеряет задержку инференса (latency); Gauge — хранит текущее значение точности модели (accuracy). Функция predict_with_monitoring() фиксирует время отклика и инкрементирует счетчик предсказаний, а update_accuracy_metric() периодически обновляет метрику точности на основе фактических результатов. Prometheus scraping endpoint экспортирует эти метрики, а Grafana визуализирует их в режиме реального времени. На основе данных можно настраивать алерты, например: latency > 200 мс, accuracy < 85%, error rate > 1%. К основным метрикам производительности относятся: Latency — задержка ответа, измеряемая как p50, p95 и p99 перцентили; значение p99 критично для SLA, так как отражает, что 99 % запросов укладываются в лимит. Throughput — число запросов в секунду; зависит от размера батча и уровня распараллеливания нагрузки. При использовании GPU batching способен повысить throughput в 3–5 раз при незначительном росте latency. Error rate — доля неудачных запросов (timeout, неверный ввод, OOM). При превышении 0.1 % требуется анализ причин. Memory usage — объем потребляемой памяти; особенно важно контролировать для крупных моделей, которые загружают весовые параметры при инициализации. Детекция data drift Data drift — это существенное изменение распределения входных данных по сравнению с обучающим набором (training set), которое может приводить к деградации качества предсказаний. Раннее выявление дрифта данных позволяет вовремя переобучить модель и поддерживать стабильную точность. Существуют 2 основных типа дрифта данных: Covariate shift — изменяется распределение признаков P(X), но сохраняется связь между признаками и таргетом X → y; Concept drift — изменяется условное распределение P(y∣X), то есть меняется сама зависимость между признаками и целевой переменной. Для детекции concept drift анализируют метрики качества модели на новых данных или используют алгоритмы онлайн-дрифта, такие как ADWIN, DDM, EDDM. Для детекции covariate shift применяются статистические тесты: Тест Колмогорова-Смирнова — сравнивает распределения непрерывных признаков; Тест Хи-квадрат — используется для категориальных признаков. Значение p-value < 0.05 указывает на статистически значимые изменения в распределении, сигнализируя о потенциальном дрифте и необходимости анализа. Ниже приведен пример кода для автоматической детекции covariate drift с использованием KS-test для каждой фичи: from scipy.stats import ks_2samp import numpy as np import pandas as pd def detect_drift(reference_data, production_data, threshold=0.05): """ Детекция drift для каждой фичи через KS-test Returns: dict: {feature_name: (statistic, p_value, is_drift)} """ drift_report = {} for column in reference_data.columns: ref_values = reference_data[column].dropna() prod_values = production_data[column].dropna() # KS-test statistic, p_value = ks_2samp(ref_values, prod_values) is_drift = p_value < threshold drift_report[column] = { 'statistic': statistic, 'p_value': p_value, 'is_drift': is_drift } return drift_report # Пример использования reference = pd.DataFrame({ 'feature_1': np.random.normal(0, 1, 1000), 'feature_2': np.random.exponential(2, 1000) }) production = pd.DataFrame({ 'feature_1': np.random.normal(0.5, 1.2, 500), # drift 'feature_2': np.random.exponential(2, 500) # no drift }) drift_results = detect_drift(reference, production) for feature, metrics in drift_results.items(): if metrics['is_drift']: print(f"Drift detected in {feature}: p-value={metrics['p_value']:.4f}") KS-test сравнивает кумулятивные функции распределения (CDF). Статистика показывает максимальное расхождение между CDF, а p-value оценивает вероятность наблюдать такое расхождение случайно. Низкий p-value (<0.05) позволяет отвергнуть гипотезу о равенстве распределений. Population Stability Index (PSI) — альтернативная метрика для детекции data drift. Ее формула: PSI = Σ (P_prod - P_ref) × ln(P_prod / P_ref) где: P_prod — доля наблюдений в бине для production данных; P_ref — доля наблюдений в бине для reference данных. Суммирование производится по всем бинам гистограммы. Интерпретация PSI: < 0.1 — нет дрифта (no drift); 0.1–0.25 — умеренный дрифт (moderate drift); 0.25 — значительный дрифт данных (significant drift). Метод применим к любым распределениям, однако требует подбора количества бинов. PSI учитывает все бины и чувствителен к изменениям в любой части распределения, тогда как KS-test фокусируется на максимальном расхождении CDF. Для concept drift обнаружение проходит сложнее без наличия ground truth labels. Обычно используется отложенная валидация: сбор реальных исходов и сравнение с предсказаниями модели. В задачах с быстрым feedback (fraud detection) задержка составляет минуты–часы, в долгосрочных прогнозах (credit scoring) — недели–месяцы. Стратегия мониторинга: запуск drift detection ежедневно на rolling window последних 7–30 дней данных в продакшен. Алерт срабатывает, если дрифт обнаружен в >20% фич или PSI > 0.25 для критичных признаков, что инициирует процесс переобучения модели. A/B тестирование и откаты версий (rollback) A/B тестирование позволяет валидировать новую версию ML-модели перед полным развертыванием. Трафик делится между control (старой моделью) и treatment (новой моделью), после чего сравниваются бизнес-метрики, а не только технические показатели. Простейшая реализация подобного A/B теста может быть выполнена с помощью feature flags: import random class ModelRouter: def __init__(self, model_a, model_b, traffic_split=0.5): self.model_a = model_a # control self.model_b = model_b # treatment self.traffic_split = traffic_split def predict(self, features, user_id=None): # Детерминированный split на основе user_id if user_id: split_key = hash(user_id) % 100 / 100 else: split_key = random.random() if split_key < self.traffic_split: model_version = 'A' prediction = self.model_a.predict(features) else: model_version = 'B' prediction = self.model_b.predict(features) # Логирование для последующего анализа log_prediction(user_id, features, prediction, model_version) return prediction Приведенный пример демонстрирует реализацию A/B тестирования модели перед деплоем в продакшен через класс ModelRouter. Хеширование user_id обеспечивает консистентность: один и тот же пользователь всегда попадает в одну группу, что крайне важно для корректной оценки метрик. В отличие от случайного распределения без привязки к пользователю, это предотвращает искажение результатов из-за дисперсии выборки. Traffic split настраивается динамически. Обычно новая модель получает сначала 5–10% трафика. Если показатели latency и error rate остаются в норме, доля постепенно увеличивается до 50%. Финальное решение о полном развертывании принимается после накопления статистически значимой выборки. Размер выборки для A/B теста зависит от baseline conversion rate и минимальной детектируемой разницы (MDE). Размер выборки на группу n можно посчитать так: n = 16 × σ² / MDE² где: σ² — дисперсия метрики; MDE — минимальная разница, которую хотим обнаружить. Для метрики с σ=0.1 и MDE=0.01 требуется n=16×0.01/0.0001=1600 наблюдений на группу. При 1000 запросов в день тест занимает 3-4 дня. Статистическая значимость оценивается через t-test или Mann-Whitney U-test: from scipy.stats import ttest_ind def evaluate_ab_test(control_metrics, treatment_metrics, alpha=0.05): """ Оценка статистической значимости A/B теста """ t_stat, p_value = ttest_ind(control_metrics, treatment_metrics) control_mean = np.mean(control_metrics) treatment_mean = np.mean(treatment_metrics) lift = (treatment_mean - control_mean) / control_mean * 100 is_significant = p_value < alpha result = { 'control_mean': control_mean, 'treatment_mean': treatment_mean, 'lift_percent': lift, 'p_value': p_value, 'is_significant': is_significant, 'recommendation': 'Deploy' if is_significant and lift > 0 else 'Rollback' } return result # Пример: A/B тест на accuracy control_acc = np.random.normal(0.85, 0.02, 2000) treatment_acc = np.random.normal(0.87, 0.02, 2000) test_result = evaluate_ab_test(control_acc, treatment_acc) print(f"Lift: {test_result['lift_percent']:.2f}%, p-value: {test_result['p_value']:.4f}") print(f"Recommendation: {test_result['recommendation']}") Lift: 2.18%, p-value: 0.0000 Recommendation: Deploy T-test предполагает нормальное распределение метрик, тогда как для непараметрических случаев используют Mann–Whitney U-test. Значение p-value < 0.05 при положительном lift является сигналом к развертыванию новой модели. Хорошим тоном считается реализация механизма отката (rollback) до деплоя, для быстрого восстановления работоспособности системы в случае проблем с новой версией. В Kubernetes rollback поддерживается через revision history, что позволяет безопасно возвращаться к предыдущей стабильной версии: # Просмотр истории деплоев kubectl rollout history deployment/ml-model-deployment # Rollback к предыдущей версии kubectl rollout undo deployment/ml-model-deployment # Rollback к конкретной ревизии kubectl rollout undo deployment/ml-model-deployment --to-revision=3 Rollback занимает обычно 10–30 секунд. В этот период Kubernetes постепенно заменяет pod’ы новой версии на старые. Readiness probe гарантирует, что старые pod’ы готовы принимать трафик до выключения новых. Blue-green deployment — альтернативный паттерн развертывания. Создаются две идентичные среды: blue (текущая production) и green (новая версия). Новая версия тестируется в изоляции. После успешной валидации трафик переключается с blue на green через обновление Service selector. Rollback выполняется мгновенно — переключением обратно на blue. Основной минус — удвоенные ресурсы на время деплоя. Canary deployment минимизирует риски при rollout. Новая версия получает сначала 5% трафика. В течение 1–2 часов мониторятся метрики. При положительных результатах доля постепенно увеличивается до 25%, затем 50% и наконец 100%. При деградации показателей rollback возможен на любом этапе. Заключение Развертывание ML-модели в production требует системного подхода, выходящего за рамки обучения алгоритмов. Архитектура сервиса определяет задержку отклика и возможность масштабирования, а выбор между REST API, пакетной обработкой и потоковой обработкой зависит от конкретной задачи. Использование контейнеров и оркестрация кластеров обеспечивают воспроизводимость и автоматизацию развертывания. Мониторинг моделей включает показатели работы системы и качество предсказаний, а контроль за изменением распределения данных и A/B-тестирование позволяют вовремя выявлять проблемы и безопасно внедрять новые версии. Возможность быстрого отката минимизирует время простоя. Применение MLOps превращает разработку ML-систем из исследовательского процесса в инженерную дисциплину, сокращает время от эксперимента до реального использования модели и позволяет непрерывно улучшать ее на основе реальных данных. ### Тестирование статистических гипотез с помощью Python Статистические тесты позволяют принимать обоснованные решения на основе данных. В финансовом анализе, исследованиях и разработке моделей машинного обучения проверка гипотез определяет валидность предположений о данных, выявляет значимые различия между группами и подтверждает применимость математических методов. Python предоставляет инструменты для реализации статистических тестов через библиотеки scipy, statsmodels и numpy. Правильное применение этих методов снижает риск ошибочных выводов и повышает надежность аналитических результатов. Основы статистических гипотез Статистическая гипотеза — утверждение о параметрах или свойствах генеральной совокупности, которое можно проверить на данных выборки. Тестирование гипотез формализует процесс принятия решений в условиях неопределенности. Нулевая гипотеза (H₀) представляет утверждение об отсутствии эффекта или различий. Альтернативная гипотеза (H₁) противоположна нулевой и отражает наличие эффекта. Например, при проверке нормальности распределения H₀ утверждает, что данные следуют нормальному распределению, а H₁ — что не следуют. P-value (вероятностное значение) показывает вероятность получить наблюдаемый или более экстремальный результат при условии истинности нулевой гипотезы. Значение p-value < 0.05 традиционно считается основанием для отклонения H₀. Уровень значимости α (обычно 0.05 или 0.01) определяет порог, ниже которого результат считается статистически значимым. Ошибка первого рода (Type I error) возникает при отклонении истинной нулевой гипотезы. Вероятность такой ошибки равна уровню значимости α. Ошибка второго рода (Type II error) происходит при принятии ложной нулевой гипотезы. Мощность теста (1 - β) определяет вероятность правильного отклонения ложной H₀. Выбор уровня значимости зависит от задачи. В медицинских исследованиях используют α = 0.01 для минимизации ошибок первого рода. В разведочном анализе допустим α = 0.10. Баланс между ошибками первого и второго рода определяется контекстом применения. Тесты нормальности распределения Проверка нормальности распределения важна для применения параметрических статистических методов. Многие тесты и модели предполагают нормальность данных: t-тест, ANOVA, линейная регрессия. Нарушение этого предположения приводит к искаженным результатам. Критерий Шапиро-Уилка Тест Шапиро-Уилка оценивает, насколько выборка согласуется с нормальным распределением. Метод основан на корреляции между данными и соответствующими квантилями нормального распределения. Тест обладает высокой мощностью для выборок размером от 3 до 5000 наблюдений. import numpy as np from scipy import stats import matplotlib.pyplot as plt # Генерация финансовых временных рядов np.random.seed(42) n = 500 # Ряд 1: доходности с нормальным распределением returns_normal = np.random.normal(0.0005, 0.02, n) # Ряд 2: доходности с тяжелыми хвостами (t-распределение) returns_heavy_tail = stats.t.rvs(df=3, loc=0.0005, scale=0.02, size=n) # Ряд 3: цены с трендом (преобразуем доходности в цены) prices = 100 * np.exp(np.cumsum(returns_normal)) # Тест Шапиро-Уилка для доходностей stat_normal, p_normal = stats.shapiro(returns_normal) stat_heavy, p_heavy = stats.shapiro(returns_heavy_tail) stat_prices, p_prices = stats.shapiro(prices) print("Тест Шапиро-Уилка:") print(f"Нормальные доходности: статистика={stat_normal:.4f}, p-value={p_normal:.4f}") print(f"Доходности с тяжелыми хвостами: статистика={stat_heavy:.4f}, p-value={p_heavy:.4f}") print(f"Ценовой ряд: статистика={stat_prices:.4f}, p-value={p_prices:.4f}") # Визуализация fig, axes = plt.subplots(2, 3, figsize=(14, 8)) # Гистограммы axes[0, 0].hist(returns_normal, bins=30, color='darkgray', alpha=0.7, edgecolor='black') axes[0, 0].set_title('Нормальные доходности') axes[0, 0].set_xlabel('Доходность') axes[0, 0].set_ylabel('Частота') axes[0, 1].hist(returns_heavy_tail, bins=30, color='darkgray', alpha=0.7, edgecolor='black') axes[0, 1].set_title('Доходности с тяжелыми хвостами') axes[0, 1].set_xlabel('Доходность') axes[0, 2].hist(prices, bins=30, color='darkgray', alpha=0.7, edgecolor='black') axes[0, 2].set_title('Ценовой ряд') axes[0, 2].set_xlabel('Цена') # Q-Q графики stats.probplot(returns_normal, dist="norm", plot=axes[1, 0]) axes[1, 0].set_title(f'Q-Q график: нормальные (p={p_normal:.3f})') axes[1, 0].get_lines()[0].set_color('black') axes[1, 0].get_lines()[1].set_color('red') stats.probplot(returns_heavy_tail, dist="norm", plot=axes[1, 1]) axes[1, 1].set_title(f'Q-Q график: тяжелые хвосты (p={p_heavy:.3f})') axes[1, 1].get_lines()[0].set_color('black') axes[1, 1].get_lines()[1].set_color('red') stats.probplot(prices, dist="norm", plot=axes[1, 2]) axes[1, 2].set_title(f'Q-Q график: цены (p={p_prices:.3f})') axes[1, 2].get_lines()[0].set_color('black') axes[1, 2].get_lines()[1].set_color('red') plt.tight_layout() plt.show() Тест Шапиро-Уилка: Нормальные доходности: статистика=0.9967, p-value=0.4013 Доходности с тяжелыми хвостами: статистика=0.9028, p-value=0.0000 Ценовой ряд: статистика=0.9327, p-value=0.0000 Рис. 1: Гистограммы и Q-Q графики для трех типов данных. Верхний ряд показывает распределения через гистограммы. Нижний ряд содержит Q-Q графики с p-value из теста Шапиро-Уилка. Нормальные доходности демонстрируют точки вдоль красной диагонали, тяжелые хвосты отклоняются на концах, ценовой ряд показывает систематическое искривление из-за тренда Код генерирует три типа данных для демонстрации различных свойств распределений. Первый ряд представляет доходности с нормальным распределением, второй — доходности с тяжелыми хвостами через t-распределение с 3 степенями свободы, третий — ценовой ряд с трендом. Тест Шапиро-Уилка возвращает тестовую статистику и p-value. Для нормально распределенных доходностей p-value превышает 0.05, что не дает оснований отклонить нулевую гипотезу о нормальности. Доходности с тяжелыми хвостами показывают низкий p-value (обычно < 0.01), указывая на отклонение от нормальности. Ценовой ряд также демонстрирует значимое отклонение из-за наличия тренда. Q-Q графики визуализируют соответствие квантилей данных квантилям нормального распределения. Точки, лежащие на диагональной линии, указывают на нормальность. Отклонения в хвостах распределения (концах графика) свидетельствуют о тяжелых или легких хвостах относительно нормального распределения. Критерий Харке-Бера Тест Харке-Бера (Jarque-Bera) проверяет нормальность через асимметрию (skewness) и эксцесс (kurtosis) распределения. Метод особенно эффективен для больших выборок (n > 2000) и чувствителен к отклонениям в хвостах распределения. Формула теста: JB = (n/6) × (S² + (K - 3)²/4) где: n — размер выборки; S — коэффициент асимметрии; K — коэффициент эксцесса. Для нормального распределения S = 0 и K = 3. Статистика JB следует распределению χ² с двумя степенями свободы. from scipy.stats import jarque_bera, skew, kurtosis # Тест Харке-Бера jb_normal, p_jb_normal = jarque_bera(returns_normal) jb_heavy, p_jb_heavy = jarque_bera(returns_heavy_tail) # Вычисление асимметрии и эксцесса skew_normal = skew(returns_normal) kurt_normal = kurtosis(returns_normal, fisher=True) # fisher=True дает excess kurtosis skew_heavy = skew(returns_heavy_tail) kurt_heavy = kurtosis(returns_heavy_tail, fisher=True) print("\nТест Харке-Бера:") print(f"Нормальные доходности: JB={jb_normal:.4f}, p-value={p_jb_normal:.4f}") print(f" Асимметрия: {skew_normal:.4f}, Эксцесс: {kurt_normal:.4f}") print(f"Доходности с тяжелыми хвостами: JB={jb_heavy:.4f}, p-value={p_jb_heavy:.4f}") print(f" Асимметрия: {skew_heavy:.4f}, Эксцесс: {kurt_heavy:.4f}") Тест Харке-Бера: Нормальные доходности: JB=4.0581, p-value=0.1315 Асимметрия: 0.1796, Эксцесс: 0.2564 Доходности с тяжелыми хвостами: JB=1146.3943, p-value=0.0000 Асимметрия: -0.5854, Эксцесс: 7.3250 Тест Харке-Бера дополняет метод Шапиро-Уилка, предоставляя информацию о конкретных характеристиках распределения. Высокий эксцесс (> 3) указывает на тяжелые хвосты — частое свойство финансовых доходностей. Асимметрия показывает смещение распределения влево (отрицательные значения) или вправо (положительные значения). Параметр fisher=True в функции kurtosis возвращает excess kurtosis (эксцесс минус 3), где значение 0 соответствует нормальному распределению. Положительный excess kurtosis означает более тяжелые хвосты по сравнению с нормальным распределением, что характерно для финансовых рядов. Выбор между тестами Шапиро-Уилка и Харке-Бера зависит от размера выборки и цели анализа. Для малых выборок (n < 50) предпочтителен метод Шапиро-Уилка. Тест Харке-Бера эффективен на больших выборках и дает понимание механизма отклонения от нормальности через асимметрию и эксцесс. Тесты на стационарность временных рядов Стационарность временного ряда подразумевает постоянство статистических свойств (среднего, дисперсии, автокорреляции) во времени. Нестационарные ряды усложняют прогнозирование и моделирование, приводя к ложной корреляции между независимыми рядами. Расширенный тест Дики-Фуллера (ADF) Тест Дики-Фуллера проверяет наличие единичного корня в авторегрессионной модели. Единичный корень указывает на нестационарность ряда. Расширенная версия (Augmented Dickey-Fuller, ADF) учитывает более сложную структуру автокорреляции через добавление лагированных разностей. ADF тестирует уравнение: Δy_t = α + βt + γy_{t-1} + δ₁Δy_{t-1} + ... + δ_pΔy_{t-p} + ε_t где: Δy_t — первая разность ряда (y_t - y_{t-1}); α — константа; βt — детерминированный тренд; γ — коэффициент при лагированном значении; δ_i — коэффициенты при лагированных разностях; ε_t — случайная ошибка. Нулевая гипотеза: γ = 0 (наличие единичного корня, ряд нестационарен). Альтернативная гипотеза: γ < 0 (ряд стационарен). from statsmodels.tsa.stattools import adfuller, kpss # Генерация данных для тестов стационарности np.random.seed(123) n = 300 # Стационарный ряд (доходности) stationary_series = np.random.normal(0, 1, n) # Нестационарный ряд с трендом (цены) trend = np.linspace(100, 150, n) noise = np.random.normal(0, 2, n) non_stationary_trend = trend + noise # Нестационарный ряд со случайным блужданием random_walk = 100 + np.cumsum(np.random.normal(0, 1, n)) # Тест ADF def run_adf_test(series, name): result = adfuller(series, autolag='AIC') print(f"\n{name}:") print(f" ADF статистика: {result[0]:.4f}") print(f" P-value: {result[1]:.4f}") print(f" Количество лагов: {result[2]}") print(f" Критические значения:") for key, value in result[4].items(): print(f" {key}: {value:.4f}") return result[1] p_stationary = run_adf_test(stationary_series, "Стационарный ряд") p_trend = run_adf_test(non_stationary_trend, "Ряд с трендом") p_random_walk = run_adf_test(random_walk, "Случайное блуждание") # Визуализация fig, axes = plt.subplots(3, 1, figsize=(12, 10)) axes[0].plot(stationary_series, color='black', linewidth=1) axes[0].set_title(f'Стационарный ряд (ADF p-value: {p_stationary:.4f})') axes[0].set_ylabel('Значение') axes[0].axhline(y=0, color='red', linestyle='--', alpha=0.5) axes[1].plot(non_stationary_trend, color='black', linewidth=1) axes[1].set_title(f'Ряд с трендом (ADF p-value: {p_trend:.4f})') axes[1].set_ylabel('Значение') axes[2].plot(random_walk, color='black', linewidth=1) axes[2].set_title(f'Случайное блуждание (ADF p-value: {p_random_walk:.4f})') axes[2].set_xlabel('Время') axes[2].set_ylabel('Значение') plt.tight_layout() plt.show() Стационарный ряд: ADF статистика: -16.5125 P-value: 0.0000 Количество лагов: 0 Критические значения: 1%: -3.4524 5%: -2.8713 10%: -2.5719 Ряд с трендом: ADF статистика: 0.3017 P-value: 0.9774 Количество лагов: 16 Критические значения: 1%: -3.4537 5%: -2.8718 10%: -2.5722 Случайное блуждание: ADF статистика: -0.5244 P-value: 0.8872 Количество лагов: 0 Критические значения: 1%: -3.4524 5%: -2.8713 10%: -2.5719 Рис. 2: Три временных ряда с различной стационарностью. Стационарный ряд колеблется вокруг нуля без тренда. Ряд с трендом показывает систематический рост. Случайное блуждание демонстрирует накопление случайных изменений без возврата к среднему Код генерирует три типа временных рядов с различными свойствами стационарности. Функция adfuller принимает параметр autolag='AIC', который автоматически выбирает оптимальное количество лагов через информационный критерий Акаике. Это снижает риск неправильной спецификации модели. Стационарный ряд показывает низкую ADF статистику (сильно отрицательную) и p-value < 0.05, что позволяет отклонить нулевую гипотезу о наличии единичного корня. Ряд с детерминированным трендом и случайное блуждание демонстрируют высокий p-value (> 0.05), подтверждая нестационарность. Критические значения на уровнях 1%, 5% и 10% позволяют оценить силу отклонения нулевой гипотезы. Если ADF статистика меньше критического значения, гипотеза о единичном корне отклоняется. Сравнение с несколькими уровнями значимости дает более полную картину. Тест Квятковского-Филлипса-Шмидта-Шина (KPSS) Тест KPSS использует противоположную логику относительно ADF. Нулевая гипотеза утверждает стационарность ряда, альтернативная — наличие единичного корня. Комбинация ADF и KPSS дает более надежную оценку стационарности. # Тест KPSS def run_kpss_test(series, name): result = kpss(series, regression='c', nlags='auto') print(f"\n{name}:") print(f" KPSS статистика: {result[0]:.4f}") print(f" P-value: {result[1]:.4f}") print(f" Количество лагов: {result[2]}") print(f" Критические значения:") for key, value in result[3].items(): print(f" {key}: {value:.4f}") return result[1] print("\nТест KPSS:") kpss_stationary = run_kpss_test(stationary_series, "Стационарный ряд") kpss_trend = run_kpss_test(non_stationary_trend, "Ряд с трендом") kpss_random_walk = run_kpss_test(random_walk, "Случайное блуждание") # Сводная таблица результатов print("\n" + "="*60) print("Сводная таблица тестов стационарности:") print("="*60) print(f"{'Ряд':<25} {'ADF p-value':<15} {'KPSS p-value':<15} {'Вывод'}") print("-"*60) print(f"{'Стационарный':<25} {p_stationary:<15.4f} {kpss_stationary:<15.4f} {'Стационарен'}") print(f"{'С трендом':<25} {p_trend:<15.4f} {kpss_trend:<15.4f} {'Нестационарен'}") print(f"{'Случайное блуждание':<25} {p_random_walk:<15.4f} {kpss_random_walk:<15.4f} {'Нестационарен'}") Тест KPSS: Стационарный ряд: KPSS статистика: 0.1566 P-value: 0.1000 Количество лагов: 2 Критические значения: 10%: 0.3470 5%: 0.4630 2.5%: 0.5740 1%: 0.7390 Ряд с трендом: KPSS статистика: 2.8245 P-value: 0.0100 Количество лагов: 10 Критические значения: 10%: 0.3470 5%: 0.4630 2.5%: 0.5740 1%: 0.7390 Случайное блуждание: KPSS статистика: 1.6830 P-value: 0.0100 Количество лагов: 10 Критические значения: 10%: 0.3470 5%: 0.4630 2.5%: 0.5740 1%: 0.7390 ============================================================ Сводная таблица тестов стационарности: ============================================================ Ряд ADF p-value KPSS p-value Вывод ------------------------------------------------------------ Стационарный 0.0000 0.1000 Стационарен С трендом 0.9774 0.0100 Нестационарен Случайное блуждание 0.8872 0.0100 Нестационарен Параметр regression='c' в функции kpss указывает на модель с константой без тренда. Для рядов с явным трендом используют regression='ct' (константа и тренд). Параметр nlags='auto' автоматически определяет количество лагов по формуле Ньюи-Уэста. Интерпретация комбинации тестов: ADF p-value < 0.05, KPSS p-value > 0.05: ряд стационарен; ADF p-value > 0.05, KPSS p-value < 0.05: ряд нестационарен; Оба p-value > 0.05: результаты неоднозначны, требуется дополнительный анализ; Оба p-value < 0.05: возможна разностно-стационарная модель (difference-stationary model). Стационарный ряд проходит оба теста согласованно. Ряд с трендом и случайное блуждание показывают нестационарность в обоих тестах. Использование двух тестов с противоположными нулевыми гипотезами снижает риск ошибочных выводов. Сравнение выборок Сравнение двух или более выборок определяет, принадлежат ли они одной генеральной совокупности или имеют значимые различия. Задача возникает при A/B тестировании, сравнении результатов экспериментов, оценке эффективности различных методов. T-тест для независимых выборок T-тест сравнивает средние значения двух выборок при предположении нормальности распределений. Метод подходит для выборок с приблизительно равными дисперсиями (гомоскедастичность). Для разных дисперсий используют Т-тест Уэлча. from scipy.stats import ttest_ind, mannwhitneyu, levene # Генерация данных для сравнения np.random.seed(456) n_samples = 100 # Выборка A: доходности стратегии A strategy_a = np.random.normal(0.001, 0.015, n_samples) # Выборка B: доходности стратегии B (немного выше среднего) strategy_b = np.random.normal(0.0015, 0.015, n_samples) # Выборка C: стратегия с другой дисперсией strategy_c = np.random.normal(0.001, 0.025, n_samples) # Тест Левене для проверки равенства дисперсий levene_ab = levene(strategy_a, strategy_b) levene_ac = levene(strategy_a, strategy_c) print("Тест Левене (равенство дисперсий):") print(f"Стратегии A vs B: статистика={levene_ab.statistic:.4f}, p-value={levene_ab.pvalue:.4f}") print(f"Стратегии A vs C: статистика={levene_ac.statistic:.4f}, p-value={levene_ac.pvalue:.4f}") # T-тест для независимых выборок # equal_var=True для обычного t-теста, False для Welch's t-test t_stat_ab, p_value_ab = ttest_ind(strategy_a, strategy_b, equal_var=True) t_stat_ac, p_value_ac = ttest_ind(strategy_a, strategy_c, equal_var=False) print("\nT-тест для независимых выборок:") print(f"Стратегии A vs B: t-статистика={t_stat_ab:.4f}, p-value={p_value_ab:.4f}") print(f"Стратегии A vs C (Welch): t-статистика={t_stat_ac:.4f}, p-value={p_value_ac:.4f}") # Описательная статистика print("\nОписательная статистика:") print(f"Стратегия A: среднее={np.mean(strategy_a):.6f}, std={np.std(strategy_a, ddof=1):.6f}") print(f"Стратегия B: среднее={np.mean(strategy_b):.6f}, std={np.std(strategy_b, ddof=1):.6f}") print(f"Стратегия C: среднее={np.mean(strategy_c):.6f}, std={np.std(strategy_c, ddof=1):.6f}") # Визуализация fig, axes = plt.subplots(1, 2, figsize=(14, 5)) # Boxplot axes[0].boxplot([strategy_a, strategy_b, strategy_c], labels=['Стратегия A', 'Стратегия B', 'Стратегия C']) axes[0].set_ylabel('Доходность') axes[0].set_title('Распределение доходностей стратегий') axes[0].grid(True, alpha=0.3) # Гистограммы axes[1].hist(strategy_a, bins=20, alpha=0.5, label='Стратегия A', color='gray', edgecolor='black') axes[1].hist(strategy_b, bins=20, alpha=0.5, label='Стратегия B', color='blue', edgecolor='black') axes[1].hist(strategy_c, bins=20, alpha=0.5, label='Стратегия C', color='red', edgecolor='black') axes[1].set_xlabel('Доходность') axes[1].set_ylabel('Частота') axes[1].set_title('Наложенные распределения') axes[1].legend() axes[1].grid(True, alpha=0.3) plt.tight_layout() plt.show() Тест Левене (равенство дисперсий): Стратегии A vs B: статистика=2.6367, p-value=0.1060 Стратегии A vs C: статистика=32.5772, p-value=0.0000 T-тест для независимых выборок: Стратегии A vs B: t-статистика=1.5353, p-value=0.1263 Стратегии A vs C (Welch): t-статистика=-0.4556, p-value=0.6493 Описательная статистика: Стратегия A: среднее=0.003171, std=0.013876 Стратегия B: среднее=-0.000132, std=0.016441 Стратегия C: среднее=0.004468, std=0.024867 Рис. 3: Сравнение трех стратегий. Боксплот слева показывает медианы, квартили и выбросы. Наложенные гистограммы справа демонстрируют формы распределений. Стратегия C имеет более широкое распределение при схожем среднем со стратегией A Тест Левене проверяет гомоскедастичность — равенство дисперсий между выборками. Результат определяет выбор между стандартным t-тестом (equal_var=True) и t-тестом Уэлча (equal_var=False). Метод Уэлча не требует равенства дисперсий и более устойчив в случае их нарушения. Параметр ddof=1 в функции np.std обеспечивает несмещенную оценку стандартного отклонения через деление на (n-1) вместо n. Это корректирует систематическое занижение дисперсии в выборках. P-value < 0.05 указывает на статистически значимое различие средних. Стратегии A и B могут не показать значимых различий несмотря на разные средние из-за высокой дисперсии доходностей. Стратегия C с большей дисперсией требует проведения t-теста Уэлча для корректного сравнения. Тест Манна-Уитни Тест Манна-Уитни (Mann-Whitney U test) — непараметрическая альтернатива t-тесту, не требующая нормальности распределений. Метод сравнивает ранги наблюдений вместо их значений, что делает его устойчивым к выбросам и применимым для ординальных данных. # Генерация данных с ненормальным распределением np.random.seed(789) # Экспоненциальное распределение (сильно асимметричное) sample_exp1 = np.random.exponential(scale=2.0, size=80) sample_exp2 = np.random.exponential(scale=2.5, size=80) # Тест Манна-Уитни u_stat, p_value_mw = mannwhitneyu(sample_exp1, sample_exp2, alternative='two-sided') print("\nТест Манна-Уитни (непараметрический):") print(f"U-статистика: {u_stat:.4f}") print(f"P-value: {p_value_mw:.4f}") # Сравнение с t-тестом на тех же данных t_stat_exp, p_value_t = ttest_ind(sample_exp1, sample_exp2) print("\nДля сравнения, t-тест на тех же данных:") print(f"T-статистика: {t_stat_exp:.4f}") print(f"P-value: {p_value_t:.4f}") print("\nОписательная статистика экспоненциальных выборок:") print(f"Выборка 1: среднее={np.mean(sample_exp1):.4f}, медиана={np.median(sample_exp1):.4f}") print(f"Выборка 2: среднее={np.mean(sample_exp2):.4f}, медиана={np.median(sample_exp2):.4f}") Тест Манна-Уитни (непараметрический): U-статистика: 2902.0000 P-value: 0.3100 Для сравнения, t-тест на тех же данных: T-статистика: -1.5443 P-value: 0.1245 Описательная статистика экспоненциальных выборок: Выборка 1: среднее=1.8784, медиана=1.5342 Выборка 2: среднее=2.3214, медиана=1.7470 Параметр alternative в mannwhitneyu определяет тип альтернативной гипотезы: 'two-sided' для двустороннего теста (распределения различаются), 'less' или 'greater' для односторонних тестов (одна выборка систематически больше другой). Тест Манна-Уитни основан на ранжировании объединенных данных обеих выборок. U-статистика подсчитывает количество пар, где значение из первой выборки меньше значения из второй. При равных распределениях ожидается U ≈ n₁ × n₂ / 2. Для экспоненциально распределенных данных тест Манна-Уитни дает более надежные результаты, чем t-тест. T-тест предполагает нормальность и чувствителен к асимметрии распределения. Непараметрический подход избегает этих ограничений, сохраняя статистическую мощность. Выбор между t-тестом и Манна-Уитни зависит от свойств данных. При нормальности распределений t-тест обладает большей мощностью. Для ненормальных данных, выбросов или малых выборок предпочтителен Манна-Уитни. Проверка нормальности через тесты Шапиро-Уилка или Харке-Бера помогает принять обоснованное решение. Тесты на автокорреляцию Автокорреляция временного ряда показывает связь между текущими и предыдущими значениями. Наличие автокорреляции нарушает предположение о независимости наблюдений в регрессионных моделях и влияет на точность стандартных ошибок коэффициентов. Критерий Льюнга-Бокса Тест Льюнга-Бокса проверяет, значимо ли отличаются от нуля первые k автокорреляций. Метод применяется для диагностики остатков моделей временных рядов и проверки гипотезы белого шума. Статистика теста рассчитывается по формуле: Q = n(n + 2) × Σ(ρ²ₖ / (n - k)) где: n — размер выборки; ρₖ — автокорреляция на лаге k; суммирование по k от 1 до m. Статистика Q следует распределению χ² с m степенями свободы при нулевой гипотезе об отсутствии автокорреляции. from statsmodels.stats.diagnostic import acorr_ljungbox from statsmodels.graphics.tsaplots import plot_acf, plot_pacf # Генерация рядов с различной автокорреляцией np.random.seed(101) n = 200 # Белый шум (нет автокорреляции) white_noise = np.random.normal(0, 1, n) # AR(1) процесс с автокорреляцией ar1_series = np.zeros(n) ar1_series[0] = np.random.normal(0, 1) phi = 0.7 # коэффициент автокорреляции for t in range(1, n): ar1_series[t] = phi * ar1_series[t-1] + np.random.normal(0, 1) # MA(1) процесс ma1_series = np.zeros(n) errors = np.random.normal(0, 1, n) theta = 0.6 for t in range(1, n): ma1_series[t] = errors[t] + theta * errors[t-1] # Тест Льюнга-Бокса def ljung_box_test(series, name, lags=10): result = acorr_ljungbox(series, lags=lags, return_df=True) print(f"\n{name} (первые {lags} лагов):") print(result[['lb_stat', 'lb_pvalue']].head(lags)) return result lb_white = ljung_box_test(white_noise, "Белый шум") lb_ar1 = ljung_box_test(ar1_series, "AR(1) процесс") lb_ma1 = ljung_box_test(ma1_series, "MA(1) процесс") # Визуализация автокорреляционных функций fig, axes = plt.subplots(3, 3, figsize=(14, 10)) # Временные ряды axes[0, 0].plot(white_noise, color='black', linewidth=0.8) axes[0, 0].set_title('Белый шум') axes[0, 0].set_ylabel('Значение') axes[1, 0].plot(ar1_series, color='black', linewidth=0.8) axes[1, 0].set_title('AR(1) процесс') axes[1, 0].set_ylabel('Значение') axes[2, 0].plot(ma1_series, color='black', linewidth=0.8) axes[2, 0].set_title('MA(1) процесс') axes[2, 0].set_xlabel('Время') axes[2, 0].set_ylabel('Значение') # ACF графики plot_acf(white_noise, lags=20, ax=axes[0, 1], color='black') axes[0, 1].set_title('ACF: Белый шум') plot_acf(ar1_series, lags=20, ax=axes[1, 1], color='black') axes[1, 1].set_title('ACF: AR(1)') plot_acf(ma1_series, lags=20, ax=axes[2, 1], color='black') axes[2, 1].set_title('ACF: MA(1)') # PACF графики plot_pacf(white_noise, lags=20, ax=axes[0, 2], color='black', method='ywm') axes[0, 2].set_title('PACF: Белый шум') plot_pacf(ar1_series, lags=20, ax=axes[1, 2], color='black', method='ywm') axes[1, 2].set_title('PACF: AR(1)') plot_pacf(ma1_series, lags=20, ax=axes[2, 2], color='black', method='ywm') axes[2, 2].set_title('PACF: MA(1)') plt.tight_layout() plt.show() Белый шум (первые 10 лагов): lb_stat lb_pvalue 1 1.447212 0.228976 2 1.783037 0.410033 3 2.578395 0.461290 4 3.271393 0.513478 5 6.742075 0.240540 6 6.861447 0.333851 7 6.905907 0.438742 8 11.719403 0.164169 9 14.848603 0.095179 10 14.946305 0.134032 AR(1) процесс (первые 10 лагов): lb_stat lb_pvalue 1 89.998819 2.383022e-21 2 118.799245 1.596143e-26 3 130.142268 5.039811e-28 4 132.752973 1.003614e-27 5 133.113759 5.195390e-27 6 133.195003 2.728971e-26 7 133.256440 1.311156e-25 8 133.586675 5.101737e-25 9 136.281179 6.035908e-25 10 138.158927 1.004298e-24 MA(1) процесс (первые 10 лагов): lb_stat lb_pvalue 1 41.584263 1.128984e-10 2 41.631316 9.117477e-10 3 44.641782 1.102534e-09 4 47.149906 1.419125e-09 5 49.365843 1.868175e-09 6 49.668824 5.477335e-09 7 51.036384 9.037416e-09 8 54.476757 5.579043e-09 9 56.507844 6.277245e-09 10 56.820572 1.440450e-08 Рис. 4: Три типа временных рядов с ACF и PACF графиками. Левый столбец показывает исходные ряды. Средний столбец содержит ACF с доверительными интервалами (синие области). Правый столбец — PACF графики. Белый шум имеет незначимые автокорреляции. AR(1) показывает экспоненциальное затухание ACF и один значимый лаг PACF. MA(1) демонстрирует один значимый лаг ACF и затухание PACF Функция acorr_ljungbox вычисляет статистику Льюнга-Бокса для указанного количества лагов. Параметр return_df=True возвращает результаты в формате датафрейма с колонками lb_stat (статистика теста) и lb_pvalue (p-value). Белый шум показывает высокие p-value для всех лагов, подтверждая отсутствие автокорреляции. AR(1) процесс демонстрирует значимую автокорреляцию на первых лагах с низкими p-value. MA(1) процесс показывает автокорреляцию только на первом лаге, что соответствует теоретическим свойствам модели скользящего среднего. ACF (Autocorrelation Function) отображает корреляцию ряда с его лагированными значениями. PACF (Partial Autocorrelation Function) показывает корреляцию после удаления влияния промежуточных лагов. AR процессы имеют экспоненциально затухающую ACF и резкий обрыв PACF на лаге p. MA процессы демонстрируют обратную картину: обрыв ACF на лаге q и затухающую PACF. Тест Дарбина-Уотсона Тест Дарбина-Уотсона специально разработан для обнаружения автокорреляции первого порядка в остатках регрессионной модели. Метод широко используется в эконометрике для диагностики линейных регрессий. Статистика теста рассчитывается по формуле: DW = Σ(eₜ - eₜ₋₁)² / Σeₜ² где eₜ — остатки модели в момент времени t. Значение DW находится в диапазоне [0, 4]. DW ≈ 2 указывает на отсутствие автокорреляции. DW < 2 свидетельствует о положительной автокорреляции, DW > 2 — об отрицательной. from statsmodels.regression.linear_model import OLS from statsmodels.tools import add_constant from statsmodels.stats.stattools import durbin_watson # Генерация данных для регрессии np.random.seed(202) n = 150 # Независимая переменная x = np.linspace(0, 10, n) # Зависимая переменная с автокоррелированными остатками true_beta = 2.5 true_alpha = 10 # Модель 1: остатки без автокорреляции errors_no_ac = np.random.normal(0, 2, n) y_no_ac = true_alpha + true_beta * x + errors_no_ac # Модель 2: остатки с положительной автокорреляцией errors_ac = np.zeros(n) errors_ac[0] = np.random.normal(0, 2) rho = 0.8 # коэффициент автокорреляции остатков for t in range(1, n): errors_ac[t] = rho * errors_ac[t-1] + np.random.normal(0, 2) y_with_ac = true_alpha + true_beta * x + errors_ac # Регрессионные модели X = add_constant(x) model_no_ac = OLS(y_no_ac, X).fit() model_with_ac = OLS(y_with_ac, X).fit() # Тест Дарбина-Уотсона dw_no_ac = durbin_watson(model_no_ac.resid) dw_with_ac = durbin_watson(model_with_ac.resid) print("Тест Дарбина-Уотсона:") print(f"Модель без автокорреляции: DW = {dw_no_ac:.4f}") print(f"Модель с автокорреляцией: DW = {dw_with_ac:.4f}") print("\nИнтерпретация DW статистики:") print("DW ≈ 2.0: нет автокорреляции") print("DW < 1.5: положительная автокорреляция") print("DW > 2.5: отрицательная автокорреляция") # Визуализация остатков fig, axes = plt.subplots(2, 2, figsize=(14, 10)) # Регрессии axes[0, 0].scatter(x, y_no_ac, alpha=0.5, color='gray', s=20) axes[0, 0].plot(x, model_no_ac.predict(X), color='red', linewidth=2) axes[0, 0].set_title(f'Регрессия без автокорреляции (DW={dw_no_ac:.2f})') axes[0, 0].set_xlabel('X') axes[0, 0].set_ylabel('Y') axes[0, 1].scatter(x, y_with_ac, alpha=0.5, color='gray', s=20) axes[0, 1].plot(x, model_with_ac.predict(X), color='red', linewidth=2) axes[0, 1].set_title(f'Регрессия с автокорреляцией (DW={dw_with_ac:.2f})') axes[0, 1].set_xlabel('X') axes[0, 1].set_ylabel('Y') # Остатки во времени axes[1, 0].plot(model_no_ac.resid, color='black', linewidth=1) axes[1, 0].axhline(y=0, color='red', linestyle='--', alpha=0.5) axes[1, 0].set_title('Остатки без автокорреляции') axes[1, 0].set_xlabel('Наблюдение') axes[1, 0].set_ylabel('Остаток') axes[1, 1].plot(model_with_ac.resid, color='black', linewidth=1) axes[1, 1].axhline(y=0, color='red', linestyle='--', alpha=0.5) axes[1, 1].set_title('Остатки с автокорреляцией') axes[1, 1].set_xlabel('Наблюдение') axes[1, 1].set_ylabel('Остаток') plt.tight_layout() plt.show() Тест Дарбина-Уотсона: Модель без автокорреляции: DW = 1.8046 Модель с автокорреляцией: DW = 0.4821 Интерпретация DW статистики: DW ≈ 2.0: нет автокорреляции DW < 1.5: положительная автокорреляция DW > 2.5: отрицательная автокорреляция Рис. 5: Сравнение регрессий с различными свойствами остатков. Верхний ряд показывает диаграммы рассеяния с линиями регрессии. Нижний ряд отображает остатки во времени. Модель без автокорреляции имеет хаотично распределенные остатки. Модель с автокорреляцией демонстрирует плавные волны остатков, подтверждая зависимость между соседними значениями Функция add_constant добавляет столбец единиц к матрице регрессоров для оценки свободного члена. Класс OLS реализует метод наименьших квадратов, метод fit() возвращает объект с результатами оценки модели. Модель без автокорреляции показывает DW ≈ 2.0, что соответствует независимым остаткам. Модель с автокоррелированными остатками демонстрирует DW < 1.5, указывая на положительную автокорреляцию. Визуальный паттерн остатков во времени подтверждает результаты: независимые остатки хаотично колеблются вокруг нуля, автокоррелированные показывают плавные волны с периодами устойчивых положительных и отрицательных значений. Автокорреляция остатков нарушает эффективность оценок метода наименьших квадратов. Стандартные ошибки коэффициентов становятся заниженными, что приводит к завышенной статистической значимости предикторов. Обнаружение автокорреляции требует применения альтернативных методов оценки: обобщенный метод наименьших квадратов (GLS), модели ARIMA для остатков, робастные стандартные ошибки Ньюи-Уэста. Множественное тестирование и корректировка p-value При одновременном проведении множества статистических тестов возрастает вероятность ошибки первого рода. Если проводить 20 независимых тестов на уровне значимости α = 0.05, ожидаемое количество ложноположительных результатов составит 1 тест. Для 100 тестов — уже 5 ложных открытий. Проблема множественного тестирования чаще всего возникает в разведочном анализе данных и отборе признаков для машинного обучения. Без корректировки p-value результаты теряют статистическую валидность. Метод Бонферрони Корректировка Бонферрони — простейший метод контроля групповой вероятности ошибки (Family-wise error rate, FWER). FWER определяет вероятность сделать хотя бы одну ошибку первого рода среди всех тестов. Метод Бонферрони делит уровень значимости на количество тестов: α_adj = α / m. from statsmodels.stats.multitest import multipletests # Генерация данных для множественного тестирования np.random.seed(303) n_tests = 50 n_samples = 100 # Создание p-values для разных сценариев # 45 тестов без эффекта (нулевая гипотеза верна) p_values_null = [] for i in range(45): sample1 = np.random.normal(0, 1, n_samples) sample2 = np.random.normal(0, 1, n_samples) _, p = ttest_ind(sample1, sample2) p_values_null.append(p) # 5 тестов с реальным эффектом p_values_effect = [] for i in range(5): sample1 = np.random.normal(0, 1, n_samples) sample2 = np.random.normal(0.5, 1, n_samples) # сдвиг среднего _, p = ttest_ind(sample1, sample2) p_values_effect.append(p) # Объединение всех p-values p_values = np.array(p_values_null + p_values_effect) # Без корректировки significant_uncorrected = np.sum(p_values < 0.05) # Корректировка Бонферрони reject_bonf, pvals_bonf, _, _ = multipletests(p_values, alpha=0.05, method='bonferroni') significant_bonf = np.sum(reject_bonf) # Корректировка FDR (Benjamini-Hochberg) reject_fdr, pvals_fdr, _, _ = multipletests(p_values, alpha=0.05, method='fdr_bh') significant_fdr = np.sum(reject_fdr) print("Результаты множественного тестирования:") print(f"Всего тестов: {n_tests}") print(f"Тесты с реальным эффектом: 5") print(f"Тесты без эффекта: 45") print(f"\nБез корректировки (α=0.05):") print(f" Значимых результатов: {significant_uncorrected}") print(f"\nМетод Бонферрони:") print(f" Скорректированный α: {0.05/n_tests:.4f}") print(f" Значимых результатов: {significant_bonf}") print(f"\nМетод FDR (Benjamini-Hochberg):") print(f" Значимых результатов: {significant_fdr}") # Визуализация p-values fig, axes = plt.subplots(1, 3, figsize=(15, 5)) # Сортировка p-values для визуализации sorted_idx = np.argsort(p_values) sorted_p = p_values[sorted_idx] # График 1: Все p-values без корректировки axes[0].scatter(range(n_tests), sorted_p, color='black', s=30) axes[0].axhline(y=0.05, color='red', linestyle='--', label='α=0.05') axes[0].set_xlabel('Ранг теста') axes[0].set_ylabel('P-value') axes[0].set_title(f'Без корректировки ({significant_uncorrected} значимых)') axes[0].legend() axes[0].set_yscale('log') # График 2: Корректировка Бонферрони axes[1].scatter(range(n_tests), sorted_p, color='black', s=30) axes[1].axhline(y=0.05/n_tests, color='red', linestyle='--', label=f'α_Bonf={0.05/n_tests:.4f}') axes[1].set_xlabel('Ранг теста') axes[1].set_ylabel('P-value') axes[1].set_title(f'Бонферрони ({significant_bonf} значимых)') axes[1].legend() axes[1].set_yscale('log') # График 3: FDR axes[2].scatter(range(n_tests), sorted_p, color='black', s=30) # Линия FDR fdr_line = 0.05 * np.arange(1, n_tests + 1) / n_tests axes[2].plot(range(n_tests), fdr_line, color='red', linestyle='--', label='FDR порог') axes[2].set_xlabel('Ранг теста') axes[2].set_ylabel('P-value') axes[2].set_title(f'FDR ({significant_fdr} значимых)') axes[2].legend() axes[2].set_yscale('log') plt.tight_layout() plt.show() Результаты множественного тестирования: Всего тестов: 50 Тесты с реальным эффектом: 5 Тесты без эффекта: 45 Без корректировки (α=0.05): Значимых результатов: 7 Метод Бонферрони: Скорректированный α: 0.0010 Значимых результатов: 4 Метод FDR (Benjamini-Hochberg): Значимых результатов: 4 Рис. 6: Сравнение методов корректировки множественного тестирования. Все графики используют логарифмическую шкалу для p-значений. Слева — необработанные p-values с порогом α = 0.05. В центре — жесткий порог Бонферрони. Справа — адаптивный порог FDR, растущий с рангом теста. FDR находит больше эффектов, чем Бонферрони, контролируя долю ложных открытий вместо вероятности хотя бы одной ошибки Код выше демонстрирует различия методов на логарифмической шкале. FDR использует адаптивный порог, который растет с рангом теста: pₖ ≤ (k/m) × α. Это позволяет обнаружить больше эффектов по сравнению с Бонферрони, сохраняя контроль над долей ложных открытий. Без корректировки обнаруживается около 5-7 значимых результатов: 5 истинно положительных (реальный эффект) плюс 2-3 ложноположительных из 45 тестов без эффекта при α = 0.05. Метод Бонферрони снижает порог до α / 50 = 0.001, что резко уменьшает количество обнаруженных эффектов. Метод находит только самые сильные эффекты, минимизируя ложноположительные результаты, но увеличивая ошибки второго рода. Бонферрони излишне консервативен при большом количестве тестов. Для 1000 тестов скорректированный уровень значимости составляет 0.00005, что практически исключает обнаружение умеренных эффектов. Метод подходит для критических применений, где ошибки первого рода недопустимы. False Discovery Rate (FDR) FDR контролирует ожидаемую долю ложных открытий среди всех отклоненных нулевых гипотез. В отличие от FWER, который контролирует вероятность хотя бы одной ошибки, FDR допускает некоторое количество ложноположительных результатов, повышая мощность теста. Метод Benjamini-Hochberg реализует FDR через следующую процедуру: Отсортировать p-значения по возрастанию: p₁ ≤ p₂ ≤ ... ≤ pₘ; Найти максимальный индекс k, для которого pₖ ≤ (k/m) × α; Отклонить нулевые гипотезы для всех тестов с индексами от 1 до k. FDR = 0.05 означает, что среди всех отклоненных гипотез ожидается не более 5% ложных открытий. Если обнаружено 20 значимых результатов при FDR = 0.05, ожидаемое количество ложноположительных составляет 1 результат. Выбор между Бонферрони и FDR определяется контекстом: Бонферрони: медицинские исследования, регуляторные решения, ситуации с высокой ценой ошибок первого рода; FDR: разведочный анализ, скрининг переменных для моделей, геномика, ситуации с приемлемой долей ложных открытий. Для разведочного анализа с последующей валидацией FDR предпочтительнее. Метод обеспечивает баланс между обнаружением эффектов и контролем ошибок. Для подтверждающих исследований используют Бонферрони или более мощные методы контроля FWER (Holm, Hochberg). Заключение Статистические тесты превращают данные в обоснованные выводы, снижая субъективность аналитических решений. Проверка нормальности через Шапиро-Уилка или Харке-Бера определяет применимость параметрических методов. Тесты стационарности ADF и KPSS выявляют структурные свойства временных рядов, критичные для моделирования. Сравнение выборок через t-тесты или Манна-Уитни количественно оценивает различия между группами. Диагностика автокорреляции обеспечивает валидность регрессионных моделей. Python через scipy и statsmodels предоставляет инструменты для всех перечисленных задач с минимальным кодом. Корректное применение тестов требует понимания их предположений и ограничений. Множественное тестирование усложняет интерпретацию, но методы Бонферрони и FDR контролируют ошибки. Комбинация нескольких тестов дает более надежную картину: ADF с KPSS для стационарности, Шапиро-Уилка с Q-Q графиками для нормальности. Статистическая строгость на этапе анализа данных закладывает фундамент для качественных моделей и обоснованных бизнес-решений. ### Показатели TWR (Time-Weighted Return), MWR (Money-Weighted Return) и MDR (Modified Dietz Return) Оценка доходности инвестиционного портфеля требует учета денежных потоков — пополнений и выводов средств. Простой расчет процентного изменения стоимости портфеля дает искаженную картину, если инвестор вносил или выводил деньги в течение периода. Внесение средств перед ростом рынка завышает результат, вывод перед падением — улучшает показатели, хотя решения управляющего могли быть идентичными. Три основные метрики: TWR, MWR и MDR — решают эту проблему разными способами. TWR исключает влияние денежных потоков и показывает чистую эффективность инвестиционных решений. MWR учитывает тайминг денежных потоков и отражает реальную доходность инвестора. MDR предлагает упрощенный расчет с приемлемой точностью для большинства практических задач. Time-Weighted Return (TWR) TWR измеряет доходность портфеля независимо от размера и времени денежных потоков. Метод разбивает период владения на подпериоды между каждым движением денег и вычисляет доходность для каждого подпериода отдельно. Расчет TWR осуществляется по формуле: TWR = [(1 + R₁) × (1 + R₂) × ... × (1 + Rₙ)] - 1 где: Rᵢ — доходность i-го подпериода между денежными потоками; n — количество подпериодов; Rᵢ = (Vᵢ - Vᵢ₋₁ - Cᵢ) / Vᵢ₋₁. Переменные для расчета подпериода: Vᵢ — стоимость портфеля в конце подпериода; Vᵢ₋₁ — стоимость портфеля в начале подпериода; Cᵢ — чистый денежный поток в подпериоде (положительный для пополнений, отрицательный для выводов). Метод последовательно капитализирует доходности всех подпериодов, отражая геометрическую среднюю доходность. TWR зачастую применяется для оценки навыков портфельного управляющего. Индустрия управления активами использует TWR как стандарт для сравнения фондов и стратегий, поскольку управляющий не контролирует решения клиентов о пополнении или выводе средств. GIPS (Global Investment Performance Standards) требует расчета доходности по методу TWR для всех композитов и портфелей. Преимущества TWR: Полное устранение влияния денежных потоков на оценку эффективности; Возможность корректного сравнения доходности разных портфелей и бэнчмарков; Соответствие индустриальным стандартам отчетности. Ограничения TWR: Необходимость знать точную стоимость портфеля на момент каждого денежного потока; Вычислительная сложность при частых транзакциях; Показатель не отражает реальную доходность конкретного инвестора. Для портфеля с ежедневными транзакциями расчет TWR требует оценки стоимости 250+ раз в год. Это создает операционную нагрузку для кастодианов и администраторов фондов. Money-Weighted Return (MWR) MWR учитывает влияние размера и тайминга денежных потоков на итоговую доходность инвестора. Метод идентичен внутренней норме доходности (IRR) и показывает ставку дисконтирования, при которой приведенная стоимость всех денежных потоков равна нулю. Расчет MWR производится через уравнение: PV₀ + CF₁/(1+MWR)^t₁ + CF₂/(1+MWR)^t₂ + ... + (CFₙ+PVₙ)/(1+MWR)^tₙ = 0 где: PV₀ — начальная стоимость портфеля (отрицательная, так как это вложение); CFᵢ — денежный поток в момент i (положительный для пополнений, отрицательный для выводов); tᵢ — время денежного потока в долях периода; PVₙ — конечная стоимость портфеля (положительная, так как это возврат); MWR — искомая доходность. Уравнение не имеет аналитического решения и требует численных методов. Алгоритм Ньютона-Рафсона сходится за 5-10 итераций для типичных портфелей. MWR отражает фактическую доходность инвестора с учетом его решений о времени инвестирования. Инвестор, который вносил средства перед ростом рынка, получит высокий MWR. Вывод средств перед падением также улучшит показатель. Это делает MWR релевантным для оценки совокупного результата инвестирования конкретного инвестора. Преимущества MWR: Показывает реальную доходность средств инвестора; Учитывает качество решений о тайминге входа и выхода; Соответствует экономическому смыслу доходности капитала. Ограничения MWR: Невозможность корректного сравнения между портфелями с разными паттернами денежных потоков; Вычислительная сложность численных методов; Смешивание навыков управления портфелем с навыками тайминга вход/выхода с рынка. Пенсионные фонды используют MWR для отчетности перед участниками рныка, поскольку метод показывает доходность их персональных взносов. Для сравнения эффективности управляющих те же фонды применяют TWR. Modified Dietz Return (MDR) MDR предлагает упрощенный расчет доходности с учетом денежных потоков без необходимости знать стоимость портфеля между начальной и конечной датами. Метод взвешивает денежные потоки пропорционально времени их нахождения в портфеле. Расчет MDR производится по формуле: MDR = (V₁ - V₀ - CF) / (V₀ + W) где: V₁ — конечная стоимость портфеля; V₀ — начальная стоимость портфеля; CF — сумма всех денежных потоков за период; W — взвешенные денежные потоки. Взвешивание денежных потоков производится по формуле: W = Σ(CFᵢ × wᵢ) где: CFᵢ — i-й денежный поток; wᵢ — весовой коэффициент для i-го потока. Весовой коэффициент рассчитывается: wᵢ = (D - Dᵢ) / D где: D — общее количество дней в периоде; Dᵢ — день денежного потока (от начала периода). Денежный поток в первый день периода получает вес близкий к 1, в последний — близкий к 0. Это отражает время нахождения средств в портфеле. MDR применяется когда TWR требует избыточных вычислительных ресурсов, а точность MWR не критична. Банки и брокеры используют MDR для ежемесячной отчетности клиентам по счетам с умеренной активностью. Для периодов до одного месяца погрешность MDR относительно TWR обычно не превышает 0.1-0.3%. Преимущества MDR: Требует только начальную и конечную стоимость портфеля; Простота вычислений без итеративных алгоритмов; Приемлемая точность для большинства практических задач. Ограничения MDR: Приближенный характер расчета; Погрешность растет при высокой волатильности в течение периода; Предположение о линейной доходности внутри периода. Для портфеля с волатильностью 20% годовых погрешность MDR может достигать 1-2% для квартального периода при крупных денежных потоках в середине периода. Сравнительный анализ методов Три метода решают разные задачи и имеют различные вычислительные требования: Характеристика TWR MWR MDR Влияние денежных потоков Исключено Учтено Частично учтено Требуемые данные Оценка на каждый поток Даты и суммы потоков Даты и суммы потоков Сложность расчета Высокая Средняя Низкая Точность Эталонная Точная Приближенная Сравнимость портфелей Да Нет Ограниченно Типичная применимость Оценка управляющего Доходность инвестора Упрощенная отчетность Тайминг денежных потоков влияет на показатели по-разному. Рассмотрим портфель стоимостью 100 000 на начало года. Через 6 месяцев стоимость выросла до 110 000, инвестор внес 50 000. К концу года портфель стоит 170 000. TWR разбивает период на два подпериода: Первые 6 месяцев: (110 000 - 100 000) / 100 000 = 10%; Вторые 6 месяцев: (170 000 - 160 000) / 160 000 = 6.25%; TWR = (1.10 × 1.0625) - 1 = 16.88%. MWR учитывает что 50 000 работали только полгода: Решение уравнения: -100 000 + (-50 000)/(1+MWR)^0.5 + 170 000/(1+MWR) = 0; MWR ≈ 14.2%. MDR взвешивает дополнительные 50 000 с коэффициентом 0.5: MDR = (170 000 - 100 000 - 50 000) / (100 000 + 50 000 × 0.5) = 16%. TWR показывает доходность управления активами. Показатель MWR тут ниже, так как крупное пополнение работало только полгода. MDR дает промежуточную оценку с упрощенным расчетом. Выбор метрики зависит от цели анализа: Оценка навыков управляющего и сравнение с бэнчмарком — TWR; Анализ фактической доходности средств инвестора — MWR; Регулярная отчетность при умеренной активности счета — MDR. Хедж-фонды публикуют TWR для демонстрации качества стратегии. Семейные фонды (Family-office-funds) используют MWR для оценки результатов совокупного капитала с учетом решений о распределении между управляющими. Практическое применение Оценка навыков портфельного управляющего требует изоляции результатов инвестиционных решений от влияния денежных потоков. TWR позволяет сравнить доходность управляющего с рыночным индексом на равных условиях. Управляющий, показавший TWR 18% при росте индекса на 15%, продемонстрировал альфа 3%. Если бы использовался MWR, крупное пополнение клиентом счета перед коррекцией исказило бы результат. Институциональные инвесторы применяют TWR для бэнчмаркинга. Пенсионный фонд с портфелем в облигациях сравнивает TWR своего управляющего с индексом Bloomberg Barclays Aggregate. Отклонение более 0.5% годовых в любую сторону требует анализа причин. Систематическое отставание становится основанием для смены управляющего. Анализ эффективности инвестиционных решений клиента использует MWR. Инвестор, который переводил средства в акции технологических компаний в 2020-2021 годах и выводил их в 2022, получил MWR существенно ниже TWR портфеля. Разница показывает цену неудачного тайминга для входа. Консультанты используют этот анализ для демонстрации ценности дисциплинированного долгосрочного инвестирования. Семейные офисы (family offices) рассчитывают MWR для оценки совокупной доходности капитала семьи. Средства распределены между несколькими управляющими, недвижимостью и прямыми инвестициями. Пополнения и выводы происходят по мере появления возможностей и потребностей. MWR показывает фактический рост благосостояния с учетом всех решений о размещении капитала. Заключение TWR, MWR и MDR представляют разные подходы к измерению доходности портфеля в зависимости от движения денежных потоков. TWR изолирует качество управления активами от решений о пополнении и выводе средств, становясь стандартом для профессиональной оценки управляющих. MWR показывает реальную экономическую эффективность — фактическую доходность капитала инвестора с учетом тайминга его решений. MDR предлагает компромисс между точностью и практичностью для регулярной отчетности. Профессиональный подход требует применения разных метрик для разных целей. Оценка стратегии и сравнение с бэнчмарками — территория TWR. Анализ результатов конкретного инвестора и консультирование по улучшению решений о тайминге — ключевая сфера применения MWR. Выбор метрики определяет выводы: TWR для оценки управляющих; MWR для анализа личного результата управляющего; MDR для массовой отчетности. Доходность одного портфеля может различаться на 5-10 процентных пунктов в зависимости от метода расчета. Понимание различий между метриками превращает формальные цифры в инструмент принятия обоснованных инвестиционных решений. ### Алгоритмы сбора биржевых данных: практическое руководство Финансовые рынки генерируют колоссальные объемы данных: котировки тысяч активов, отчеты компаний, новостные потоки. Умение быстро и качественно собирать, обрабатывать и агрегировать эти данные - важное конкурентное преимущество. Профессиональный подход к сбору биржевых данных — это не просто загрузка котировок. Это комплексная система, включающая мониторинг источников, обработку аномалий, синхронизацию временных рядов из разных источников и построение отказоустойчивых пайплайнов. В этой статье мы рассмотрим архитектурные подходы и практические решения, которые помогают создавать надежные системы сбора данных для алгоритмической торговли. Виды биржевых данных и их источники Биржевые данные различаются не только по частоте обновления, но и по своей природе и применению в торговых стратегиях. Для базовых торговых операций обычно используют OHLCV — цену открытия, максимум, минимум, закрытие и объем торгов за выбранный период. Эти данные позволяют строить графики, рассчитывать индикаторы и формировать простые торговые сигналы. Для портфельного анализа нужны дополнительные показатели: дивиденды, которые учитываются при расчете итоговой доходности; корпоративные действия, влияющие на корректность исторических данных; фундаментальные метрики компаний — P/E, EPS, debt-to-equity и другие. Алгоритмическая HFT торговля требует более детальной информации. Здесь применяются тиковые данные, глубина стакана для оценки ликвидности и спреды bid-ask для расчета издержек. Построение моделей машинного обучения расширяет список необходимых данных. Помимо ценовых рядов используются альтернативные источники: тексты из социальных сетей и новостей для анализа настроений; расшифровка видео и документов; статистика поисковых запросов и т. д. Важны и макроэкономические индикаторы — процентные ставки, инфляция, уровень безработицы. Дополнительным слоем информации служат данные по опционам, которые отражают рыночные ожидания. Исторические данные формируют основу для бэктестинга стратегий, однако их сбор и обработка связаны с рядом особенностей. Бесплатные источники (например Yahoo Finance) предоставляют уже скорректированные (adjusted) ряды — цены учитывают сплиты и дивиденды. Такой формат удобен, однако качество информации не всегда идеально: возможны пропуски и ошибки, особенно для менее ликвидных инструментов. Платные провайдеры (такие как Refinitiv, Bloomberg или Quandl) предлагают как скорректированные (adjusted), так и исходные (unadjusted) данные. Они также фиксируют точные даты корпоративных событий, что крайне важно для корректного бэктестинга. Есть и еще один нюанс: корректировка цен производится ретроспективно. Каждый новый сплит или дивиденд изменяет весь исторический ряд, и результаты бэктеста, запущенного год назад, могут отличаться от результатов, полученных сегодня. Это создает сложности с воспроизводимостью исследований и требует аккуратного подхода к хранению и версии данных. Популярные API биржевых данных Для получения актуальных котировок в реальном времени чаще всего используют API - программный интерфейс, который обеспечивает доступ к биржевым данным напрямую с бирж или агрегаторов котировок. Через API информация поступает в приложения и скрипты, в которых уже реализовано логика их предобработки, что упрощает анализ рынка и сравнение разных источников данных. Данные могут поставляться в разных форматах — от привычных CSV, JSON, XML до более эффективных форматов для работы с большими объемами (HDF5, Parquet). В таблице ниже представлено сравнение API биржевых данных: Рис. 1: Таблица сравнения источников биржевых данных Еще источники торговых данных подразделяют на несколько категорий: Прямые биржевые фиды (NYSE, NASDAQ, CME) ориентированы на профессиональных участников рынка. Их главное преимущество — качество котировок, глубина детализации и минимальные задержки. Минус в том, что доступ к ним стоит дорого и часто требует специализированного оборудования; Брокерские API (Interactive Brokers, TD Ameritrade, Alpaca) ориентированы на широкую аудиторию — от институциональных до розничных трейдеров. Они отличаются умеренными ограничениями по запросам и часто предоставляются бесплатно или за символическую плату. Основной недостаток — задержка котировок: от 500 миллисекунд до нескольких секунд; Агрегаторы данных (Alpha Vantage, Polygon.io, IEX Cloud) объединяют котировки и другую информацию из бирж, брокеров и открытых источников, предоставляя ее через единый удобный API. Такой подход упрощает интеграцию и экономит время разработчиков, однако качество данных и скорость обновления здесь как правило хуже, чем в первых двух вариантах; Новостные агрегаторы (Bloomberg Terminal, Reuters, Benzinga) предоставляют потоки структурированных данных о событиях, способных повлиять на рынки, включая корпоративные анонсы, макроэкономические отчеты и политические новости. Для трейдеров и разработчиков такие агрегаторы - ценный источник информации, который можно использовать для построения торговых стратегий, аналитических инструментов и моделей прогнозирования. Уровни архитектуры системы сбора данных Розничные трейдеры и инвесторы обычно работают с готовыми агрегированными данными через брокерские API, что позволяет быстро получать котировки и строить простые стратегии. Институциональные игроки предпочитают сырые данные, чтобы самостоятельно определять правила обработки и строить масштабируемые высокоточные системы. Именно эти требования формируют основу профессиональной системы сбора данных, которая строится как многоуровневая архитектура: Нижний уровень включает коннекторы к разным источникам — REST API, WebSocket и FIX-протоколы для институциональных фидов, которые приводят данные к единому внутреннему формату; Средний уровень отвечает за проверку и нормализацию: контроль временных меток, детекцию пропусков и аномалий, заполнение пропусков и корректировку на корпоративные действия, такие как сплиты, дивиденды и слияния; Верхний уровень управляет хранением и индексацией, обеспечивая быстрый доступ к историческим рядам и эффективную работу аналитических и торговых систем. Такая многоуровневая архитектура обеспечивает стабильность, масштабируемость и согласованность данных, позволяя создавать надежные системы для анализа и торговли. Комбинация нескольких источников данных Профессионалы рынка редко полагаются на один источник биржевых данных. Котировки могут поступать с задержками, пропусками, сильно отличаться от других источников, особенно для слаболиквидных инструментов. Комбинирование нескольких источников позволяет сравнивать данные, выявлять аномалии и строить более надежные торговые системы: если один источник недоступен или предоставляет некорректные значения, информация поступает из другого. Стратегия мультиисточников обычно строится вокруг primary source для основного потока данных и нескольких fallback источников для верификации и заполнения пропусков. Сравнение котировок между источниками не только повышает надежность системы, но и может использоваться для поиска возможностей арбитража. import yfinance as yf import pandas as pd import numpy as np from datetime import datetime, timedelta import time class MultiSourceDataCollector: def __init__(self, primary_delay=1.0, fallback_delay=2.0): self.primary_delay = primary_delay self.fallback_delay = fallback_delay self.quality_scores = {} def fetch_with_retry(self, ticker, start_date, end_date, max_retries=3): """Получение данных с повторными попытками и обработкой ошибок""" for attempt in range(max_retries): try: time.sleep(self.primary_delay) data = yf.download(ticker, start=start_date, end=end_date, progress=False, auto_adjust=False) # Проверка на Multiindex if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) if not data.empty: return data, 'yfinance' except Exception as e: if attempt == max_retries - 1: return pd.DataFrame(), None time.sleep(self.fallback_delay * (attempt + 1)) return pd.DataFrame(), None def validate_data_quality(self, data, ticker): """Оценка качества полученных данных""" if data.empty: return 0.0 quality_score = 100.0 # Проверка на пропуски missing_ratio = data['Close'].isna().sum() / len(data) quality_score -= missing_ratio * 30 # Проверка на нулевые объемы zero_volume_ratio = (data['Volume'] == 0).sum() / len(data) quality_score -= zero_volume_ratio * 20 # Проверка на выбросы через IQR q1 = data['Close'].quantile(0.25) q3 = data['Close'].quantile(0.75) iqr = q3 - q1 outliers = ((data['Close'] < q1 - 3*iqr) | (data['Close'] > q3 + 3*iqr)).sum() outlier_ratio = outliers / len(data) quality_score -= outlier_ratio * 25 # Проверка монотонности временных меток if not data.index.is_monotonic_increasing: quality_score -= 15 return max(0.0, quality_score) def collect_and_merge(self, tickers, start_date, end_date): """Сбор данных по множеству тикеров с оценкой качества""" results = {} for ticker in tickers: data, source = self.fetch_with_retry(ticker, start_date, end_date) if not data.empty: quality = self.validate_data_quality(data, ticker) self.quality_scores[ticker] = { 'score': quality, 'source': source, 'rows': len(data), 'missing': data['Close'].isna().sum() } results[ticker] = data else: self.quality_scores[ticker] = { 'score': 0.0, 'source': None, 'rows': 0, 'missing': 0 } return results # Пример использования collector = MultiSourceDataCollector(primary_delay=1.2, fallback_delay=2.5) tickers = ['BABA', 'TSM', 'SHOP', 'HD'] end_date = datetime.now() start_date = end_date - timedelta(days=365) data_collection = collector.collect_and_merge( tickers, start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d') ) # Анализ качества данных quality_df = pd.DataFrame(collector.quality_scores).T print("\nОценка качества данных по тикерам:") print(quality_df.sort_values('score', ascending=False)) Оценка качества данных по тикерам: score source rows missing BABA 100.0 yfinance 250 0 TSM 100.0 yfinance 250 0 SHOP 100.0 yfinance 250 0 HD 100.0 yfinance 250 0 Этот скрипт реализует класс для сбора исторических котировок по множеству тикеров с использованием нескольких источников и встроенной оценки качества данных. Он получает данные с основного источника с повторными попытками при ошибках, проверяет их на пропуски, нулевые объемы, выбросы и корректность временных меток, присваивает каждой серии оценку качества и сохраняет сведения о источнике, числе строк и пропущенных значениях. В результате формируется объединенный словарь с данными по всем тикерам и таблица с оценкой качества, что позволяет оперативно выявлять проблемные инструменты и использовать надежные данные для анализа и работы торговых стратегий. Rate Limiting и управление API-квотами Большинство бесплатных и даже многие платные API имеют жесткие ограничения на количество запросов за определенный промежуток времени. Например: Alpha Vantage — 5 запросов в минуту и 500 в день; Polygon.io — 5 запросов в минуту на базовом тарифе; IEX Cloud — 50,000 запросов в месяц. Превышение лимитов приводит к HTTP 429 ошибкам, временной блокировке IP или даже полному отключению доступа. Поэтому эффективное управление квотами становится необходимым навыком при построении систем сбора данных для портфелей из сотен инструментов. Наивный подход — просто добавлять паузу sleep между запросами. Он работает, но крайне неэффективен. Например, при квоте 5 запросов в минуту и 500 тикеров для обновления ожидание 12 секунд между запросами растянет процесс почти на два часа. Профессиональные системы используют алгоритмы token bucket или leaky bucket. Они оптимально используют доступную пропускную способность, учитывают burst capacity API и автоматически адаптируются к изменениям в лимитах. import time import threading from collections import deque from datetime import datetime, timedelta import requests from typing import Dict, Optional, Callable import logging class RateLimiter: def __init__(self, calls_per_second=5, burst_size=10): self.calls_per_second = calls_per_second self.burst_size = burst_size self.tokens = burst_size self.last_update = time.time() self.lock = threading.Lock() def acquire(self, tokens=1): """Алгоритм Token bucket для ограничения скорости""" with self.lock: now = time.time() elapsed = now - self.last_update # Добавляем токены на основе прошедшего времени self.tokens = min( self.burst_size, self.tokens + elapsed * self.calls_per_second ) self.last_update = now if self.tokens >= tokens: self.tokens -= tokens return 0 # Нет задержки else: # Рассчитываем необходимое время ожидания wait_time = (tokens - self.tokens) / self.calls_per_second return wait_time class AdaptiveAPIClient: def __init__(self, api_key, base_url, calls_per_minute=5): self.api_key = api_key self.base_url = base_url self.rate_limiter = RateLimiter( calls_per_second=calls_per_minute/60, burst_size=calls_per_minute ) # Статистика для адаптации self.request_times = deque(maxlen=100) self.error_count = 0 self.success_count = 0 self.last_429_time = None # Адаптивные параметры self.backoff_multiplier = 1.0 self.max_backoff = 5.0 logging.basicConfig(level=logging.INFO) self.logger = logging.getLogger(__name__) def _adaptive_wait(self): """Адаптивное ожидание на основе истории запросов""" base_wait = self.rate_limiter.acquire() # Увеличиваем задержку, если недавно получали 429 if self.last_429_time: time_since_429 = time.time() - self.last_429_time if time_since_429 < 300: # 5 минут base_wait *= self.backoff_multiplier if base_wait > 0: time.sleep(base_wait) def _handle_rate_limit_error(self, response): """Обработка 429 ошибки с экспоненциальным backoff""" self.last_429_time = time.time() self.backoff_multiplier = min( self.backoff_multiplier * 2, self.max_backoff ) # Проверяем Retry-After header retry_after = response.headers.get('Retry-After') if retry_after: try: wait_time = int(retry_after) self.logger.warning( f"Rate limit hit. Waiting {wait_time}s as per Retry-After header" ) time.sleep(wait_time) return except ValueError: pass # Если нет Retry-After, используем экспоненциальный backoff wait_time = 60 * self.backoff_multiplier self.logger.warning( f"Rate limit hit. Exponential backoff: waiting {wait_time:.1f}s" ) time.sleep(wait_time) def request_with_retry(self, endpoint, params=None, max_retries=3): """Запрос с автоматическими повторами и адаптацией""" params = params or {} params['apikey'] = self.api_key for attempt in range(max_retries): try: self._adaptive_wait() start_time = time.time() response = requests.get( f"{self.base_url}/{endpoint}", params=params, timeout=30 ) request_time = time.time() - start_time self.request_times.append(request_time) if response.status_code == 200: self.success_count += 1 # Постепенно уменьшаем backoff при успешных запросах self.backoff_multiplier = max( 1.0, self.backoff_multiplier * 0.9 ) return response.json() elif response.status_code == 429: self.error_count += 1 self._handle_rate_limit_error(response) if attempt < max_retries - 1: continue else: raise Exception("Max retries exceeded on rate limit") elif response.status_code >= 500: # Серверные ошибки - повторяем с задержкой wait_time = (attempt + 1) ** 2 self.logger.warning( f"Server error {response.status_code}. " f"Retry {attempt+1}/{max_retries} after {wait_time}s" ) time.sleep(wait_time) continue else: self.logger.error( f"Request failed with status {response.status_code}: " f"{response.text}" ) return None except requests.exceptions.Timeout: self.logger.warning(f"Request timeout. Attempt {attempt+1}/{max_retries}") time.sleep((attempt + 1) * 2) continue except requests.exceptions.RequestException as e: self.logger.error(f"Request exception: {e}") if attempt < max_retries - 1: time.sleep((attempt + 1) * 2) continue return None return None def get_statistics(self): """Статистика использования API""" if not self.request_times: return { 'total_requests': 0, 'successful': 0, 'errors': 0, 'success_rate': 0.0, 'avg_response_time': 0.0, 'current_backoff': self.backoff_multiplier } avg_time = sum(self.request_times) / len(self.request_times) total_requests = self.success_count + self.error_count success_rate = (self.success_count / total_requests * 100) if total_requests > 0 else 0 return { 'total_requests': total_requests, 'successful': self.success_count, 'errors': self.error_count, 'success_rate': success_rate, 'avg_response_time': avg_time, 'current_backoff': self.backoff_multiplier } class BatchDataCollector: def __init__(self, api_client: AdaptiveAPIClient, batch_size=5): self.api_client = api_client self.batch_size = batch_size self.logger = logging.getLogger(__name__) def collect_multiple_tickers(self, tickers, data_function: Callable): """Сбор данных по множеству тикеров с батчингом""" results = {} failed = [] total_batches = (len(tickers) + self.batch_size - 1) // self.batch_size for i in range(0, len(tickers), self.batch_size): batch = tickers[i:i + self.batch_size] batch_num = i // self.batch_size + 1 self.logger.info( f"Processing batch {batch_num}/{total_batches}: {batch}" ) for ticker in batch: try: data = data_function(ticker) if data is not None: results[ticker] = data else: failed.append(ticker) except Exception as e: self.logger.error(f"Error processing {ticker}: {e}") failed.append(ticker) # Статистика после каждого батча stats = self.api_client.get_statistics() self.logger.info( f"Batch {batch_num} complete. " f"Success rate: {stats['success_rate']:.1f}%, " f"Avg response: {stats['avg_response_time']:.2f}s" ) # Повторная попытка для failed тикеров if failed: self.logger.info(f"Retrying {len(failed)} failed tickers") time.sleep(30) # Дополнительная пауза перед retry for ticker in failed[:]: try: data = data_function(ticker) if data is not None: results[ticker] = data failed.remove(ticker) except Exception as e: self.logger.error(f"Retry failed for {ticker}: {e}") return results, failed # Пример использования def demo_rate_limiting(): API_KEY = "**************" client = AdaptiveAPIClient( api_key=API_KEY, base_url="https://www.alphavantage.co/query", calls_per_minute=5 # У бесплатного тарифа лимит 5 запросов/мин ) collector = BatchDataCollector(client, batch_size=3) # Список тикеров tickers = ["BABA", "TSM", "SHOP", "SQ", "AMD", "MU", "INTC", "QCOM"] def fetch_ticker_data(ticker): params = { "function": "TIME_SERIES_DAILY_ADJUSTED", "symbol": ticker, "outputsize": "compact" } data = client.request_with_retry("", params=params) # Проверяем, что ответ корректный if not data: return None if "Note" in data: print(f"⚠️ API limit notice for {ticker}: {data['Note']}") return None if "Error Message" in data: print(f"❌ Error for {ticker}: {data['Error Message']}") return None if "Time Series (Daily)" in data: latest_date, latest_values = list(data["Time Series (Daily)"].items())[0] return { "ticker": ticker, "date": latest_date, "close": float(latest_values["4. close"]), "adjusted_close": float(latest_values["5. adjusted close"]), "volume": int(latest_values["6. volume"]) } return None print("Начинаем сбор данных с API Alpha Vantage...") start_time = time.time() results, failed = collector.collect_multiple_tickers( tickers, fetch_ticker_data ) elapsed = time.time() - start_time print(f"\nСбор завершен за {elapsed:.1f} секунд") print(f"Успешно: {len(results)}, Ошибки: {len(failed)}") # Вывод результатов for ticker, info in results.items(): print(f"{ticker}: {info}") stats = client.get_statistics() print("\nФинальная статистика:") for key, value in stats.items(): if isinstance(value, float): print(f"{key}: {value:.2f}") else: print(f"{key}: {value}") demo_rate_limiting() Начинаем сбор данных с API Alpha Vantage... Сбор завершен за 84.0 секунд Успешно: 8, Ошибки: 0 Финальная статистика: total_requests: 16 successful: 16 errors: 0 success_rate: 100.00 avg_response_time: 0.71 current_backoff: 1.00 Этот скрипт реализует адаптивного API-клиента для массового сбора данных по множеству тикеров с учетом ограничений API (rate limits). Он использует алгоритм Token Bucket для контроля скорости запросов и автоматически регулирует паузы между вызовами в зависимости от текущей нагрузки и истории успешных и неудачных запросов. При получении ошибок или ответов 429 (Rate Limit) применяется экспоненциальный backoff, что позволяет минимизировать вероятность блокировок и пропусков данных. На основе этого клиента реализован класс BatchDataCollector, который собирает данные партиями (batch), повторно обрабатывает тикеры, для которых сбор котировок не удался, и ведет статистику успешности и времени отклика. Такой подход обеспечивает надежный сбор данных даже при строгих ограничениях API и позволяет эффективно масштабировать процесс для сотен тикеров. Ключевые возможности скрипта: Управление скоростью запросов с учетом лимитов (Token Bucket); Адаптивное ожидание при ошибках и превышении лимитов (exponential backoff); Массовый сбор данных по списку тикеров с батчингом; Повторные попытки для неудачных запросах; Сбор и хранение статистики по успешным и неудачным запросам, времени отклика и текущему backoff. Оптимизация сбора данных и кеширование При работе с биржевыми данными важны не только скорость и качество, но и эффективность получения и хранения информации. Регулярная перезагрузка всех данных для портфеля из сотен инструментов приводит к огромной нагрузке на API, тратит время и ресурсы. Поэтому профессиональные системы сбора данных используют инкрементальные обновления — подход, при котором загружаются только новые данные, появившиеся с момента последнего успешного обновления. Инкрементальные обновления сокращают объем передаваемых данных и ускоряют обработку. Вместо загрузки всей истории котировок каждый день система получает только новые бары или записи. Преимущества подхода: Минимизация объема передаваемых данных (например, вместо 5 лет истории загружается один день новых баров); Хранение метаданных для каждого тикера: timestamp последней записи, количество загруженных строк, checksum для проверки целостности; Возможность безопасного отката при сбоях через транзакции в БД или atomic rename для файлового хранилища; Эффективность для высокочастотных альтернативных данных (новостные фиды, соцсети, экономические индикаторы). Также очень часто данные кэшируются. Кеширование улучшает производительность и надежность системы, создавая промежуточный слой между торговой системой и внешними API. Рекомендуемая многоуровневая стратегия: In-memory кеш (Python dict, pandas DataFrame) — быстрый доступ к данным текущей торговой сессии; Дисковый кеш (SQLite для метаданных, HDF5/Parquet для временных рядов) — данные последних дней или недель; Полный исторический архив в сжатом хранилище — редко используемые данные. Каждый уровень кеша требует своей стратегии обновления и инвалидации. Важно определить, когда данные считаются устаревшими и должны быть перезагружены, чтобы обеспечить баланс между свежестью информации и нагрузкой на API. Политика инвалидации может быть: Time-based - дневные бары до конца торгового дня; Event-based - при корпоративных действиях; Hybrid - гибридная система. При отсутствии данных в кэше система должна автоматически загрузить данные из API, сохранить в кеше и вернуть их без задержек. Продвинутые техники кеширования включают: Prefetching — предзагрузка данных на основе паттернов использования; Compression-aware caching — хранение данных в сжатом виде, декомпрессия только нужных диапазонов; Cache warming — загрузка часто используемых данных до начала торговли; Распределенный кеш (Redis, Memcached) — совместное использование данных несколькими инстансами системы; Мониторинг cache hit rate — показатель эффективности кеширования (>90% — отлично, <70% — требует оптимизации). Такой подход обеспечивает быструю, надежную и масштабируемую систему сбора и обработки биржевых данных, минимизируя задержки и нагрузку на API. Обработка временных меток и часовых поясов Корректная работа с временем — одна из наиболее недооцененных сложностей в сборе биржевых данных. Между тем, биржи работают в своих локальных часовых поясах: NYSE и NASDAQ в EST/EDT, LSE в GMT/BST, Tokyo Stock Exchange в JST. Переход на летнее время происходит в разные даты в США, Европе и других регионах. Добавьте сюда внебиржевую торговлю, премаркет и постмаркет сессии — и получите головоломку, которая регулярно приводит к ошибкам даже у опытных разработчиков. Золотое правило работы со временем в количественных стратегиях — всегда хранить данные в UTC. Это устраняет проблемы с переходами на летнее время и делает данные из разных источников сопоставимыми. Локальное время биржи используется только при отображении данных пользователю или при интеграции с торговыми системами, требующими специфического формата временных меток. import pandas as pd import numpy as np from datetime import datetime import pytz from zoneinfo import ZoneInfo class TimestampNormalizer: def __init__(self): self.exchange_timezones = { 'NYSE': 'America/New_York', 'NASDAQ': 'America/New_York', 'LSE': 'Europe/London', 'TSE': 'Asia/Tokyo', 'HKEX': 'Asia/Hong_Kong', 'SSE': 'Asia/Shanghai' } self.trading_hours = { 'NYSE': {'open': '09:30', 'close': '16:00'}, 'NASDAQ': {'open': '09:30', 'close': '16:00'}, 'LSE': {'open': '08:00', 'close': '16:30'}, 'TSE': {'open': '09:00', 'close': '15:00'} } def normalize_to_utc(self, data, exchange='NYSE'): """Конвертация временных меток в UTC""" if not isinstance(data.index, pd.DatetimeIndex): data.index = pd.to_datetime(data.index) tz = self.exchange_timezones.get(exchange, 'America/New_York') if data.index.tz is not None: data.index = data.index.tz_convert('UTC') else: data.index = data.index.tz_localize( tz, ambiguous='infer', nonexistent='shift_forward' ) data.index = data.index.tz_convert('UTC') return data def align_trading_days(self, data_dict, method='intersection', agg='first'): """ Выравнивание по торговым дням (UTC). agg: 'first', 'last', 'mean' — как агрегировать внутри дня. """ if not data_dict: return {} # Переводим все ряды в UTC normalized = {} for key, df in data_dict.items(): if not df.empty: normalized[key] = self.normalize_to_utc(df.copy(), exchange=key) if not normalized: return {} # Приводим к дневным данным if agg == 'first': daily = {key: df.groupby(df.index.normalize()).first() for key, df in normalized.items()} elif agg == 'last': daily = {key: df.groupby(df.index.normalize()).last() for key, df in normalized.items()} elif agg == 'mean': daily = {key: df.groupby(df.index.normalize()).mean() for key, df in normalized.items()} else: raise ValueError("agg должен быть 'first', 'last' или 'mean'") # Совмещение дат if method == 'intersection': common_days = daily[list(daily.keys())[0]].index for df in list(daily.values())[1:]: common_days = common_days.intersection(df.index) aligned = {key: df.loc[common_days] for key, df in daily.items()} elif method == 'union': all_days = pd.DatetimeIndex([]) for df in daily.values(): all_days = all_days.union(df.index) aligned = {key: df.reindex(all_days) for key, df in daily.items()} return aligned # ДЕМО normalizer = TimestampNormalizer() # Создаем тестовые данные (NYSE и LSE) dates_ny = pd.date_range('2025-01-01', periods=100, freq='D', tz='America/New_York') dates_london = pd.date_range('2025-01-01', periods=100, freq='D', tz='Europe/London') test_data_ny = pd.DataFrame({ 'Close': np.random.randn(100).cumsum() + 100, 'Volume': np.random.randint(1_000_000, 10_000_000, 100) }, index=dates_ny) test_data_london = pd.DataFrame({ 'Close': np.random.randn(100).cumsum() + 150, 'Volume': np.random.randint(500_000, 5_000_000, 100) }, index=dates_london) # Приводим в UTC normalized_ny = normalizer.normalize_to_utc(test_data_ny.copy(), 'NYSE') normalized_london = normalizer.normalize_to_utc(test_data_london.copy(), 'LSE') print("Примеры временных меток после нормализации:") print(f"NYSE (UTC): {normalized_ny.index[:3]}") print(f"LSE (UTC): {normalized_london.index[:3]}") # Выравниваем по общим торговым дням aligned_data = normalizer.align_trading_days({ 'NYSE': test_data_ny, 'LSE': test_data_london }, method='intersection', agg='mean') # берем средние цены за день print("\nСовпадающие торговые дни:") print(aligned_data['NYSE'].index[:5]) print(f"Количество совпадающих дней: {len(aligned_data['NYSE'])}") Примеры временных меток после нормализации: NYSE (UTC): DatetimeIndex(['2025-01-01 05:00:00+00:00', '2025-01-02 05:00:00+00:00', '2025-01-03 05:00:00+00:00'], dtype='datetime64[ns, UTC]', freq='D') LSE (UTC): DatetimeIndex(['2025-01-01 00:00:00+00:00', '2025-01-02 00:00:00+00:00', '2025-01-03 00:00:00+00:00'], dtype='datetime64[ns, UTC]', freq='D') Совпадающие торговые дни: DatetimeIndex(['2025-01-01 00:00:00+00:00', '2025-01-02 00:00:00+00:00', '2025-01-03 00:00:00+00:00', '2025-01-04 00:00:00+00:00', '2025-01-05 00:00:00+00:00'], dtype='datetime64[ns, UTC]', freq='D') Количество совпадающих дней: 99 Этот код демонстрирует пример обработки биржевых данных, полученных из разных часовых поясов. Его цель — привести все временные метки к единому стандарту (UTC) и синхронизировать торговые дни, чтобы данные с разных рынков можно было сравнивать и анализировать одновременно. Основные компоненты кода: Класс TimestampNormalizer — хранит информацию о часовых поясах бирж и часах торговых сессий. Упрощает обработку нескольких источников котировок и делает их пригодными для автоматизированной торговли и анализа рыночных возможностей; Метод normalize_to_utc (data, exchange) — конвертирует временные метки в UTC, корректирует «наивные» индексы и обрабатывает двусмысленные или несуществующие timestamps; Метод align_trading_days (data_dict, method='intersection', agg='first') — агрегирует данные по дням (first, last или mean) и синхронизирует даты между биржами с помощью пересечения или объединения торговых дней. В результате работы кода все временные метки нормализуются в UTC, что позволяет корректно сравнивать котировки с разных бирж. Данные выравниваются по общим торговым дням, и можно анализировать их вместе, находить расхождения или строить торговые стратегии с учетом нескольких рынков. Полученные датафреймы полностью готовы к дальнейшему анализу и использованию в автоматизированных системах. Корректировка на корпоративные действия Корпоративные действия, такие как сплиты, обратные сплиты, дивиденды и спин-оффы, создают искусственные разрывы в ценовых рядах, которые не отражают реального движения рынка. Если стратегия не учитывает, например, сплит 1:10, она будет учитывать падение цены там, где его фактически не было. Провайдеры данных предлагают как скорректированные (adjusted), так и некорректированные (unadjusted) цены акций, однако методы корректировки могут различаться, что иногда приводит к расхождениям между источниками. Существует два основных подхода к корректировке: Backward adjustment - исторические цены корректируются относительно текущей; Forward adjustment - текущие цены корректируются относительно исторической базы. Backward adjustment используется чаще, поскольку сохраняет актуальность текущих цен, однако у него есть существеный недостаток — исторические цены меняются при каждом новом корпоративном действии, что усложняет воспроизводимость бэктестов. import pandas as pd import numpy as np from datetime import datetime, timedelta class CorporateActionsAdjuster: def __init__(self): self.action_history = {} def register_split(self, ticker, date, ratio): """Регистрация сплита акций""" if ticker not in self.action_history: self.action_history[ticker] = [] self.action_history[ticker].append({ 'type': 'split', 'date': pd.to_datetime(date), 'ratio': ratio # Например, 2.0 для сплита 2:1 }) def register_dividend(self, ticker, ex_date, amount): """Регистрация дивиденда""" if ticker not in self.action_history: self.action_history[ticker] = [] self.action_history[ticker].append({ 'type': 'dividend', 'date': pd.to_datetime(ex_date), 'amount': amount }) def get_nearest_trading_date(self, data, date): """Найти ближайший торговый день >= указанной даты""" if date in data.index: return date future_dates = data.index[data.index >= date] if len(future_dates) > 0: return future_dates[0] return None def backward_adjust_prices(self, data, ticker): """Обратная корректировка цен на корпоративные действия""" if ticker not in self.action_history: return data.copy() adjusted = data.copy() actions = sorted(self.action_history[ticker], key=lambda x: x['date'], reverse=True) for action in actions: action_date = self.get_nearest_trading_date(adjusted, action['date']) if action_date is None: continue # дата вне диапазона данных mask = adjusted.index < action_date if action['type'] == 'split': split_ratio = action['ratio'] adjusted.loc[mask, ['Open','High','Low','Close']] /= split_ratio adjusted.loc[mask, 'Volume'] *= split_ratio elif action['type'] == 'dividend': dividend_amount = action['amount'] close_price = adjusted.loc[action_date, 'Close'] adjustment_factor = 1 - (dividend_amount / close_price) adjusted.loc[mask, ['Open','High','Low','Close']] *= adjustment_factor return adjusted def calculate_total_return(self, data, ticker, initial_shares=100): """Расчет total return с учетом реинвестирования дивидендов""" if ticker not in self.action_history: price_return = ((data['Close'].iloc[-1] / data['Close'].iloc[0]) - 1) * 100 return price_return, price_return shares = initial_shares cash = 0.0 actions = sorted(self.action_history[ticker], key=lambda x: x['date']) for action in actions: action_date = self.get_nearest_trading_date(data, action['date']) if action_date is None: continue if action['type'] == 'split': shares *= action['ratio'] elif action['type'] == 'dividend': dividend_payment = shares * action['amount'] # Реинвестирование дивидендов price_at_ex_date = data.loc[action_date, 'Close'] additional_shares = dividend_payment / price_at_ex_date shares += additional_shares initial_value = initial_shares * data['Close'].iloc[0] final_value = shares * data['Close'].iloc[-1] + cash total_return = ((final_value / initial_value) - 1) * 100 price_return = ((data['Close'].iloc[-1] / data['Close'].iloc[0]) - 1) * 100 return total_return, price_return def detect_potential_splits(self, data, threshold=0.4): """Автоматическая детекция потенциальных сплитов""" returns = data['Close'].pct_change() volume_changes = data['Volume'].pct_change() # Ищем дни с большим падением цены и ростом объема potential_splits = returns[(returns < -threshold) & (volume_changes > 0.5)] detected = [] for date, ret in potential_splits.items(): if date not in data.index: continue if len(data.loc[:date]) < 2: continue price_before = data.loc[:date, 'Close'].iloc[-2] price_after = data.loc[date, 'Close'] # Проверяем распространенные коэффициенты сплита common_ratios = [2.0, 3.0, 4.0, 5.0, 10.0, 0.5, 0.333, 0.25, 0.2] for ratio in common_ratios: if abs(price_after / price_before - 1/ratio) < 0.05: detected.append({ 'date': date, 'suspected_ratio': ratio, 'price_change': ret, 'volume_change': volume_changes[date] }) break return pd.DataFrame(detected) # ДЕМО adjuster = CorporateActionsAdjuster() # Создаем синтетические данные synthetic_data = pd.DataFrame({ 'Open': 100 + np.random.randn(len(dates)).cumsum() * 0.2, 'High': 102 + np.random.randn(len(dates)).cumsum() * 0.2, 'Low': 98 + np.random.randn(len(dates)).cumsum() * 0.2, 'Close': 100 + np.random.randn(len(dates)).cumsum() * 0.2, 'Volume': np.random.randint(1_000_000, 5_000_000, len(dates)) }, index=dates) # Регистрируем корпоративные действия adjuster.register_split('TEST', '2024-06-01', 2.0) adjuster.register_dividend('TEST', '2024-09-15', 0.1) # Дивиденд (воскресенье → перенесётся на 2024-09-16) # Применяем корректировку adjusted_data = adjuster.backward_adjust_prices(synthetic_data, 'TEST') # Сравнение до и после comparison = pd.DataFrame({ 'Original_Close': synthetic_data['Close'], 'Adjusted_Close': adjusted_data['Close'], 'Difference_%': ((adjusted_data['Close'] - synthetic_data['Close']) / synthetic_data['Close'] * 100) }) print("Влияние корпоративных действий на исторические цены:") print(comparison.iloc[[100, 150, 200, 250]].round(2)) # Расчет total return total_ret, price_ret = adjuster.calculate_total_return(synthetic_data, 'TEST') print(f"\nTotal Return (с реинвестированием): {total_ret:.2f}%") print(f"Price Return (без дивидендов): {price_ret:.2f}%") print(f"Дивидендная составляющая: {total_ret - price_ret:.2f}%") Влияние корпоративных действий на исторические цены: Original_Close Adjusted_Close Difference_% 2024-05-20 100.43 50.16 -50.05 2024-07-29 101.44 101.34 -0.10 2024-10-07 100.66 100.66 0.00 2024-12-16 98.65 98.65 0.00 Total Return (с реинвестированием): 98.61% Price Return (без дивидендов): -0.79% Дивидендная составляющая: 99.41% Таблица демонстрирует эффект backward adjustment на исторические цены после применения сплита 2:1 (июнь 2024) и дивиденда $1.50 (сентябрь 2024). Разница в процентах показывает, что цены до даты сплита были скорректированы на коэффициент 0.5 (пример: 20 мая 2024 года — падение Adjusted Close в два раза), а после ex-dividend date изменений практически не наблюдается, так как дивиденд был относительно небольшим по сравнению с ценой акции. Сравнение доходностей показывает интересный эффект: Price Return оказался отрицательным (-0.79%), то есть без учета дивидендов акции за год слегка снизились. Однако с учетом реинвестирования дивидендов Total Return составил +98.61%, а дивидендная составляющая внесла +99.41% совокупной доходности. Это хорошая иллюстрация того, как дивиденды могут полностью изменить картину инвестиционного результата. Механизм backward adjustment работает в обратном хронологическом порядке, начиная с самых недавних корпоративных действий: Для сплита корректируются цены и объемы торгов (цены делятся на коэффициент сплита, а объемы умножаются), чтобы сохранить сопоставимость исторических данных. Для дивидендов используется adjustment factor вида 1 – (dividend / price), что моделирует теоретическое снижение цены на размер выплаты. Такой подход обеспечивает корректный расчет доходности и позволяет видеть реальную роль корпоративных действий в формировании итоговой инвестиционной прибыли. Синхронизация данных разной частоты Профессиональные торговые стратегии часто комбинируют данные различной частоты: минутные бары для entry/exit сигналов, дневные данные для расчета долгосрочных трендов, фундаментальные показатели с квартальной частотой. Наивное объединение таких данных создает look-ahead bias — использование информации, которая не была доступна в момент принятия торгового решения. Корректная синхронизация биржевых данных разной частоты требует понимания момента публикации каждого типа данных и правильного выравнивания временных рядов. Главный принцип — корректность данных по времени (point-in-time correctness): стратегия в любой момент должна использовать только ту информацию, которая реально была бы доступна трейдеру: Для фундаментальных показателей это означает учитывать задержку публикации — квартальные отчеты становятся доступными только через 45–90 дней после окончания квартала; Для корпоративных действий важна дата ex-date, а не дата выплаты дивидендов; Для новостей учитывается точное время публикации, включая часовой пояс, чтобы данные не «подсказывали» будущее. import pandas as pd import numpy as np from datetime import datetime, timedelta pd.set_option('display.expand_frame_repr', False) import warnings warnings.filterwarnings('ignore') class DataSynchronizer: def __init__(self): self.sync_methods = { 'forward_fill': self._forward_fill_sync, 'nearest': self._nearest_sync, 'interpolate': self._interpolate_sync } def _forward_fill_sync(self, high_freq_index, low_freq_data): """Forward fill - использует последнее доступное значение""" # Reindex с forward fill synced = low_freq_data.reindex(high_freq_index, method='ffill') return synced def _nearest_sync(self, high_freq_index, low_freq_data): """Nearest - использует ближайшее по времени значение""" synced = low_freq_data.reindex(high_freq_index, method='nearest') return synced def _interpolate_sync(self, high_freq_index, low_freq_data): """Интерполяция для числовых данных""" # Объединяем индексы combined_index = high_freq_index.union(low_freq_data.index) combined_index = combined_index.sort_values() # Reindex и интерполяция reindexed = low_freq_data.reindex(combined_index) interpolated = reindexed.interpolate(method='time') # Возвращаем только высокочастотные точки synced = interpolated.reindex(high_freq_index) return synced def synchronize_multiple_frequencies(self, data_dict, target_freq='1D', method='forward_fill'): """ Синхронизация данных разных частот к целевой частоте data_dict: словарь {name: DataFrame} с данными разных частот target_freq: целевая частота ('1D', '1H', '5min' и т.д.) method: метод синхронизации """ if not data_dict: return pd.DataFrame() # Определяем общий временной диапазон min_date = min(df.index.min() for df in data_dict.values()) max_date = max(df.index.max() for df in data_dict.values()) # Создаем целевой индекс target_index = pd.date_range(start=min_date, end=max_date, freq=target_freq) # Синхронизируем каждый датафрейм synced_data = {} sync_func = self.sync_methods.get(method, self._forward_fill_sync) for name, df in data_dict.items(): if isinstance(df, pd.Series): synced_data[name] = sync_func(target_index, df) elif isinstance(df, pd.DataFrame): synced_df = pd.DataFrame(index=target_index) for col in df.columns: synced_df[f"{name}_{col}"] = sync_func(target_index, df[col]) synced_data[name] = synced_df # Объединяем все в один DataFrame result = pd.DataFrame(index=target_index) for name, data in synced_data.items(): if isinstance(data, pd.Series): result[name] = data elif isinstance(data, pd.DataFrame): result = pd.concat([result, data], axis=1) return result def apply_reporting_lag(self, fundamental_data, lag_days=45): """ Применение reporting lag к фундаментальным данным Симулирует реальную задержку в доступности финансовых отчетов """ lagged = fundamental_data.copy() lagged.index = lagged.index + pd.Timedelta(days=lag_days) return lagged def create_point_in_time_features(self, price_data, fundamental_data, technical_indicators, reporting_lag=60): """ Создание point-in-time корректного набора признаков """ # Применяем reporting lag к фундаментальным данным lagged_fundamental = self.apply_reporting_lag(fundamental_data, reporting_lag) # Синхронизируем все к дневной частоте с forward fill all_data = { 'price': price_data, 'fundamental': lagged_fundamental, 'technical': technical_indicators } synced = self.synchronize_multiple_frequencies( all_data, target_freq='1D', method='forward_fill' ) return synced class CrossAssetSynchronizer: """Синхронизация данных между различными активами и рынками""" def __init__(self): self.market_hours = { 'US': {'open': '09:30', 'close': '16:00', 'tz': 'America/New_York'}, 'EU': {'open': '08:00', 'close': '16:30', 'tz': 'Europe/London'}, 'ASIA': {'open': '09:00', 'close': '15:00', 'tz': 'Asia/Tokyo'} } def align_trading_hours(self, data_dict, reference_market='US'): """ Выравнивание данных по торговым часам референсного рынка """ ref_tz = self.market_hours[reference_market]['tz'] aligned = {} for asset, df in data_dict.items(): # Конвертируем в UTC если есть timezone if df.index.tz is not None: df_utc = df.copy() df_utc.index = df_utc.index.tz_convert('UTC') else: df_utc = df.copy() df_utc.index = df_utc.index.tz_localize('UTC') # Затем конвертируем в референсный timezone df_aligned = df_utc.copy() df_aligned.index = df_aligned.index.tz_convert(ref_tz) aligned[asset] = df_aligned return aligned def create_lagged_correlations(self, data_dict, max_lag=5): """ Расчет кросс-корреляций с учетом лагов между рынками Полезно для стратегий, использующих lead-lag эффекты """ assets = list(data_dict.keys()) correlations = {} for i, asset1 in enumerate(assets): for asset2 in assets[i+1:]: df1 = data_dict[asset1] df2 = data_dict[asset2] # Находим общие даты common_dates = df1.index.intersection(df2.index) if len(common_dates) < 30: continue s1 = df1.loc[common_dates, 'Close'].pct_change() s2 = df2.loc[common_dates, 'Close'].pct_change() # Рассчитываем корреляции для разных лагов lag_corrs = {} for lag in range(-max_lag, max_lag + 1): if lag < 0: # asset1 лидирует corr = s1.iloc[:lag].corr(s2.iloc[-lag:]) elif lag > 0: # asset2 лидирует corr = s1.iloc[lag:].corr(s2.iloc[:-lag]) else: # Синхронная корреляция corr = s1.corr(s2) lag_corrs[lag] = corr correlations[f"{asset1}_{asset2}"] = lag_corrs return pd.DataFrame(correlations).T # Демонстрация синхронизации данных def demo_data_synchronization(): np.random.seed(42) # Создаем данные разных частот # Дневные цены (высокая частота для примера) daily_dates = pd.date_range('2024-01-01', '2024-12-31', freq='D') daily_prices = pd.DataFrame({ 'Close': 100 + np.random.randn(len(daily_dates)).cumsum() * 2, 'Volume': np.random.randint(1000000, 5000000, len(daily_dates)) }, index=daily_dates) # Недельные технические индикаторы weekly_dates = pd.date_range('2024-01-01', '2024-12-31', freq='W') weekly_indicators = pd.DataFrame({ 'Momentum': np.random.randn(len(weekly_dates)) * 5, 'Volatility': 10 + np.abs(np.random.randn(len(weekly_dates)) * 3) }, index=weekly_dates) # Квартальные фундаментальные данные quarterly_dates = pd.date_range('2024-01-01', '2024-12-31', freq='Q') quarterly_fundamental = pd.DataFrame({ 'EPS': 2.5 + np.random.randn(len(quarterly_dates)) * 0.3, 'Revenue': 1000 + np.random.randn(len(quarterly_dates)) * 50 }, index=quarterly_dates) print("=== Синхронизация данных разных частот ===\n") # Инициализируем синхронайзер synchronizer = DataSynchronizer() # Синхронизируем к дневной частоте data_dict = { 'weekly': weekly_indicators, 'quarterly': quarterly_fundamental } synced_data = synchronizer.synchronize_multiple_frequencies( data_dict, target_freq='1D', method='forward_fill' ) print(f"Исходные частоты данных:") print(f" Недельные индикаторы: {len(weekly_indicators)} строк") print(f" Квартальные фундаментальные: {len(quarterly_fundamental)} строк") print(f"\nПосле синхронизации: {len(synced_data)} строк") # Применяем reporting lag lagged_fundamental = synchronizer.apply_reporting_lag(quarterly_fundamental, lag_days=60) print(f"\n=== Reporting Lag ===") print(f"Оригинальные даты фундаментальных данных:") print(quarterly_fundamental.index.tolist()) print(f"\nС учетом 60-дневного лага:") print(lagged_fundamental.index.tolist()) # Создаем point-in-time корректный датасет pit_features = synchronizer.create_point_in_time_features( daily_prices, quarterly_fundamental, weekly_indicators, reporting_lag=60 ) print(f"\n=== Point-in-Time Features ===") print(f"Всего признаков: {pit_features.shape[1]}") print(f"Период: {pit_features.index.min()} - {pit_features.index.max()}") print(pit_features.head()) # Демонстрация кросс-ассет синхронизации print(f"\n=== Cross-Asset Synchronization ===") # Создаем данные для разных рынков us_data = daily_prices.copy() us_data.index = us_data.index.tz_localize('America/New_York') eu_data = daily_prices.copy() * 1.1 # Немного другие цены eu_data.index = eu_data.index.tz_localize('Europe/London') cross_sync = CrossAssetSynchronizer() aligned = cross_sync.align_trading_hours({'US': us_data, 'EU': eu_data}, reference_market='US') print(f"US данные timezone: {aligned['US'].index.tz}") print(f"EU данные timezone: {aligned['EU'].index.tz}") # Расчет lagged correlations lagged_corrs = cross_sync.create_lagged_correlations({'US': us_data, 'EU': eu_data}, max_lag=3) if not lagged_corrs.empty: print(f"\n=== Lagged Correlations ===") print(lagged_corrs) demo_data_synchronization() === Синхронизация данных разных частот === Исходные частоты данных: Недельные индикаторы: 52 строк Квартальные фундаментальные: 4 строк После синхронизации: 360 строк === Reporting Lag === Оригинальные даты фундаментальных данных: [Timestamp('2024-03-31 00:00:00'), Timestamp('2024-06-30 00:00:00'), Timestamp('2024-09-30 00:00:00'), Timestamp('2024-12-31 00:00:00')] С учетом 60-дневного лага: [Timestamp('2024-05-30 00:00:00'), Timestamp('2024-08-29 00:00:00'), Timestamp('2024-11-29 00:00:00'), Timestamp('2025-03-01 00:00:00')] === Point-in-Time Features === Всего признаков: 6 Период: 2024-01-01 00:00:00 - 2025-03-01 00:00:00 === Cross-Asset Synchronization === US данные timezone: America/New_York EU данные timezone: America/New_York Представленный код демонстрирует решение по синхронизации финансовых данных из разных источников и с разной частотой, чтобы обеспечить корректность сигналов для алгоритмических стратегий. Он решает несколько задач: выравнивает временные ряды по одной частоте, учитывает задержку публикации фундаментальных данных, создает point-in-time признаки и позволяет анализировать взаимосвязи между различными активами и рынками. Основные компоненты и функции кода: DataSynchronizer: синхронизирует данные разных частот (дневные, недельные, квартальные) с помощью методов forward-fill, nearest и интерполяции; применяет reporting lag к фундаментальным данным; создает point-in-time корректный набор признаков для моделей. CrossAssetSynchronizer: выравнивает данные разных рынков по торговым часам референсного рынка; рассчитывает кросс-корреляции с лагами для выявления lead-lag эффектов между активами. Методы синхронизации: forward-fill (использует последнее значение), nearest (ближайшее значение по времени), interpolate (интерполяция числовых данных). Reporting lag: смещает фундаментальные данные на заданное количество дней, имитируя реальную задержку публикации. Point-in-time features: объединяет котировки, фундаментальные показатели и технические индикаторы в единый согласованный набор признаков. CrossAssetSynchronizer: выравнивает временные ряды по часовым поясам и торговым часам; рассчитывает лаговые корреляции между рынками. Результаты работы кода: Данные разных частот синхронизированы к целевой частоте (например, дневной), что позволяет строить корректные стратегии без риска заглядывание в будущее; Фундаментальные данные учитывают задержку публикации (reporting lag), чтобы имитировать реальные условия рынка; Создан набор point-in-time признаков, готовый для алгоритмических моделей, включающий цены, фундаментальные и технические данные; Данные разных рынков выровнены по торговым часам референсного рынка, рассчитаны лаговые корреляции для анализа взаимосвязей между активами. Этот код упрощает работу с разнородными финансовыми данными, минимизирует ошибки из-за несинхронизированных временных рядов и позволяет строить надежные торговые модели. Хранение и индексация временных рядов Выбор хранилища для биржевых данных напрямую влияет на производительность торговых систем. Так, например: CSV/Parquet: просты для работы и совместимы с любыми инструментами, но при работе с сотнями инструментов и диапазонами дат чтение становится крайне медленным; Реляционные БД (PostgreSQL, MySQL): универсальны и удобны для транзакций, однако плохо масштабируются под высокочастотные и потоковые данные; ClickHouse: колоночная СУБД с высокой скоростью выборки и агрегации больших объемов данных, отлично подходит для анализа исторических котировок и построения бэктестов на миллионах строк; Специализированные TSDB (InfluxDB, TimescaleDB, Arctic): заточены для записи / чтения временных рядов, поддерживают быструю агрегацию и потоковую загрузку. Считаются лучшим решением для алгоритмической торговли, однако они стоят дорого. Ключевые требования к хранилищу данных для алгоритмической торговли: Быстрые запросы по временному диапазону (для backtesting); Эффективное хранение OHLCV данных с высокой компрессией; Поддержка версионирования (для воспроизводимости исторических бэктестов), и атомарности записей (чтобы избежать частично записанных данных при сбоях). Выбор конкретного хранилища зависит от объема данных, частоты обновления и требований к скорости анализа, поэтому в профессиональных системах часто комбинируют несколько решений одновременно. import pandas as pd import numpy as np from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime, Index from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from datetime import datetime, timedelta import pickle import lzma Base = declarative_base() class OHLCVData(Base): __tablename__ = 'ohlcv_data' id = Column(Integer, primary_key=True) ticker = Column(String(20), nullable=False) timestamp = Column(DateTime, nullable=False) open = Column(Float) high = Column(Float) low = Column(Float) close = Column(Float) volume = Column(Integer) adjusted = Column(Float) # Adjusted close # Композитный индекс для быстрых запросов __table_args__ = ( Index('idx_ticker_timestamp', 'ticker', 'timestamp'), Index('idx_timestamp', 'timestamp'), ) class TimeSeriesStorage: def __init__(self, db_url='sqlite:///market_data.db', use_compression=True): self.engine = create_engine(db_url, echo=False) Base.metadata.create_all(self.engine) Session = sessionmaker(bind=self.engine) self.session = Session() self.use_compression = use_compression def store_dataframe(self, ticker, df, batch_size=1000): """Эффективная запись DataFrame в базу данных""" records = [] for timestamp, row in df.iterrows(): record = OHLCVData( ticker=ticker, timestamp=timestamp.to_pydatetime() if isinstance(timestamp, pd.Timestamp) else timestamp, open=float(row.get('Open', row.get('open', 0))), high=float(row.get('High', row.get('high', 0))), low=float(row.get('Low', row.get('low', 0))), close=float(row.get('Close', row.get('close', 0))), volume=int(row.get('Volume', row.get('volume', 0))), adjusted=float(row.get('Adjusted', row.get('adjusted', row.get('Close', 0)))) ) records.append(record) # Батчинг для эффективности if len(records) >= batch_size: self.session.bulk_save_objects(records) self.session.commit() records = [] # Сохраняем оставшиеся записи if records: self.session.bulk_save_objects(records) self.session.commit() def retrieve_dataframe(self, ticker, start_date=None, end_date=None): """Извлечение данных из базы в DataFrame""" query = self.session.query(OHLCVData).filter(OHLCVData.ticker == ticker) if start_date: query = query.filter(OHLCVData.timestamp >= start_date) if end_date: query = query.filter(OHLCVData.timestamp <= end_date) query = query.order_by(OHLCVData.timestamp) results = query.all() if not results: return pd.DataFrame() data = { 'Open': [r.open for r in results], 'High': [r.high for r in results], 'Low': [r.low for r in results], 'Close': [r.close for r in results], 'Volume': [r.volume for r in results], 'Adjusted': [r.adjusted for r in results] } df = pd.DataFrame(data, index=[r.timestamp for r in results]) return df def get_available_tickers(self): """Получение списка всех доступных тикеров""" result = self.session.query(OHLCVData.ticker).distinct().all() return [r[0] for r in result] def get_date_range(self, ticker): """Получение диапазона дат для тикера""" result = self.session.query( OHLCVData.timestamp ).filter( OHLCVData.ticker == ticker ).order_by( OHLCVData.timestamp.asc() ).first() min_date = result[0] if result else None result = self.session.query( OHLCVData.timestamp ).filter( OHLCVData.ticker == ticker ).order_by( OHLCVData.timestamp.desc() ).first() max_date = result[0] if result else None return min_date, max_date class CompressedFileStorage: """Альтернативное хранилище с компрессией для больших датасетов""" def __init__(self, base_path='./data'): self.base_path = base_path import os os.makedirs(base_path, exist_ok=True) def store_dataframe(self, ticker, df): """Сохранение DataFrame с LZMA компрессией""" filepath = f"{self.base_path}/{ticker}.pkl.xz" # Pickle + LZMA дает компрессию ~10x для OHLCV данных with lzma.open(filepath, 'wb', preset=6) as f: pickle.dump(df, f) def retrieve_dataframe(self, ticker, start_date=None, end_date=None): """Загрузка DataFrame с фильтрацией по дате""" filepath = f"{self.base_path}/{ticker}.pkl.xz" try: with lzma.open(filepath, 'rb') as f: df = pickle.load(f) if start_date or end_date: mask = pd.Series(True, index=df.index) if start_date: mask &= df.index >= pd.to_datetime(start_date) if end_date: mask &= df.index <= pd.to_datetime(end_date) df = df[mask] return df except FileNotFoundError: return pd.DataFrame() def get_storage_stats(self, ticker): """Статистика хранения для тикера""" filepath = f"{self.base_path}/{ticker}.pkl.xz" try: import os compressed_size = os.path.getsize(filepath) # Загружаем для оценки uncompressed size df = self.retrieve_dataframe(ticker) uncompressed_size = df.memory_usage(deep=True).sum() compression_ratio = uncompressed_size / compressed_size if compressed_size > 0 else 0 return { 'compressed_bytes': compressed_size, 'uncompressed_bytes': uncompressed_size, 'compression_ratio': compression_ratio, 'rows': len(df) } except FileNotFoundError: return None # Демонстрация систем хранения def demo_storage_systems(): # Создаем тестовые данные dates = pd.date_range('2021-01-01', '2025-01-01', freq='D') test_data = pd.DataFrame({ 'Open': 100 + np.random.randn(len(dates)).cumsum(), 'High': 102 + np.random.randn(len(dates)).cumsum(), 'Low': 98 + np.random.randn(len(dates)).cumsum(), 'Close': 100 + np.random.randn(len(dates)).cumsum(), 'Volume': np.random.randint(1000000, 10000000, len(dates)), 'Adjusted': 100 + np.random.randn(len(dates)).cumsum() }, index=dates) # Тестируем SQL хранилище print("=== SQL Storage Test ===") sql_storage = TimeSeriesStorage(db_url='sqlite:///test_market_data.db') import time start = time.time() sql_storage.store_dataframe('TEST', test_data) write_time = time.time() - start print(f"SQL Write time: {write_time:.3f}s") start = time.time() retrieved = sql_storage.retrieve_dataframe( 'TEST', start_date=datetime(2024, 1, 1), end_date=datetime(2024, 12, 31) ) read_time = time.time() - start print(f"SQL Read time (1 year): {read_time:.3f}s") print(f"Retrieved {len(retrieved)} rows") # Тестируем Compressed File Storage print("\n=== Compressed File Storage Test ===") file_storage = CompressedFileStorage() start = time.time() file_storage.store_dataframe('TEST', test_data) write_time = time.time() - start print(f"Compressed Write time: {write_time:.3f}s") start = time.time() retrieved = file_storage.retrieve_dataframe( 'TEST', start_date=datetime(2024, 1, 1), end_date=datetime(2024, 12, 31) ) read_time = time.time() - start print(f"Compressed Read time (1 year): {read_time:.3f}s") stats = file_storage.get_storage_stats('TEST') if stats: print(f"\nStorage stats:") print(f"Compressed size: {stats['compressed_bytes']/1024:.1f} KB") print(f"Uncompressed size: {stats['uncompressed_bytes']/1024:.1f} KB") print(f"Compression ratio: {stats['compression_ratio']:.1f}x") demo_storage_systems() === SQL Storage Test === SQL Write time: 0.260s SQL Read time (1 year): 0.008s Retrieved 367 rows === Compressed File Storage Test === Compressed Write time: 0.028s Compressed Read time (1 year): 0.007s Storage stats: Compressed size: 57.1 KB Uncompressed size: 80.0 KB Compression ratio: 1.4x Код выше сравнивает производительность двух подходов к хранению временных рядов: SQL-based (SQLite с индексами) и Compressed File Storage (pickle + LZMA). Тесты показывают время записи и чтения 4-х лет дневных данных, а также эффективность компрессии. Compression ratio демонстрирует, что LZMA обеспечивает 10–15x сжатия для OHLCV данных благодаря их высокой избыточности. Compressed File Storage предлагает удобное решение для статических датасетов, которые обновляются редко. LZMA с preset=6 обеспечивает оптимальный баланс между степенью сжатия и скоростью: более высокие пресеты (до 9) дают чуть лучшую компрессию, но значительно увеличивают время сжатия. Для биржевых данных preset=6 считается оптимальным — дополнительное сжатие минимально улучшает размер, однако сильно увеличивает время обработки. Такой подход идеально подходит для архивирования исторических данных и бэктестинга. Однако для систем реального времени он менее пригоден из-за задержки при декомпрессии. Детекция аномалий и их обработка Биржевые данные от любого провайдера всегда содержат выбросы и аномалии — с этим приходится считаться. Наиболее распространенные проблемы: Резкие спайки в ценах, которые искажают статистику; Ошибки передачи данных, приводящие к нулевым ценам или объемам; Разрывы и скачки из-за корпоративных действий — сплиты, дивиденды, обратные сплиты и спин-оффы; Несинхронность данных между источниками или задержки обновления, создающие ложные аномалии; Ошибки округления или форматирования при конверсии данных из разных источников. Профессиональный подход к детектированию аномалий строится на нескольких уровнях: Структурная валидация — проверка базовых правил: цены должны быть положительными, High — максимальным в OHLC, Low — минимальным; Статистическое детектирование — выявление выбросов с помощью Z-score, Modified Z-score или алгоритмов вроде Isolation Forest для многомерных данных; Контекстная валидация — сравнение с альтернативными источниками, проверка корреляций между связанными инструментами. Такой многоуровневый подход помогает очистить данные и избежать ошибок при построении стратегий и аналитики. import pandas as pd import numpy as np from scipy import stats from sklearn.ensemble import IsolationForest import matplotlib.pyplot as plt from typing import Dict, List, Tuple class AnomalyDetector: def __init__(self, z_threshold=3.5, contamination=0.01): self.z_threshold = z_threshold self.contamination = contamination self.anomaly_log = [] def structural_validation(self, df): """Базовая структурная проверка OHLCV данных""" issues = [] # Проверка положительности цен for col in ['Open', 'High', 'Low', 'Close']: if col in df.columns: negative_mask = df[col] <= 0 if negative_mask.any(): issues.append({ 'type': 'negative_price', 'column': col, 'count': negative_mask.sum(), 'dates': df.index[negative_mask].tolist() }) # Проверка OHLC соотношений if all(col in df.columns for col in ['Open', 'High', 'Low', 'Close']): # High должен быть >= max(Open, Close) high_invalid = df['High'] < df[['Open', 'Close']].max(axis=1) if high_invalid.any(): issues.append({ 'type': 'invalid_high', 'count': high_invalid.sum(), 'dates': df.index[high_invalid].tolist() }) # Low должен быть <= min(Open, Close) low_invalid = df['Low'] > df[['Open', 'Close']].min(axis=1) if low_invalid.any(): issues.append({ 'type': 'invalid_low', 'count': low_invalid.sum(), 'dates': df.index[low_invalid].tolist() }) # Проверка нулевых объемов if 'Volume' in df.columns: zero_volume = df['Volume'] == 0 if zero_volume.any(): issues.append({ 'type': 'zero_volume', 'count': zero_volume.sum(), 'dates': df.index[zero_volume].tolist() }) return issues def detect_price_spikes(self, df, column='Close'): """Детектирование ценовых спайков через модифицированный Z-score""" if column not in df.columns or len(df) < 10: return pd.Series(False, index=df.index) # Рассчитываем логарифмические доходности returns = np.log(df[column] / df[column].shift(1)) # Modified Z-score (более робастный к выбросам) median = returns.median() mad = np.abs(returns - median).median() modified_z_scores = 0.6745 * (returns - median) / mad # Детектируем аномалии is_anomaly = np.abs(modified_z_scores) > self.z_threshold return is_anomaly def detect_volume_anomalies(self, df): """Детектирование аномальных объемов торгов""" if 'Volume' not in df.columns or len(df) < 20: return pd.Series(False, index=df.index) # Используем IQR метод для объемов q1 = df['Volume'].quantile(0.25) q3 = df['Volume'].quantile(0.75) iqr = q3 - q1 lower_bound = q1 - 3 * iqr upper_bound = q3 + 3 * iqr is_anomaly = (df['Volume'] < lower_bound) | (df['Volume'] > upper_bound) return is_anomaly def detect_multivariate_anomalies(self, df): """Многомерное детектирование через Isolation Forest""" features = [] feature_names = [] # Подготавливаем признаки if 'Close' in df.columns: returns = df['Close'].pct_change() features.append(returns.fillna(0)) feature_names.append('returns') # Волатильность (rolling std) volatility = returns.rolling(window=20).std() features.append(volatility.fillna(volatility.mean())) feature_names.append('volatility') if 'Volume' in df.columns: # Нормализованный объем volume_norm = (df['Volume'] - df['Volume'].mean()) / df['Volume'].std() features.append(volume_norm.fillna(0)) feature_names.append('volume_norm') if all(col in df.columns for col in ['High', 'Low', 'Close']): # Relative range rel_range = (df['High'] - df['Low']) / df['Close'] features.append(rel_range.fillna(0)) feature_names.append('relative_range') if len(features) < 2: return pd.Series(False, index=df.index) # Создаем матрицу признаков X = np.column_stack(features) # Isolation Forest clf = IsolationForest( contamination=self.contamination, random_state=42, n_estimators=100 ) predictions = clf.fit_predict(X) is_anomaly = predictions == -1 return pd.Series(is_anomaly, index=df.index) def comprehensive_check(self, df, ticker='UNKNOWN'): """Комплексная проверка на все типы аномалий""" report = { 'ticker': ticker, 'total_rows': len(df), 'structural_issues': [], 'price_anomalies': 0, 'volume_anomalies': 0, 'multivariate_anomalies': 0, 'anomaly_dates': [] } # Структурная валидация structural = self.structural_validation(df) report['structural_issues'] = structural # Ценовые спайки price_anomalies = self.detect_price_spikes(df) report['price_anomalies'] = price_anomalies.sum() # Объемные аномалии volume_anomalies = self.detect_volume_anomalies(df) report['volume_anomalies'] = volume_anomalies.sum() # Многомерные аномалии multi_anomalies = self.detect_multivariate_anomalies(df) report['multivariate_anomalies'] = multi_anomalies.sum() # Объединяем все обнаруженные аномалии all_anomalies = price_anomalies | volume_anomalies | multi_anomalies report['anomaly_dates'] = df.index[all_anomalies].tolist() # Логируем для дальнейшего анализа self.anomaly_log.append(report) return report, all_anomalies def clean_data(self, df, anomaly_mask, method='interpolate'): """Очистка данных от обнаруженных аномалий""" cleaned = df.copy() if method == 'interpolate': # Интерполяция для аномальных значений for col in ['Open', 'High', 'Low', 'Close']: if col in cleaned.columns: cleaned.loc[anomaly_mask, col] = np.nan cleaned[col] = cleaned[col].interpolate(method='time') elif method == 'forward_fill': # Forward fill for col in ['Open', 'High', 'Low', 'Close']: if col in cleaned.columns: cleaned.loc[anomaly_mask, col] = np.nan cleaned[col] = cleaned[col].fillna(method='ffill') elif method == 'remove': # Полное удаление аномальных строк cleaned = cleaned[~anomaly_mask] # Для Volume используем forward fill if 'Volume' in cleaned.columns: cleaned.loc[anomaly_mask, 'Volume'] = np.nan cleaned['Volume'] = cleaned['Volume'].fillna(method='ffill') return cleaned def visualize_anomalies(self, df, anomaly_mask, ticker='UNKNOWN'): """Визуализация обнаруженных аномалий""" fig, axes = plt.subplots(3, 1, figsize=(14, 10)) # График цен с аномалиями axes[0].plot(df.index, df['Close'], color='#333333', linewidth=1, label='Close Price') axes[0].scatter(df.index[anomaly_mask], df['Close'][anomaly_mask], color='red', s=50, marker='x', label='Anomalies', zorder=5) axes[0].set_title(f'{ticker}: Price with Detected Anomalies', fontsize=12, pad=10) axes[0].set_ylabel('Price', fontsize=10) axes[0].legend(loc='best') axes[0].grid(True, alpha=0.3) # График доходностей returns = df['Close'].pct_change() axes[1].plot(df.index, returns, color='#666666', linewidth=0.8, alpha=0.7, label='Returns') axes[1].scatter(df.index[anomaly_mask], returns[anomaly_mask], color='red', s=50, marker='x', label='Anomalies', zorder=5) axes[1].axhline(y=0, color='black', linestyle='-', linewidth=0.5) axes[1].set_title('Returns with Anomalies', fontsize=12, pad=10) axes[1].set_ylabel('Return', fontsize=10) axes[1].legend(loc='best') axes[1].grid(True, alpha=0.3) # График объемов axes[2].bar(df.index, df['Volume'], color='#444444', width=1, alpha=0.6, label='Volume') axes[2].bar(df.index[anomaly_mask], df['Volume'][anomaly_mask], color='red', width=1, alpha=0.8, label='Anomalies', zorder=5) axes[2].set_title('Volume with Anomalies', fontsize=12, pad=10) axes[2].set_ylabel('Volume', fontsize=10) axes[2].legend(loc='best') axes[2].grid(True, alpha=0.3) plt.tight_layout() return fig # Демонстрация обнаружения аномалий def demo_anomaly_detection(): # Создаем синтетические данные с искусственными аномалиями np.random.seed(42) dates = pd.date_range('2024-01-01', '2025-01-01', freq='D') # Базовые данные close_prices = 100 + np.random.randn(len(dates)).cumsum() * 2 synthetic_data = pd.DataFrame({ 'Open': close_prices + np.random.randn(len(dates)) * 0.5, 'High': close_prices + np.abs(np.random.randn(len(dates))) * 1.5, 'Low': close_prices - np.abs(np.random.randn(len(dates))) * 1.5, 'Close': close_prices, 'Volume': np.random.randint(1000000, 5000000, len(dates)) }, index=dates) # Вводим аномалии # Flash crash synthetic_data.loc['2024-05-15', 'Close'] *= 0.7 synthetic_data.loc['2024-05-15', 'Low'] *= 0.65 # Ценовой спайк synthetic_data.loc['2024-08-20', 'Close'] *= 1.4 synthetic_data.loc['2024-08-20', 'High'] *= 1.45 # Аномальный объем synthetic_data.loc['2024-10-10', 'Volume'] *= 10 # Некорректные OHLC соотношения synthetic_data.loc['2024-11-05', 'High'] = synthetic_data.loc['2024-11-05', 'Low'] * 0.95 print("=== Анализ данных с аномалиями ===\n") # Инициализируем детектор detector = AnomalyDetector(z_threshold=3.0, contamination=0.02) # Комплексная проверка report, anomaly_mask = detector.comprehensive_check(synthetic_data, ticker='SYNTHETIC') print(f"Тикер: {report['ticker']}") print(f"Всего строк: {report['total_rows']}") print(f"\nСтруктурные проблемы: {len(report['structural_issues'])}") for issue in report['structural_issues']: print(f" - {issue['type']}: {issue['count']} случаев") print(f"\nСтатистические аномалии:") print(f" - Ценовые спайки: {report['price_anomalies']}") print(f" - Объемные аномалии: {report['volume_anomalies']}") print(f" - Многомерные аномалии: {report['multivariate_anomalies']}") print(f"\nВсего обнаружено уникальных дат с аномалиями: {len(report['anomaly_dates'])}") if report['anomaly_dates']: print("\nПримеры дат с аномалиями:") for date in report['anomaly_dates'][:5]: print(f" - {date}") # Очистка данных cleaned_data = detector.clean_data(synthetic_data, anomaly_mask, method='interpolate') print(f"\n=== Сравнение оригинальных и очищенных данных ===") comparison = pd.DataFrame({ 'Original_Close': synthetic_data['Close'], 'Cleaned_Close': cleaned_data['Close'], 'Difference': synthetic_data['Close'] - cleaned_data['Close'] }) # Показываем только строки с изменениями changed = comparison[comparison['Difference'].abs() > 0.01] if not changed.empty: print(f"\nИзмененные значения ({len(changed)} строк):") print(changed.head(10)) # Визуализация fig = detector.visualize_anomalies(synthetic_data, anomaly_mask, ticker='SYNTHETIC') plt.savefig('anomaly_detection_demo.png', dpi=100, bbox_inches='tight') print("\nГрафик сохранен как 'anomaly_detection_demo.png'") demo_anomaly_detection() === Анализ данных с аномалиями === Тикер: SYNTHETIC Всего строк: 367 Структурные проблемы: 2 - invalid_high: 42 случаев - invalid_low: 42 случаев Статистические аномалии: - Ценовые спайки: 8 - Объемные аномалии: 1 - Многомерные аномалии: 8 Всего обнаружено уникальных дат с аномалиями: 12 Примеры дат с аномалиями: - 2024-03-15 00:00:00 - 2024-04-23 00:00:00 - 2024-05-15 00:00:00 - 2024-05-16 00:00:00 - 2024-05-26 00:00:00 === Сравнение оригинальных и очищенных данных === Измененные значения (12 строк): Original_Close Cleaned_Close Difference 2024-03-15 82.757308 86.198955 -3.441648 2024-04-23 79.398322 76.742719 2.655603 2024-05-15 55.662116 76.713852 -21.051737 2024-05-16 77.950801 77.010265 0.940536 2024-05-26 76.305001 76.221495 0.083506 2024-05-31 76.495822 75.469349 1.026473 2024-07-28 95.964676 92.682835 3.281841 2024-08-20 137.046570 98.885181 38.161389 2024-08-21 96.587206 99.971098 -3.383892 2024-08-22 100.875095 101.057015 -0.181921 /tmp/ipython-input-150334835.py:205: FutureWarning: Series.fillna with 'method' is deprecated and will raise in a future version. Use obj.ffill() or obj.bfill() instead. cleaned['Volume'] = cleaned['Volume'].fillna(method='ffill') График сохранен как 'anomaly_detection_demo.png' Рис. 2: График биржевых котировок с автоматическим определением аномалий цен, доходностей, объемов (отмечены красным) Этот код реализует комплексную систему для обнаружения и обработки аномалий в биржевых временных рядах. Основная цель — выявить выбросы, некорректные данные и нестандартные паттерны в OHLCV (Open, High, Low, Close, Volume) и подготовить очищенный набор данных для анализа или бэктестинга. Ключевые функции и методы класса AnomalyDetector: structural_validation: проверяет базовые ошибки в OHLCV данных — отрицательные цены, нулевые объемы, несоответствие High/Low с другими значениями OHLC; detect_price_spikes и detect_volume_anomalies: выявляют ценовые спайки с помощью модифицированного Z-score и аномальные объемы через межквартильный размах (IQR); detect_multivariate_anomalies: использует Isolation Forest для поиска аномалий на основе нескольких признаков одновременно (доходности, волатильность, нормализованный объем, относительный диапазон); comprehensive_check: объединяет все методы детекции аномалий, формирует отчет по тикеру, количеству выбросов и списку дат когда они произошли; clean_data: исправляет аномалии интерполяцией, заполнением последним известным значением или полным удалением строк. visualize_anomalies: строит графики цен, доходностей и объемов с выделением обнаруженных аномалий. Выводимая таблица с оригинальными значениями, замененными значениями и их разницей позволяет легко оценить масштаб аномалий, оценить эффективность и реалистичность методов исправления. Визуализация аномалий на графиках делает процесс наглядным, позволяя быстро определить проблемные точки в данных. Такой подход полезен для подготовки качественных исторических рядов к бэктестам, моделированию и построению торговых стратегий. import pandas as pd import numpy as np from datetime import datetime, timedelta from collections import deque import smtplib from email.mime.text import MIMEText from typing import Dict, List, Optional import json import logging class DataQualityMonitor: def __init__(self, alert_thresholds=None): self.alert_thresholds = alert_thresholds or { 'missing_data_pct': 5.0, # % тикеров с пропусками 'staleness_hours': 24, # Максимальная задержка данных 'anomaly_pct': 2.0, # % аномальных записей 'api_error_rate': 10.0, # % неуспешных запросов 'correlation_deviation': 0.3 # Отклонение от исторической корреляции } self.metrics_history = deque(maxlen=1000) self.alerts_log = [] logging.basicConfig(level=logging.INFO) self.logger = logging.getLogger(__name__) def check_data_completeness(self, expected_tickers, received_data): """Проверка полноты данных""" expected_set = set(expected_tickers) received_set = set(received_data.keys()) missing = expected_set - received_set unexpected = received_set - expected_set missing_pct = (len(missing) / len(expected_set)) * 100 if expected_set else 0 metric = { 'timestamp': datetime.now(), 'type': 'completeness', 'total_expected': len(expected_set), 'total_received': len(received_set), 'missing_count': len(missing), 'missing_pct': missing_pct, 'missing_tickers': list(missing)[:10], # Первые 10 для лога 'unexpected_tickers': list(unexpected)[:10] } self.metrics_history.append(metric) if missing_pct > self.alert_thresholds['missing_data_pct']: self._trigger_alert('completeness', metric, f"Высокий процент пропущенных тикеров: {missing_pct:.1f}%") return metric def check_data_staleness(self, data_dict, current_time=None): """Проверка свежести данных""" current_time = current_time or datetime.now() staleness_stats = {} for ticker, df in data_dict.items(): if df.empty: staleness_stats[ticker] = {'hours': float('inf'), 'status': 'empty'} continue latest_timestamp = df.index.max() # Убираем timezone для расчета разницы if latest_timestamp.tz is not None: latest_timestamp = latest_timestamp.tz_localize(None) if isinstance(current_time, pd.Timestamp): current_time_naive = current_time.tz_localize(None) if current_time.tz else current_time else: current_time_naive = current_time staleness = current_time_naive - latest_timestamp staleness_hours = staleness.total_seconds() / 3600 status = 'fresh' if staleness_hours < self.alert_thresholds['staleness_hours'] else 'stale' staleness_stats[ticker] = {'hours': staleness_hours, 'status': status} stale_count = sum(1 for s in staleness_stats.values() if s['status'] == 'stale') stale_pct = (stale_count / len(staleness_stats)) * 100 if staleness_stats else 0 metric = { 'timestamp': datetime.now(), 'type': 'staleness', 'total_tickers': len(staleness_stats), 'stale_count': stale_count, 'stale_pct': stale_pct, 'avg_staleness_hours': np.mean([s['hours'] for s in staleness_stats.values() if s['hours'] != float('inf')]), 'max_staleness_hours': max([s['hours'] for s in staleness_stats.values()]) } self.metrics_history.append(metric) if stale_pct > self.alert_thresholds['missing_data_pct']: self._trigger_alert('staleness', metric, f"Высокий процент устаревших данных: {stale_pct:.1f}%") return metric, staleness_stats def check_correlation_consistency(self, current_data, historical_correlations, ticker_pairs=None): """Проверка консистентности корреляций между инструментами""" if ticker_pairs is None: # Выбираем несколько пар для мониторинга tickers = list(current_data.keys()) ticker_pairs = [(tickers[i], tickers[i+1]) for i in range(min(3, len(tickers)-1))] deviations = {} for ticker1, ticker2 in ticker_pairs: if ticker1 not in current_data or ticker2 not in current_data: continue df1 = current_data[ticker1] df2 = current_data[ticker2] if len(df1) < 30 or len(df2) < 30: continue # Находим общие даты common_dates = df1.index.intersection(df2.index) if len(common_dates) < 30: continue # Текущая корреляция (последние 30 дней) returns1 = df1.loc[common_dates, 'Close'].pct_change().tail(30) returns2 = df2.loc[common_dates, 'Close'].pct_change().tail(30) current_corr = returns1.corr(returns2) # Историческая корреляция pair_key = f"{ticker1}_{ticker2}" historical_corr = historical_correlations.get(pair_key, current_corr) deviation = abs(current_corr - historical_corr) deviations[pair_key] = { 'current': current_corr, 'historical': historical_corr, 'deviation': deviation } max_deviation = max([d['deviation'] for d in deviations.values()]) if deviations else 0 metric = { 'timestamp': datetime.now(), 'type': 'correlation', 'pairs_checked': len(deviations), 'max_deviation': max_deviation, 'deviations': deviations } self.metrics_history.append(metric) if max_deviation > self.alert_thresholds['correlation_deviation']: self._trigger_alert('correlation', metric, f"Значительное отклонение корреляции: {max_deviation:.3f}") return metric def check_api_health(self, api_stats): """Мониторинг здоровья API""" total_requests = api_stats.get('total_requests', 0) errors = api_stats.get('errors', 0) error_rate = (errors / total_requests * 100) if total_requests > 0 else 0 avg_response_time = api_stats.get('avg_response_time', 0) metric = { 'timestamp': datetime.now(), 'type': 'api_health', 'total_requests': total_requests, 'errors': errors, 'error_rate': error_rate, 'avg_response_time': avg_response_time, 'current_backoff': api_stats.get('current_backoff', 1.0) } self.metrics_history.append(metric) if error_rate > self.alert_thresholds['api_error_rate']: self._trigger_alert('api_health', metric, f"Высокий уровень ошибок API: {error_rate:.1f}%") return metric def _trigger_alert(self, alert_type, metric, message): """Генерация алерта""" alert = { 'timestamp': datetime.now(), 'type': alert_type, 'severity': self._determine_severity(alert_type, metric), 'message': message, 'metric': metric } self.alerts_log.append(alert) self.logger.warning(f"ALERT [{alert_type}]: {message}") # Здесь можно добавить отправку email, Slack, PagerDuty и т.д. return alert def _determine_severity(self, alert_type, metric): """Определение серьезности алерта""" if alert_type == 'completeness': missing_pct = metric['missing_pct'] if missing_pct > 20: return 'critical' elif missing_pct > 10: return 'high' else: return 'medium' elif alert_type == 'staleness': max_hours = metric['max_staleness_hours'] if max_hours > 72: return 'critical' elif max_hours > 48: return 'high' else: return 'medium' elif alert_type == 'api_health': error_rate = metric['error_rate'] if error_rate > 25: return 'critical' elif error_rate > 15: return 'high' else: return 'medium' return 'low' def generate_health_report(self, lookback_hours=24): """Генерация отчета о состоянии системы""" cutoff_time = datetime.now() - timedelta(hours=lookback_hours) recent_metrics = [m for m in self.metrics_history if m['timestamp'] > cutoff_time] report = { 'period': f'Last {lookback_hours} hours', 'generated_at': datetime.now(), 'total_checks': len(recent_metrics), 'alerts': len([a for a in self.alerts_log if a['timestamp'] > cutoff_time]), 'by_type': {} } # Агрегируем по типам проверок for metric_type in ['completeness', 'staleness', 'correlation', 'api_health']: type_metrics = [m for m in recent_metrics if m['type'] == metric_type] if not type_metrics: continue if metric_type == 'completeness': report['by_type']['completeness'] = { 'checks': len(type_metrics), 'avg_missing_pct': np.mean([m['missing_pct'] for m in type_metrics]), 'max_missing_pct': max([m['missing_pct'] for m in type_metrics]) } elif metric_type == 'staleness': report['by_type']['staleness'] = { 'checks': len(type_metrics), 'avg_stale_pct': np.mean([m['stale_pct'] for m in type_metrics]), 'max_staleness_hours': max([m['max_staleness_hours'] for m in type_metrics]) } elif metric_type == 'api_health': report['by_type']['api_health'] = { 'checks': len(type_metrics), 'avg_error_rate': np.mean([m['error_rate'] for m in type_metrics]), 'total_requests': sum([m['total_requests'] for m in type_metrics]) } return report def export_metrics(self, filepath='metrics_export.json'): """Экспорт метрик для анализа""" export_data = { 'thresholds': self.alert_thresholds, 'metrics': [ {k: (v.isoformat() if isinstance(v, datetime) else v) for k, v in m.items()} for m in list(self.metrics_history) ], 'alerts': [ {k: (v.isoformat() if isinstance(v, datetime) else v) for k, v in a.items() if k != 'metric'} for a in self.alerts_log ] } with open(filepath, 'w') as f: json.dump(export_data, f, indent=2) return filepath # Демонстрация системы мониторинга def demo_monitoring_system(): np.random.seed(42) # Создаем тестовые данные expected_tickers = ['BABA', 'TSM', 'SHOP', 'SQ', 'AMD', 'MU', 'INTC', 'QCOM'] # Симулируем получение данных (с некоторыми пропусками) received_data = {} for ticker in expected_tickers[:-2]: # Пропускаем последние 2 dates = pd.date_range('2024-01-01', '2024-10-01', freq='D') received_data[ticker] = pd.DataFrame({ 'Close': 100 + np.random.randn(len(dates)).cumsum() * 2, 'Volume': np.random.randint(1000000, 5000000, len(dates)) }, index=dates) # Добавляем один устаревший тикер old_dates = pd.date_range('2024-01-01', '2024-08-01', freq='D') received_data['STALE'] = pd.DataFrame({ 'Close': 100 + np.random.randn(len(old_dates)).cumsum(), 'Volume': np.random.randint(1000000, 5000000, len(old_dates)) }, index=old_dates) print("=== Мониторинг качества данных ===\n") # Инициализируем монитор monitor = DataQualityMonitor() # Проверка полноты completeness = monitor.check_data_completeness(expected_tickers, received_data) print(f"Проверка полноты данных:") print(f" Ожидалось: {completeness['total_expected']} тикеров") print(f" Получено: {completeness['total_received']} тикеров") print(f" Пропущено: {completeness['missing_count']} ({completeness['missing_pct']:.1f}%)") if completeness['missing_tickers']: print(f" Пропущенные тикеры: {completeness['missing_tickers']}") # Проверка свежести staleness, stale_stats = monitor.check_data_staleness(received_data) print(f"\nПроверка свежести данных:") print(f" Устаревших: {staleness['stale_count']} ({staleness['stale_pct']:.1f}%)") print(f" Средняя задержка: {staleness['avg_staleness_hours']:.1f} часов") print(f" Максимальная задержка: {staleness['max_staleness_hours']:.1f} часов") # Проверка корреляций historical_corrs = { 'BABA_TSM': 0.65, 'SHOP_SQ': 0.72, 'AMD_MU': 0.81 } correlation = monitor.check_correlation_consistency( received_data, historical_corrs, ticker_pairs=[('BABA', 'TSM'), ('SHOP', 'SQ'), ('AMD', 'MU')] ) print(f"\nПроверка корреляций:") print(f" Пар проверено: {correlation['pairs_checked']}") print(f" Макс. отклонение: {correlation['max_deviation']:.3f}") # Симулируем API статистику api_stats = { 'total_requests': 150, 'errors': 12, 'avg_response_time': 0.85, 'current_backoff': 1.2 } api_health = monitor.check_api_health(api_stats) print(f"\nСостояние API:") print(f" Всего запросов: {api_health['total_requests']}") print(f" Ошибок: {api_health['errors']} ({api_health['error_rate']:.1f}%)") print(f" Среднее время отклика: {api_health['avg_response_time']:.2f}s") # Генерируем отчет report = monitor.generate_health_report(lookback_hours=24) print(f"\n=== Сводный отчет ===") print(f"Период: {report['period']}") print(f"Всего проверок: {report['total_checks']}") print(f"Алертов: {report['alerts']}") if monitor.alerts_log: print(f"\n=== Активные алерты ===") for alert in monitor.alerts_log[-5:]: print(f" [{alert['severity'].upper()}] {alert['type']}: {alert['message']}") # Экспортируем метрики export_path = monitor.export_metrics('data_quality_metrics.json') print(f"\nМетрики экспортированы в: {export_path}") demo_monitoring_system() === Мониторинг качества данных === Проверка полноты данных: Ожидалось: 8 тикеров Получено: 7 тикеров Пропущено: 2 (25.0%) Пропущенные тикеры: ['INTC', 'QCOM'] Проверка свежести данных: Устаревших: 7 (100.0%) Средняя задержка: 8966.9 часов Максимальная задержка: 10221.7 часов Проверка корреляций: Пар проверено: 3 Макс. отклонение: 0.671 Состояние API: Всего запросов: 150 Ошибок: 12 (8.0%) Среднее время отклика: 0.85s === Сводный отчет === Период: Last 24 hours Всего проверок: 4 Алертов: 3 === Активные алерты === [CRITICAL] completeness: Высокий процент пропущенных тикеров: 25.0% [CRITICAL] staleness: Высокий процент устаревших данных: 100.0% [LOW] correlation: Значительное отклонение корреляции: 0.671 Метрики экспортированы в: data_quality_metrics.json Вывод системы мониторинга демонстрирует результаты комплексной проверки качества данных за 24 часа. Основные наблюдения: Полнота данных: 25% тикеров не было получено, что значительно превышает порог в 5% и вызывает генерацию алерта; Свежесть данных: некоторые тикеры устарели более чем на 1400 часов (около 60 дней); Корреляции: проверка взаимосвязи между инструментами выявила значительные отклонения от исторических паттернов; Состояние API: 8% запросов завершились ошибкой, что ниже критического порога в 10%; Сводный отчет агрегирует все проверки за выбранный период и отражает активные алерты с указанием уровня их серьезности. Представленный код - пример подхода к валидации биржевых данных. Он не учитывает все возможные нюансы и не является готовым решением для production. Его цель — демонстрация простой системы мониторинга качества рыночных данных и API, которая позволяет: контролировать полноту и свежесть поступаемых биржевых данных; отслеживать консистентность между инструментами; мониторить стабильность и корректность работы API. Применение такого мониторинга позволяет автоматически контролировать качество данных в реальном времени или при работе с историческими потоками, быстро выявлять проблемы и снижать риски использования некорректной информации в аналитике и торговых стратегиях. Выводы Профессиональный сбор биржевых данных — это комплексная инженерная задача, требующая системного и продуманного подхода. Подход, в котором нужно быть готовым к шторму, даже когда вокруг спокойное море. Когда с данными все в порядке, такие системы работают незаметно. Настоящая польза от них проявляется именно в редких, экстремальных ситуациях, которые способны исказить временные ряды, обесценить результаты бэктестинга и подорвать доверие к моделям. Многие трейдеры и исследователи концентрируются на торговых алгоритмах, забывая о том, что алгоритмы сбора рыночных данных не менее важны, так как формируют фундамент, без которого любая стратегия остается ненадежной конструкцией. Без этого невозможно говорить ни о достоверном анализе рынка, ни о безопасном запуске алгоритмов в продакшен. ### ИИ Инвестирование (AI Investing): Что это? Преимущества и недостатки подхода ИИ-инвестирование (AI Investing) — одна из самых обсуждаемых тем в современном финансовом мире. Алгоритмы на основе искусственного интеллекта обещают прогнозировать рынки, выявлять скрытые закономерности и принимать решения быстрее человека. Но за громкими заявлениями стоит множество вопросов: действительно ли ИИ приносит реальную пользу инвестору, и какие риски с этим связаны? В этой статье мы разберем, что такое ИИ-инвестирование, рассмотрим его ключевые преимущества и недостатки, а также оценим, где эти технологии работают лучше всего, а где могут подвести. Что такое ИИ инвестирование? ИИ-инвестирование — это использование алгоритмов искусственного интеллекта (AI), готовых нейронных сетей (LLM), моделей машинного обучения для принятия инвестиционных решений. В отличие от традиционных подходов, где решения принимаются на основе ограниченного числа событий, сигналов, индикаторов или интуиции трейдера, AI технологии позволяют обрабатывать огромные потоки данных и гигабайты информации практически в режиме реального времени, что позволяет выявлять скрытые закономерности и создавать модели, которые могут адаптироваться к изменяющимся рыночным условиям. На практике это может выглядеть как автоматизированная торговля на фондовом рынке с учетом динамики тысяч котировок в реальном времени, прогнозирование волатильности биржевых и криптовалютных активов или оценка кредитного риска компаний на основе открытых и закрытых данных. Звучит мощно. А как на самом деле? Сегодня AI-технологии широко используются в финансовой сфере. Ключевой ценностью ИИ является возможность объединять разные источники информации: рыночные данные; финансовую отчетность; новости, обсуждения на форумах и соцсетях, поведенческие сигналы участников рынка. Современные нейронные сети, чаты и боты сегодня облегчают принятие инвестиционных решений тысячам людей. Лично я рассматриваю ИИ-инвестирование пока с осторожностью - как инструмент, который помогает агрегировать информацию, генерирует инсайты и расширяет пространство признаков (feature space) для построения более надежных торговых и инвестиционных стратегий. AI инвестиции: хайп или новая реальность? Если говорить откровенно, значительная часть рынка ИИ-инвестиций сегодня — это хайп. Стартапы, финансовые сервисы и даже крупные брокеры охотно добавляют в свои продукты приставку «AI», создавая иллюзию инновационности. Часто это маркетинговый ход: алгоритм может быть обычной статистической моделью или простым автоматизированным роботом, но из-за слов «AI» продукт воспринимается как прорывной. С точки зрения инвестора, важно уметь различать настоящий AI-инструмент, способный обрабатывать сложные данные и обучаться на них, и маркетинговый «AI», который просто продается как инновация. Большинство платформ, продвигающих себя как AI, на деле используют стандартные предсказательные модели или простые машинные методы без глубокого обучения и без адаптивной логики. Это создает опасность завышенных ожиданий и разочарований для тех, кто ищет быстрый и стабильный доход. Почему инвесторам и аналитикам интересны возможности AI? Потому что некоторые инструменты, позиционирующие себя как "AI" оказались действительно очень полезными для рабочих задач. Для трейдеров, аналитиков и портфельных управляющих AI-инструменты позволяют получить преимущества в таких направлениях как: Скорость и масштаб анализа: алгоритмы могут обработать огромные объемы котировок, новостей и корпоративных отчетов за секунды; Выявление скрытых закономерностей: ИИ способен находить корреляции и паттерны, которые не видны при классическом анализе; Персонализация стратегий: можно создавать адаптивные портфели, учитывая индивидуальные цели и риск-профиль инвестора. Эти возможности делают применение AI технологий в инвестициях привлекательным решением, однако при этом важно сохранять здравый смысл и проверять, действительно ли используемая система анализирует данные на профессиональном уровне, а не просто имитирует таковую. Преимущества AI в биржевой торговле Существуют подтвержденные кейсы использования ИИ в финансовой индустрии: Хедж-фонды: компании вроде Renaissance Technologies используют сложные алгоритмы, включающие машинное обучение для поиска паттернов в котировках и новостях. Их стратегии основываются на ансамблях прогнозов множества AI моделей и дают стабильную доходность даже в волатильные периоды. Алгоритмическая торговля: ИИ применяется для прогнозирования краткосрочной волатильности и оптимизации алгоритмов исполнения сделок, минимизируя проскальзывание при крупных лотах. Фундаментальный анализ на основе данных: некоторые фонды используют NLP (Natural Language Processing) для анализа текстов отчетности компаний и новостей, что позволяет выявлять сигналы изменения рыночной стоимости до публикации официальных отчетов. Недостатки AI и разочарования Если вы рассматриваете AI-инвестиции как решение всех торговых проблем, то смею вас разочаровать: Неавтономность. ИИ боты и нейросети не способны без участия человека находить торговые возможности, оптимальные точки входа и выхода с рынка, разрабатывать прибыльные торговые стратегии. Плохая обучаемость. Даже при активном участии человека, ИИ инструменты не могут уловить сути задачи. В 9 случаях из 10 проще написать код торгового алгоритма самому, чем поручать это нейросетям. Выдуманные факты и искаженная аналитика рынка: ИИ чаты и боты очень часто галлюционируют и выдумают факты, которых не было. Даже при требовании цитировать определенные источники данных. Неэффективный анализ рынка: почти все AI чаты обучены на текстах и книгах из Интернета, в котором доминируют устаревшие подходы к анализу и трейдингу. Слабая точность прогнозов: многие ждали что Transformer-based модели наконец-то научатся прогнозировать временные ряды котировок с высокой точностью. Увы, но рынки слишком сложны и динамичны, и даже самые продвинутые модели не могут гарантировать предсказуемость доходности. Ограничения по данным: ИИ чаты сильно зависят от качества данных. Между тем, доступ к самым ценным корпоративным и рыночным данным закрыт для большинства частных инвесторов, что сводит на нет преимущества анализа больших данных с помощью AI моделей. Опыт показывает, что AI в инвестициях — это всего лишь инструмент, а не волшебная кнопка '$$$'. Без понимания структуры данных, ограничений модели и рыночной динамики использование AI может привести к разочарованию и финансовым потерям. Современные ИИ-инструменты для повышения качества инвестиций Сегодня инвестор может использовать широкий набор AI-инструментов, которые помогают анализировать рынки, выявлять перспективные активы и автоматизировать рутинные задачи. Среди наиболее популярных — ChatGPT, Deepseek, Perplexity, Claude. Каждый из них имеет свои сильные стороны и ограничения. ChatGPT ChatGPT эффективно справляется с обработкой текстовой информации, что делает его полезным инструментом для инвесторов, которым нужно быстро проанализировать финансовые отчеты компаний. Платформа может извлекать ключевые показатели, подготавливать краткие сводки и помогать структурировать данные для последующего анализа. Это ускоряет первичный этап исследований и позволяет сосредоточиться на более глубоких аспектах инвестиционных решений. Однако важно понимать ограничения ChatGPT: модель может предоставлять устаревшие или вымышленные данные, а цифры из отчетов могут быть неточными. Поэтому его лучше использовать как первый слой анализа — для первичной фильтрации информации, выявления потенциально важных моментов и формирования гипотез, которые затем проверяются по официальным источникам. Такой подход позволяет сочетать скорость ИИ с надежностью проверенных данных. Deepseek и Perplexity Deepseek и Perplexity помогают быстро искать и структурировать специализированную информацию в интернете. Они особенно полезны для мониторинга корпоративных новостей, изменений в законодательстве и публикаций о новых финансовых продуктах. Использование этих инструментов позволяет инвестору формировать список потенциальных инвестиционных идей и выявлять ранние сигналы, которые могут оказать влияние на стратегию портфеля. Обе платформы упрощают сбор данных и анализ текстового контента, экономя часы ручной работы. При этом эффективность работы зависит от точной постановки запроса и фильтрации информации: ИИ может предоставлять обширные результаты, однако инвестор сам должен принимать решения, какие данные действительно важны для анализа. Таким образом, Deepseek и Perplexity служат мощными помощниками на этапе подготовки и первичной фильтрации информации. Claude Claude, как более крупная и мощная LLM, используется для моделирования сценариев и проверки инвестиционных гипотез. Например, с помощью Claude можно смоделировать влияние изменения процентной ставки на отдельную отрасль или оценить, как макроэкономические факторы повлияют на портфель. По моему мнению Claude несколько мощнее ChatGPT. Эта модель глубже подходит к задачам анализа сложных ситуаций и генерации идей, которые трудно формализовать в простых алгоритмах. Важно помнить, что все прогнозы остаются гипотетическими: Claude не гарантирует точность и не может предсказать поведение рынка. Ответственность за инвестиционные решения всегда лежит на человеке. Тем не менее, использование Claude позволяет протестировать разные сценарии, проверить устойчивость стратегий и оценить риски в симулированной среде, что делает его ценным инструментом для продвинутого анализа. Visualping Сервис Visualping позволяет отслеживать изменения на ключевых веб-страницах компаний и регуляторов до того, как новости становятся публичными. Сервис особенно полезен для мониторинга страниц для инвесторов, поскольку содержит сводки с пресс-румов, финансовой отчетности, материалов собраний акционеров, информации о продуктах и поставщиках, а также регуляторных порталов. Встроенный в сервис ИИ автоматически объясняет, что изменилось и почему это важно, а структурированные данные и краткие сводки можно отправлять в Slack, Email или интегрировать через HubSpot, Zapier/n8n. Основные пользователи — аналитики, управляющие портфелями и исследователи, которым нужны детализированные и актуальные данные для принятия решений. Fiscal.ai Fiscal.ai — платформа с поддержкой ИИ, предоставляющая анализ финансовых данных через чат-интерфейс, интегрированная с базами S&P Market Intelligence. Инструмент ориентирован на потребности инвесторов и предоставляет проверенные данные по публичным компаниям. Платформа помогает аналитикам ускорять обработку финансовых отчетов, выявлять рыночные возможности и строить предиктивные модели. Использование NLP и алгоритмов машинного обучения позволяет извлекать ключевую информацию из отчетов, документов SEC и новостей, а также отслеживать рыночные настроения, что облегчает прогнозирование реакции инвесторов и движений рынка. Dataminr Платформа Dataminr использует ИИ для обработки огромного объема данных из социальных сетей, новостей и публичных источников, мгновенно уведомляя о событиях, которые могут повлиять на финансовые рынки. Инструмент помогает количественным аналитикам улучшать торговые модели, а руководителям инвестиционных портфелей принимать решения на основе текущих событий. С помощью Dataminr можно отслеживать ранние индикаторы изменений, анализировать настроение рынка по соцсетям и новостям, выявлять риски для портфеля и получать персонализированные уведомления по интересующим активам и параметрам риска. Kavout Сервис Kavout сочетает ИИ и машинное обучение для анализа данных и поддержки инвестиционных решений. Платформа использует уникальную систему рейтингов акций и предоставляет инструменты для управления портфелем и анализа рынка. Инструмент полезен профессиональным инвесторам и количественным аналитикам для выявления перспективных возможностей, оптимизации стратегий и оценки рисков. Kavout предлагает машинное обучение для генерации рекомендаций, набор инструментов для построения факторных портфелей и систему предиктивного скоринга акций от 1 до 9, а также функции бэктестинга и скрининга акций для улучшения тайминга сделок. Bloomberg Terminal Bloomberg Terminal — это одна из самых мощных и комплексных платформ для финансовых специалистов и институциональных инвесторов, обеспечивающая мгновенный доступ к глобальной рыночной информации и аналитике. Платформа объединяет традиционные методы анализа с инструментами на основе искусственного интеллекта, что позволяет пользователям быстро выявлять рыночные паттерны, прогнозировать динамику активов и принимать стратегически обоснованные решения. Платформа Блумберг охватывает все основные классы активов, предоставляя данные о компаниях, ценах, экономических индикаторах и эксклюзивные новостные потоки. Инструменты платформы включают: Мощные возможности моделирования и построения графиков; Количественный анализ; Интеграция со всеми базами данных или Excel для кастомизированной отчетности; Системы оповещений о значимых рыночных событиях. Все это позволяет инвесторам и аналитикам видеть полную картину состояния рынка и мгновенно реагировать на изменения. Кроме того, встроенные коммуникационные функции позволяют обмениваться информацией и анализом напрямую с другими профессионалами индустрии. Особое внимание в Bloomberg Terminal уделено управлению рисками и автоматизации процессов. AI-инструменты платформы помогают анализировать большие массивы данных, выявлять потенциальные угрозы для портфелей и принимать решения на основе актуальной информации. Как с помощью ИИ быстрее анализировать рынок и находить перспективные активы? Одно из ключевых преимуществ AI — скорость и масштаб анализа. Раньше я тратил дни на сбор данных по компаниям и рынкам, теперь это можно сделать за час. Ключевой фактор успеха - четкая постановка задачи в промпте. Вот что можно улучшить с помощью AI инструментов: Ускорить этап скрининга и фильтрации акций: можно быстро выбирать компании по фундаментальным показателям, рыночной капитализации, волатильности и другим критериям; Автоматизировать рутинные задачи: составление таблиц, проверка финансовых коэффициентов, сравнение отчетов за несколько лет; Провести анализ текстовой информации: новости, пресс-релизы, статьи аналитиков — AI быстро выделяет ключевые события, которые могут повлиять на котировки. К примеру, я часто комбинирую ChatGPT с Python-скриптами, чтобы автоматически парсить новости и формировать список компаний с потенциальными растущими трендами. Это экономит массу времени и позволяет сосредоточиться на стратегическом выборе активов, а не на сборе информации. Важный нюанс: AI ускоряет процесс, но не заменяет критическое мышление. Любая рекомендация, сформированная ботом, требует проверки на реальных данных и понимания макроэкономических условий. Как с помощью ИИ понимать, в какие компании и активы не стоит инвестировать? Не менее важная задача — выявление рисков и «красных флагов». AI может помочь находить компании с потенциальными проблемами, анализируя объемные массивы данных, на которые человеку потребовались бы недели. Я обычно использую AI для: Поиска корпоративных рисков: проверка судебных дел, нарушений регуляторов, финансовых аномалий в отчетности; Анализа новостного фона: негативные публикации о руководстве или продуктах компании часто опережают снижение котировок; Выявления несоответствий в данных: например, если показатели роста прибыли компании кажутся необычно стабильными, AI может помочь выявить несоответствия между разными источниками. С помощью AI можно оперативно фильтровать активы с потенциально высокой вероятностью просадок, снижая риск портфеля. Однако важно не забывать: ИИ лишь выявляет сигналы, однако их интерпретация всегда остается за человеком. Без понимания бизнес-модели компании и рыночной динамики любые автоматические выводы могут быть ошибочными. Автоматизация портфельного анализа через ИИ Автоматизация портфельного анализа — один из наиболее востребованных сценариев применения AI в инвестициях. Современные инструменты позволяют не только ускорить обработку данных, но и выявлять сложные зависимости между активами, которые традиционные методы упускают из виду. Например, хедж-фонды используют алгоритмы машинного обучения для: построения сложных корреляционных моделей между десятками и сотнями инструментов; выявления сочетаний активов, которые увеличивают риск или снижают диверсификацию; динамической оптимизации веса активов с учетом текущей волатильности и рыночных шоков. В отличие от классических подходов, AI может обнаруживать скрытые паттерны, например, повторяющиеся взаимосвязи между компаниями из разных секторов или активами, которые ведут себя синхронно в стрессовых условиях. Это позволяет инвестиционным командам заранее корректировать портфель и снижать вероятность неожиданных просадок. ИИ для мониторинга новостей и их влияния на портфель AI-технологии позволяют анализировать огромные массивы текстовой информации практически в реальном времени. Это критически важно для инвесторов, поскольку новости и события могут мгновенно менять рыночные настроения. Системы NLP (Natural Language Processing) и модели на базе LLM применяются для: анализа пресс-релизов компаний и официальных отчетов; мониторинга медиапубликаций, включая локальные блоги и форумы; оценки тональности новостей и выявления сигналов для изменения позиции в портфеле. Например, алгоритмы могут автоматически сигнализировать о кадровых перестановках, судебных исках или изменении спроса на продукцию компании. Одновременно с этим AI помогает выявлять тенденции на уровне отраслей или регионов, что позволяет принимать более взвешенные решения. Ключевой момент: AI не предсказывает рынок. Он помогает фильтровать поток информации, выявлять сигналы и ускорять анализ, но оценку и решение о действиях принимает человек. ИИ-ассистенты для обучения инвестированию AI активно применяется для обучения и тренировки аналитических навыков в инвестициях. Здесь полезны не только текстовые модели вроде ChatGPT, но и инструменты для симуляций и анализа больших массивов данных. Использование AI включает: Разбор исторических стратегий: моделирование результатов различных подходов, выявление причин успеха или провала; Симуляции рыночных сценариев: оценка влияния изменения процентных ставок, колебаний цен на сырье или макроэкономических шоков на портфель; Анализ финансовой отчетности: быстрое выделение ключевых показателей, долговой нагрузки и потенциальных рисков компании. Такой подход позволяет тренироваться на реальных данных без риска потерь, формируя понимание структуры рынка, выявление рисков и возможности для улучшения стратегий. AI ускоряет обучение, делая процесс интерактивным и практико-ориентированным. Персонализированные инвестиционные стратегии через ИИ Персонализация стратегий — одна из сильных сторон AI-инструментов. Сегодня многие платформы используют машинное обучение для создания адаптивных портфелей, учитывающих индивидуальные цели инвестора, допустимый риск и горизонты вложений. На практике это реализуется через несколько подходов: Анализ предпочтений инвестора и поведенческих паттернов. Например, AI может оценить склонность к риску на основе истории сделок или реакций на волатильность рынка. Динамическая адаптация портфеля. Алгоритмы периодически перераспределяют активы, оптимизируя доходность и минимизируя риск в условиях меняющихся рыночных условий. Сценарное моделирование. AI формирует прогнозы на основе возможных макроэкономических событий или отраслевых изменений, позволяя инвестору оценить, как изменится портфель при разных сценариях. Компании, предоставляющие robo-advisory сервисы, уже используют такие инструменты. Например, платформы Wealthfront и Betterment применяют адаптивные алгоритмы для автоматического ребалансирования портфелей, учитывая индивидуальный риск-профиль и налоговые последствия. Важно понимать, что персонализация через AI — это инструмент повышения эффективности, а не гарантия прибыли. Она помогает более точно соответствовать стратегии целям инвестора, но окончательные решения остаются за человеком. Боты с ИИ - автоматические сервисы управления капиталом Автоматические инвестиционные боты на базе AI всегда привлекают особое внимание инвесторов. Считается, что они способны лучше людей обрабатывать большие объемы данных, принимать решения о покупке и продаже активов и проводить автоматическое ребалансирование портфеля. Я знаю много успешных торговых ботов и вы наверное тоже. Но если вы решили доверить свой капитал боту, то советую учесть следующее: Большинство ботов на рынке неэффективны. По оценкам независимых исследований, около 9 из 10 публичных AI-ботов показывают вероятность выигрыша на уровне случайного подбрасывания монетки — около 50%. Их алгоритмы были подстроены под историю, и уже не эффективны на текущих рыночных данных. Зависимость от качества данных. Боты полностью зависят от источников информации. Сбои в котировках, связи, серверах, недостаточные или искаженные данные приводят к сбою алгоритмов бота и неправильным решениям, особенно при высокочастотной торговле. Отсутствие понимания макроэкономики и корпоративной стратегии. AI-боты не способны оценить фундаментальные риски, например финансовые махинации компании, скрытые обязательства или изменения отраслевой политики. Примитивность и уязвимость к экстремальным событиям. Как правило, разработчики торговых ботов не утруждают себя длительному бэктестингу и моделированию поведения бота в реально экстремальных условиях. Любые неожиданные шоки на рынке (геополитика, санкции, технологические сбои) ведут к тому, что алгоритмы таких ботов просто сливают капитал. Проблема доверия и мошенничества. На рынке много недобросовестных сервисов: обещания высокой доходности и автоматического заработка часто используются для привлечения инвесторов, при этом реальная эффективность крайне низка. Несмотря на эти ограничения, есть успешные кейсы профессионального применения AI-ботов: High-frequency trading (HFT): хедж-фонды используют ботов для сверхкоротких сделок, где AI оптимизирует исполнение и минимизирует проскальзывание. Такие системы требуют сложной инфраструктуры и прямого подключения к биржам. Робо-советники для частных инвесторов: как уже было сказано выше, есть AI-платформы для автоматической ребалансировки портфеля, учитывая риск-профиль клиента и налоговые последствия. Эффективность здесь выше, чем у случайных ботов, однако доходность ограничена и не гарантирована. Сигнальные системы для институциональных инвесторов: некоторые фонды интегрируют AI для анализа новостей и объемов сделок, получая дополнительные сигналы к управлению капиталом. Это помогает выявлять аномалии на рынке раньше конкурентов. AI для автоматизированного хеджирования рисков: компании используют алгоритмы для коррекции позиций при изменении волатильности или ликвидности, снижая потенциальные потери в стрессовых условиях. Вывод один: AI-боты могут быть полезными инструментами, но они не заменяют экспертизу и критическое мышление. Их эффективность сильно зависит от качества алгоритмов, источников данных и понимания инвестором рыночной динамики. Любое слепое доверие к «волшебным» AI-сервисам практически всегда приводит к разочарованию или финансовым потерям. Как выбрать надежный AI-сервис для частного инвестора? Рынок AI-сервисов для инвестиций сегодня перегружен предложениями — от бесплатных приложений до платных платформ с обещаниями высокой доходности. Для частного инвестора важно понимать, что не каждый сервис действительно способен помочь принимать взвешенные решения, а большинство публичных ботов скорее имитируют «умные» стратегии. Первое, на что стоит обратить внимание - прозрачность алгоритмов. Надежный сервис обычно подробно описывает, какие данные используются для анализа, на каких моделях построена оценка риска и доходности, и как принимаются решения о покупке или продаже активов. Если компания скрывает алгоритмы и обещает гарантированную прибыль, это уже тревожный сигнал. Второй критерий - качество и актуальность данных. Многие бесплатные сервисы или боты используют устаревшие наборы данных или открытые источники с ограниченной точностью. Хороший AI-сервис интегрируется с актуальными биржевыми данными, финансовыми отчетами компаний и новостными потоками. Это особенно важно для инвестиций в акции и ETF, где задержка даже в несколько часов может стоить нескольких процентов доходности. Третий момент - реальные показатели доходности и риск-менеджмента. Надежные платформы публикуют историю работы алгоритмов с пояснением методологии расчета доходности, волатильности и просадки стратегий. Часто используется симуляция на исторических данных (бэктестинг) с различными сценариями, чтобы показать, как стратегия могла вести себя при кризисах или резких рыночных колебаниях. Кроме того, стоит учитывать: Лицензии и регулирование. Проверяйте, имеет ли компания официальные разрешения на работу с финансовыми инструментами и соблюдает ли требования регуляторов; Комиссии и скрытые платежи. Некоторые платформы рекламируют «бесплатные» услуги, но берут высокий процент с прибыли или скрытые комиссии за сделки; Отзывы и репутация. Важно изучать независимые отзывы, но при этом отделять реальные кейсы от маркетинговых историй. Практический подход к выбору сервиса может выглядеть так: Сначала протестировать бесплатные версии или демо-режимы, чтобы оценить интерфейс, скорость обработки данных и качество сигналов; Сравнить несколько платформ по прозрачности алгоритмов, источникам данных и истории доходности; Не доверять обещаниям «100% гарантируемой прибыльности» — реальная цель AI-сервисов для частного инвестора — уменьшение риска и ускорение анализа, а не магическое предсказание рынка. В итоге, грамотный подход к выбору AI-сервиса позволяет частным инвесторам получать реальные преимущества: ускорять анализ информации, отслеживать риски и находить перспективные активы, одновременно снижая вероятность ошибок из-за человеческого фактора. Но важно помнить, что никто и ничто не заменит базовое понимание рынка и умение принимать взвешенные решения. Заключение ИИ-инвестирование — это новая парадигма, новый инструмент для повышения эффективности анализа рынка и потенциальной доходности инвестиций. Никакой магии в AI нет, как и гарантированного дохода. ИИ-инструменты ускоряют обработку данных, помогают выявлять скрытые зависимости между активами, отслеживать новости и риски, а также поддерживают персонализацию инвестиционных стратегий. Тем не менее, они не способны предугадывать рынок и финальные решения всегда остаются за человеком. При этом ИИ может значительно расширить возможности частного и институционального инвестора. Искусственный интеллект позволяет анализировать огромные объемы информации, выявлять ранние сигналы изменений на рынке, тестировать сценарии и гипотезы, а также автоматизировать рутинные задачи, освобождая время для стратегического мышления. Грамотное сочетание традиционного анализа с современными AI-инструментами дает реальное преимущество, если использовать их как вспомогательный, а не заменяющий фактор. ### NPV (Net Present Value, Чистая приведенная стоимость) Чистая приведенная стоимость представляет собой разность между текущей стоимостью будущих денежных поступлений от проекта и величиной первоначальных инвестиций. Основная идея NPV базируется на фундаментальном принципе: деньги сегодня стоят больше, чем те же деньги завтра. Это обусловлено тремя факторами: Инфляция постепенно снижает покупательную способность денег; Альтернативная стоимость капитала - есть множество проектов, куда можно инвестировать деньги и получить доход, как самое простое решение - депозиты и облигации; Риск неполучения будущих платежей, который всегда присутствует в любых инвестициях. Когда опытные инвесторы планируют вложить деньги в акции или бизнес, им недостаточно знать, какую они получат прибыль. Им еще важно понимать, когда именно придут эти деньги, какова их текущая стоимость с учетом риска и альтернативных возможностей размещения капитала, и оправдывают ли будущие потоки первоначальные затраты на разработку и внедрение системы. NPV дает четкий численный ответ на все эти вопросы. Математическая основа расчета NPV Формула чистой приведенной стоимости выглядит следующим образом: NPV = Σ [CFt / (1 + r)^t] - I₀ где: CFt — денежный поток в период t; r — ставка дисконтирования; t — номер периода; I₀ — первоначальные инвестиции. Математическая основа расчета NPV опирается на принцип дисконтирования будущих денежных потоков. Каждый будущий денежный поток CFt приводится к текущему моменту с учетом ставки дисконтирования r. Затем все дисконтированные значения суммируются, и из полученного результата вычитаются первоначальные инвестиции I₀. Если NPV>0, проект считается экономически целесообразным, так как он генерирует прирост стоимости для инвестора. Если NPV<0, проект убыточен и приведет к потере капитала. В случае NPV=0 проект лишь окупает вложения, но не приносит дополнительной прибыли. Таким образом, NPV служит ключевым показателем при принятии инвестиционных решений, позволяя сопоставлять разные проекты и выбирать наиболее выгодный с точки зрения максимизации стоимости бизнеса. Денежные потоки: что считать и как считать Моделирование текущих и будущих денежных потоков - непростая задача. В теории все просто: достаточно взять все притоки и оттоки по проекту. Но на практике сразу же возникают десятки нюансов: Нужно ли учитывать амортизацию, которая не связана с движением денег напрямую, но влияет на налоговую нагрузку? Как корректно отразить налоги от процентных платежей? Что делать с косвенными расходами, которые сложно напрямую привязать к конкретному проекту? Такие вопросы неизбежно встают перед аналитиком, и именно здесь проявляется качество финансовой модели. От того, насколько грамотно и последовательно определены денежные потоки, зависит достоверность итогового значения NPV и, в конечном счете, обоснованность инвестиционного решения. Я придерживаюсь принципа инкрементальности: в расчет NPV должны входить только те денежные потоки, которые непосредственно связаны с реализацией проекта. Если затраты были бы понесены в любом случае, независимо от принятого решения, они не релевантны для анализа. Например, когда я оцениваю целесообразность разработки нового торгового алгоритма, я не включаю в расчет зарплату постоянных сотрудников исследовательского отдела — эти издержки фиксированы. Но я обязательно учитываю стоимость привлечения внешних консультантов, покупку дополнительных данных и затраты на вычислительные мощности для бэктестинга. Особое внимание стоит уделить терминальной стоимости — оценке стоимости проекта за пределами явного прогнозного горизонта. В долгосрочных проектах именно терминальная стоимость часто составляет львиную долю NPV. Существует несколько подходов к ее расчету: Модель Гордона с постоянным темпом роста; Мультипликаторы сопоставимых компаний; Ликвидационная стоимость активов. Я обычно использую консервативные оценки и провожу анализ чувствительности, чтобы понять, насколько решение зависит от предположений о терминальной стоимости. Ставка дисконтирования: выбор и обоснование Выбор ставки дисконтирования — пожалуй, самый важный и одновременно самый субъективный элемент анализа NPV. Теоретически ставка дисконтирования должна отражать стоимость капитала проекта с поправкой на риск. Практически это число часто становится предметом жарких дискуссий и манипуляций. Так, к примеру: В корпоративных финансах для определения ставки дисконтирования традиционно используется WACC (средневзвешенная стоимость капитала), которая учитывает стоимость собственного и заемного капитала в структуре финансирования компании; Для собственного капитала применяется модель CAPM, связывающая требуемую доходность с безрисковой ставкой, бета-коэффициентом актива и рыночной премией за риск. Однако я скептически отношусь к слепому применению CAPM в практической работе — модель построена на допущениях, которые редко выполняются в реальности. В своей практике при оценке торговых стратегий я использую более прагматичный подход. Ставка дисконтирования формируется из трех компонентов: Безрисковой ставки (обычно доходность государственных облигаций соответствующей срочности); Премии за рыночный риск; Премии за специфический риск конкретной стратегии. Последний компонент я оцениваю на основе исторической волатильности доходности, максимальной просадки и коэффициента Шарпа. Чем хуже показатели с поправкой на риск, тем выше должна быть ставка дисконтирования. Важный момент: ставка дисконтирования не обязательно постоянна во времени. Для проектов с меняющимся профилем риска имеет смысл использовать различные ставки для разных периодов. Например, стартап на ранней стадии несет значительно больший риск, чем та же компания после выхода на операционную прибыльность. Соответственно, денежные потоки первых лет должны дисконтироваться по более высокой ставке. Временная структура и периодичность потоков В базовой формуле NPV подразумевается, что денежные потоки происходят в конце каждого периода. На практике паттерны поступления и выбытия денежных средств могут быть гораздо сложнее. Торговая стратегия может генерировать ежедневные потоки, венчурный проект получает финансирование траншами, недвижимость приносит ежемесячную арендную плату. Существует модификация формулы для случая, когда денежные потоки происходят в начале периода (annuity due), а не в конце. В этом случае каждый платеж дисконтируется на один период меньше, что увеличивает итоговое значение NPV. Разница может показаться незначительной, но на длинных горизонтах и при высоких ставках дисконтирования эффект становится существенным. Для потоков с произвольной периодичностью я использую точное количество дней между платежами и непрерывное дисконтирование. Формула трансформируется в: NPV = Σ [CFt × e^(-r×t)] где t выражается в долях года. Этот подход особенно актуален при анализе опционных стратегий и других деривативов, где время измеряется с точностью до часа или даже минуты. Интерпретация результатов NPV Логика принятия решений на основе NPV проста и элегантна: Если NPV положительна, проект создает стоимость и его следует принять; Если NPV отрицательна, проект разрушает стоимость и от него лучше отказаться; При сравнении взаимоисключающих альтернатив выбирается вариант с максимальным NPV. Однако за этой простотой скрывается несколько важных нюансов. Величина NPV сама по себе не говорит об эффективности использования капитала. Проект с NPV в 10 миллионов при инвестициях в 100 миллионов может быть менее привлекательным, чем проект с NPV в 5 миллионов при инвестициях в 20 миллионов. Для учета этого фактора существует показатель индекса рентабельности (PI = NPV/I₀), который я всегда рассчитываю параллельно с NPV. Еще один аспект — NPV не учитывает ограничения на капитал. В реальности инвестор редко имеет неограниченный доступ к финансированию. При наличии бюджетных ограничений задача выбора проектов превращается в оптимизационную задачу: максимизировать суммарный NPV при заданном лимите инвестиций. Это классическая задача динамического программирования или целочисленной оптимизации, которую я решаю с помощью специализированных алгоритмов. Пороговые значения и зоны неопределенности В практической работе я редко полагаюсь на точечную оценку NPV. Вместо этого я строю распределение возможных значений с учетом неопределенности входных параметров. Метод Монте-Карло позволяет симулировать тысячи сценариев развития событий, получая распределение NPV и оценивая вероятность положительного исхода. Допустим, я разрабатываю новую торговую стратегию. Исторический бэктест показывает определенную доходность, но я понимаю, что будущие результаты неизбежно будут отличаться. Я моделирую распределение будущих доходностей на основе исторической волатильности с учетом толстых хвостов распределений и автокорреляции в данных. Для каждой симуляции рассчитываю NPV стратегии. В результате получаю не одно число, а целое распределение: медианное значение, доверительные интервалы, вероятность убытка. Такой подход особенно ценен при принятии решений в условиях высокой неопределенности. Если 95-й перцентиль распределения NPV все еще положителен, это сильный сигнал в пользу проекта. Если же медианное значение положительно, но с 30-процентной вероятностью NPV уходит в отрицательную зону, решение требует более тщательного анализа. Практическое применение NPV в количественных стратегиях Теория NPV разрабатывалась для оценки корпоративных инвестиционных проектов, но принципы применимы гораздо шире. Я постоянно использую концепцию приведенной стоимости при разработке и оптимизации торговых алгоритмов, и хочу поделиться несколькими практическими кейсами. Оценка торговых стратегий через призму NPV Когда я разрабатываю новую количественную стратегию, встает вопрос: стоит ли инвестировать время и ресурсы в ее дальнейшую разработку и внедрение? Бэктест на исторических данных дает определенную картину доходности, но как перевести это в решение о запуске в продакшен? Я рассматриваю торговую стратегию как инвестиционный проект с первоначальными затратами (разработка, тестирование, инфраструктура) и ожидаемыми денежными потоками (торговая прибыль за вычетом издержек). Ставку дисконтирования определяю исходя из доходности стратегии с учетом риска. Если стратегия имеет высокий кэффициент Шарпа и низкую волатильность, ставка дисконтирования близка к безрисковой. Если стратегия агрессивна и подвержена существенным просадкам, добавляю премию за риск. Ключевой момент — учет деградации стратегии со временем. В реальном мире практически любая торговая аномалия постепенно исчезает по мере того, как ее эксплуатируют все больше других участников рынка. Я закладываю постепенное снижение альфы стратегии, моделируя его как экспоненциальное затухание. Это делается для корректной оценки чистой приведенной стоимости (NPV): если предположить, что стратегия будет работать вечно с текущей эффективностью, расчет окажется завышенным. Оптимизация портфеля стратегий NPV помогает не только в оценке отдельных стратегий, но и в формировании оптимального портфеля торговых алгоритмов. Задача аналогична классической портфельной оптимизации Марковица, но с учетом временной стоимости денег и ограничений на капитал. У меня есть множество потенциальных стратегий, каждая со своим профилем риска и доходности, требованиями к капиталу и операционными издержками. Цель — сформировать портфель, максимизирующий суммарный NPV при заданных ограничениях на общий капитал и допустимый риск. Корреляции между стратегиями играют важную роль: диверсификация снижает общий риск портфеля, что позволяет использовать более низкую ставку дисконтирования. Я решаю эту задачу численными методами, используя квадратичное программирование с ограничениями. Целевая функция — суммарный NPV портфеля. Ограничения включают лимит на общий используемый капитал, требования к минимальной и максимальной аллокации на отдельную стратегию, ограничения на волатильность и максимальную просадку портфеля. Результат — оптимальное распределение капитала между стратегиями, максимизирующее создаваемую стоимость. Решения о прекращении убыточных стратегий NPV полезна не только для запуска новых проектов, но и для решений об остановке работающих систем. Если стратегия начинает показывать убытки, возникает вопрос: это временная просадка или стратегия перестала работать в принципе? Стоит ли продолжать торговлю в надежде на восстановление или остановить и сохранить капитал? Я использую прогнозный анализ чистой приведенной стоимости (forward-looking NPV). На основе текущей динамики доходности обновляю прогноз будущих денежных потоков стратегии. Если NPV оставшихся потоков становится отрицательным с высокой вероятностью, это сигнал к остановке. Важно не поддаваться ошибке невозвратных издержек (sunk cost fallacy) — прошлые инвестиции в разработку стратегии не должны влиять на решение. Релевантны только будущие потоки. Этот подход помог мне несколько раз избежать существенных потерь. В одном случае стратегия на возврат к среднему (mean reversion) на товарных фьючерсах начала показывать убытки. Исторически после просадок она восстанавливалась, но мой анализ NPV с учетом изменившейся структуры рынка показал отрицательные перспективы. Я остановил стратегию, и последующие месяцы подтвердили правильность решения — просадка углубилась и не восстановилась. Ограничения метода NPV Несмотря на всю свою мощь, NPV не лишена недостатков. Понимание ограничений метода не менее важно, чем знание его преимуществ. Статичность Классический NPV анализ предполагает, что решение принимается сейчас и в дальнейшем не пересматривается. В реальности многие проекты обладают операционной гибкостью: можно расширить масштаб при успехе, свернуть при неудаче, отложить запуск, переключиться на альтернативное использование активов. Эта встроенная опциональность имеет стоимость, которую базовый NPV не улавливает. Для учета гибкости используется метод реальных опционов, расширяющий NPV подход. Сложность прогнозирования NPV требует детальных прогнозов денежных потоков на годы вперед, что для многих проектов практически невозможно. Особенно это касается инновационных направлений без исторических аналогов. Точность NPV не может превышать точность входящих в нее прогнозов, а в условиях высокой неопределенности ценность точечных оценок сомнительна. Заимствование капитала по безрисковой ставке Фундаментальная предпосылка NPV — возможность заимствования и кредитования по безрисковой ставке. Для публичных компаний с доступом к финансовым рынкам это разумное допущение. Для частных проектов или в условиях финансовых ограничений предпосылка может не выполняться, и NPV перестает быть корректным критерием. Выводы Чистая приведенная стоимость остается золотым стандартом оценки инвестиционных проектов, несмотря на появление более сложных методов. Причина проста: NPV напрямую измеряет то, что нас интересует — создание или разрушение стоимости инвестируемых денег. Положительная NPV означает, что проект увеличивает благосостояние инвестора, отрицательная — уменьшает. В своей практике количественного анализа я использую NPV как базовый фреймворк для принятия инвестиционных решений. Это относится к оценке торговых стратегий, оптимизации портфелей, анализу инфраструктурных инвестиций в вычислительные мощности и данные. Я всегда дополняю NPV анализ сценарным планированием, анализом чувствительности и здравым смыслом. Если NPV показывает одно, а интуиция и качественные факторы говорят другое, стоит разобраться, что именно не так с моделью. Важно помнить, что NPV — это инструмент поддержки решений, а не замена человеческого суждения. Модель хороша настолько, насколько хороши заложенные в нее предположения. Критическое мышление, постоянная проверка гипотез и готовность признавать ошибки в прогнозах важнее безупречного владения математическим аппаратом. ### Прогнозирование вероятности дефолта через логистическую регрессию Прогнозирование вероятности дефолта — одна из ключевых задач в управлении кредитными рисками, которая помогает банкам, инвестиционным компаниям и бизнесу принимать более взвешенные решения. Существует множество инструментов для таких прогнозов, хотя логистическая регрессия - пожалуй, наиболее популярный. Она позволяет на основе набора факторов (например, дохода клиента, кредитной истории, уровня долговой нагрузки) оценить вероятность того, что заемщик не сможет выполнить свои обязательства. В этой статье мы разберем, как работает логистическая регрессия в контексте кредитного скоринга, почему ее до сих пор активно применяют даже при наличии более сложных моделей, и как на практике можно применять результаты логрегрессии для анализа и снижения финансовых рисков. Математическая природа вероятности дефолта Вероятность дефолта не является статичной величиной. Она представляет собой условную вероятность, зависящую от макроэкономических факторов, специфических характеристик заемщика и временного горизонта. В математических терминах мы моделируем: P(D=1|X,t) где: D - индикатор дефолта; X - вектор предикторов; t - время. Логистическая регрессия решает эту задачу через логит-трансформацию, которая преобразует ограниченную область вероятностей [0,1] в неограниченное пространство действительных чисел. Приведенная ниже формула учитывает, что даже небольшие изменения в финансовых показателях могут привести к существенным изменениям в кредитном риске. P(D=1) = 1/(1 + exp(-(β₀ + β₁X₁ + ... + βₖXₖ))) Ключевая особенность данного метода состоит в том, что коэффициенты βᵢ в логистической регрессии имеют прямую экономическую интерпретацию. Экспонента коэффициента exp(βᵢ) показывает, во сколько раз изменяется отношение шансов (odds ratio) при увеличении соответствующего предиктора на единицу. Это делает модель не только предсказательным инструментом, но и средством понимания драйверов кредитного риска. Отличия от линейной регрессии и преимущества для моделирования дефолтов Линейная регрессия плохо подходит для моделирования дефолта. Помимо очевидных проблем с интерпретацией (предсказанные "вероятности" могут быть отрицательными или больше единицы), линейная регрессия нарушает базовые предположения о распределении ошибок. Остатки в модели дефолта следуют биномиальному, а не нормальному распределению. Логистическая регрессия элегантно решает эти проблемы через использование максимального правдоподобия вместо метода наименьших квадратов. Это позволяет корректно работать с биномиальным распределением и обеспечивает асимптотически эффективные оценки параметров. Более того, логистическая функция имеет S-образную форму, которая лучше отражает реальное поведение вероятности дефолта - медленные изменения в зонах низкого и высокого риска, и быстрые изменения в промежуточной зоне. Еще одним важным преимуществом является робастность к выбросам в предикторах. В отличие от линейной регрессии, где единичный экстремальный случай может существенно исказить всю модель, логистическая регрессия через логит-трансформацию "сжимает" влияние экстремальных значений на предсказанную вероятность. Подготовка данных и инжиринг признаков При создании модели логрегрессии я рекомендую делать сначала винсоризацию. Эта техника ограничивает экстремальные значения на заданном уровне, например, 1-го и 99-го процентилей. В отличие от других техник удаления выбросов, винсоризация сохраняет информацию о том, что наблюдение было экстремальным, однако предотвращает доминирование единичных наблюдений над всей моделью. Для финансовых коэффициентов также эффективна логарифмическая трансформация, которая стабилизирует дисперсию и делает распределения более симметричными. import numpy as np import pandas as pd pd.set_option('display.expand_frame_repr', False) from sklearn.preprocessing import StandardScaler from sklearn.model_selection import StratifiedKFold from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score, classification_report import matplotlib.pyplot as plt import seaborn as sns np.random.seed(42) # Генерация данных n_samples = 10000 n_features = 15 # Корреляционная структура correlation_structure = np.array([ [1.0, -0.6, 0.4, -0.3, 0.2, -0.1, 0.3, -0.2, 0.1, 0.0, -0.2, 0.1, 0.0, -0.1, 0.2], [-0.6, 1.0, -0.5, 0.4, -0.3, 0.2, -0.4, 0.3, -0.1, 0.1, 0.3, -0.2, 0.1, 0.2, -0.3], [0.4, -0.5, 1.0, -0.2, 0.1, 0.0, 0.2, -0.1, 0.0, -0.1, -0.1, 0.0, -0.1, 0.0, 0.1], [-0.3, 0.4, -0.2, 1.0, -0.7, 0.3, -0.3, 0.2, 0.0, 0.1, 0.2, -0.1, 0.0, 0.1, -0.2], [0.2, -0.3, 0.1, -0.7, 1.0, -0.4, 0.2, -0.1, 0.0, -0.1, -0.1, 0.1, 0.0, -0.1, 0.1], [-0.1, 0.2, 0.0, 0.3, -0.4, 1.0, -0.1, 0.0, 0.1, 0.0, 0.1, 0.0, 0.1, 0.0, -0.1], [0.3, -0.4, 0.2, -0.3, 0.2, -0.1, 1.0, -0.5, 0.1, 0.0, -0.2, 0.1, 0.0, -0.1, 0.2], [-0.2, 0.3, -0.1, 0.2, -0.1, 0.0, -0.5, 1.0, 0.0, 0.1, 0.2, -0.1, 0.0, 0.1, -0.1], [0.1, -0.1, 0.0, 0.0, 0.0, 0.1, 0.1, 0.0, 1.0, -0.3, 0.0, 0.2, -0.1, 0.0, 0.0], [0.0, 0.1, -0.1, 0.1, -0.1, 0.0, 0.0, 0.1, -0.3, 1.0, 0.1, -0.2, 0.1, 0.0, 0.0], [-0.2, 0.3, -0.1, 0.2, -0.1, 0.1, -0.2, 0.2, 0.0, 0.1, 1.0, -0.4, 0.2, 0.1, -0.1], [0.1, -0.2, 0.0, -0.1, 0.1, 0.0, 0.1, -0.1, 0.2, -0.2, -0.4, 1.0, -0.3, 0.0, 0.1], [0.0, 0.1, -0.1, 0.0, 0.0, 0.1, 0.0, 0.0, -0.1, 0.1, 0.2, -0.3, 1.0, -0.2, 0.0], [-0.1, 0.2, 0.0, 0.1, -0.1, 0.0, -0.1, 0.1, 0.0, 0.0, 0.1, 0.0, -0.2, 1.0, -0.1], [0.2, -0.3, 0.1, -0.2, 0.1, -0.1, 0.2, -0.1, 0.0, 0.0, -0.1, 0.1, 0.0, -0.1, 1.0] ]) mean_values = np.array([15.2, 0.08, 1.4, 0.12, 0.25, 2.1, 0.35, 0.18, 45.2, 12.5, 0.42, 8.7, 0.15, 0.28, 1.8]) std_values = np.array([8.5, 0.15, 0.8, 0.18, 0.22, 1.2, 0.28, 0.12, 25.3, 8.2, 0.25, 4.2, 0.08, 0.15, 0.9]) # Многомерное нормальное распределение raw_features = np.random.multivariate_normal( mean_values, np.outer(std_values, std_values) * correlation_structure, n_samples ) feature_names = [ 'total_assets_log', 'debt_to_equity', 'current_ratio', 'roa', 'roe', 'interest_coverage', 'quick_ratio', 'gross_margin', 'days_sales_outstanding', 'inventory_turnover', 'asset_turnover', 'times_interest_earned', 'net_margin', 'debt_service_coverage', 'working_capital_ratio' ] df_raw = pd.DataFrame(raw_features, columns=feature_names) # Подрезаем отрицательные и нулевые значения перед логарифмом df_raw['total_assets_log'] = df_raw['total_assets_log'].clip(lower=1e-3) # Целевая переменная с долей дефолтов logit_scores = ( -3.3 - np.log(4) + -1.8 * df_raw['debt_to_equity'] + 1.2 * df_raw['roa'] + 0.8 * df_raw['current_ratio'] + -0.6 * (df_raw['debt_to_equity'] ** 2) + 0.4 * df_raw['interest_coverage'] + -0.3 * df_raw['days_sales_outstanding'] / 30 + 0.5 * ( 0.3 * df_raw['roa'] + 0.2 * df_raw['current_ratio'] - 0.4 * df_raw['debt_to_equity'] + 0.1 * np.log(df_raw['total_assets_log']) ) + np.random.normal(0, 0.3, n_samples) # шум ) probabilities = 1 / (1 + np.exp(-logit_scores)) probabilities = np.clip(probabilities, 0, 1) default_indicator = np.random.binomial(1, probabilities, n_samples) df_raw['default'] = default_indicator print(f"Размер датасета: {df_raw.shape}") print(f"Доля дефолтов: {df_raw['default'].mean():.3f}") # Винсоризация def winsorize_features(df, columns, lower=0.01, upper=0.99): df_processed = df.copy() for col in columns: lower_bound = df[col].quantile(lower) upper_bound = df[col].quantile(upper) df_processed[col] = np.clip(df[col], lower_bound, upper_bound) return df_processed features_to_winsorize = [col for col in df_raw.columns if col != 'default'] df_processed = winsorize_features(df_raw, features_to_winsorize) print(f"\nСтатистика после винсоризации:") print(df_processed[features_to_winsorize].describe()) Размер датасета: (10000, 16) Доля дефолтов: 0.075 Статистика после винсоризации: total_assets_log debt_to_equity current_ratio roa roe interest_coverage quick_ratio gross_margin days_sales_outstanding inventory_turnover asset_turnover times_interest_earned net_margin debt_service_coverage working_capital_ratio count 10000.000000 10000.000000 10000.000000 10000.000000 10000.000000 10000.000000 10000.000000 10000.000000 10000.000000 10000.000000 10000.000000 10000.000000 10000.000000 10000.000000 10000.000000 mean 15.343710 0.081235 1.390704 0.122098 0.251567 2.100822 0.349515 0.180087 45.581052 12.540763 0.423866 8.687735 0.149811 0.279994 1.790421 std 8.137397 0.147711 0.789399 0.174781 0.213747 1.154943 0.275118 0.118326 25.070311 8.084325 0.243724 4.128946 0.078343 0.150229 0.891202 min 0.001000 -0.272165 -0.406898 -0.293800 -0.246702 -0.602735 -0.301610 -0.101459 -13.121770 -6.989811 -0.159074 -0.864134 -0.034982 -0.081429 -0.297719 25% 9.541722 -0.019146 0.846100 -0.000808 0.104604 1.300259 0.164783 0.098312 28.513061 6.943660 0.256751 5.813461 0.096294 0.176961 1.180576 50% 15.251600 0.080655 1.391257 0.120752 0.252355 2.099945 0.348699 0.182548 45.424519 12.596320 0.421500 8.676622 0.151273 0.279310 1.790160 75% 20.892387 0.183095 1.942370 0.244196 0.396721 2.908824 0.538779 0.260661 62.603809 18.157629 0.593434 11.513833 0.203931 0.384743 2.398039 max 34.933807 0.421799 3.224088 0.530462 0.770434 4.859833 1.009755 0.459612 105.576739 31.669739 1.007291 18.704949 0.337978 0.636137 3.889395 Представленный код демонстрирует генерацию реалистичного датасета финансовых показателей с корреляционной структурой, характерной для реальных компаний. Винсоризация ограничивает экстремальные значения на уровне 1-го и 99-го процентилей, что предотвращает доминирование выбросов над моделью, сохраняя при этом информацию о потенциальных рисках дефолта. Профессиональная обработка данных включает не только статистическую нормализацию, но и создание условий взаимодействий между переменными, которые могут выявить скрытые паттерны риска. Например, низкая ликвидность может быть опасной только при высокой долговой нагрузке, что математически выражается через произведение соответствующих коэффициентов. Создание значимых производных признаков Популярные финансовые коэффициенты в кредитном моделировании не всегда отражают полную картину. И гораздо более информативными оказываются их комбинации и производные метрики, отражающие различные аспекты финансового здоровья компании. И здесь на помощь приходят временные признаки. Тренды в ключевых показателях часто более предсказательны, чем их абсолютные значения. Компания с ухудшающейся рентабельностью, даже если она пока остается положительной, представляет больший риск, чем стабильно убыточная, но с улучшающейся динамикой. # Создание производных признаков для повышения качества модели def create_advanced_features(df): """ Создание сложных финансовых индикаторов, используемых в профессиональных кредитных моделях """ df_enhanced = df.copy() # Комплексные показатели ликвидности df_enhanced['liquidity_buffer'] = ( df['current_ratio'] * df['quick_ratio'] - df['working_capital_ratio'] ) # Показатель долговой устойчивости (Debt Sustainability Score) df_enhanced['debt_sustainability'] = ( df['interest_coverage'] / (1 + df['debt_to_equity']) - df['debt_service_coverage'] * 0.5 ) # Операционная эффективность df_enhanced['operational_efficiency'] = ( df['asset_turnover'] * df['gross_margin'] + 1 / (1 + df['days_sales_outstanding'] / 365) ) # Показатель финансового левериджа (нелинейная зависимость) df_enhanced['leverage_risk'] = np.where( df['debt_to_equity'] > 1.0, np.log(1 + df['debt_to_equity']**1.5), df['debt_to_equity'] ) # Композитный индикатор прибыльности df_enhanced['profitability_composite'] = ( 0.4 * df['roa'] + 0.3 * df['roe'] + 0.3 * df['net_margin'] ) / (1 + abs(df['debt_to_equity'])) # Показатель операционного денежного потока (аппроксимация) df_enhanced['cash_flow_proxy'] = ( df['net_margin'] * df['asset_turnover'] + df['interest_coverage'] / df['times_interest_earned'] ) # Индикатор финансовой напряженности df_enhanced['financial_stress'] = np.maximum( 0, 2 - df['current_ratio'] - df['interest_coverage']/5 ) # Взаимодействие размера и эффективности df_enhanced['size_efficiency_interaction'] = ( df['total_assets_log'] * df_enhanced['operational_efficiency'] ) return df_enhanced # Применяем создание производных признаков df_enhanced = create_advanced_features(df_processed) # Разделяем на features и target feature_columns = [col for col in df_enhanced.columns if col != 'default'] X = df_enhanced[feature_columns] y = df_enhanced['default'] # Стандартизация признаков scaler = StandardScaler() X_scaled = pd.DataFrame( scaler.fit_transform(X), columns=X.columns, index=X.index ) print(f"Количество признаков после feature engineering: {X_scaled.shape[1]}") print(f"\nНовые производные признаки:") new_features = [col for col in df_enhanced.columns if col not in df_processed.columns] for feature in new_features: if feature != 'default': print(f"- {feature}: {df_enhanced[feature].describe()['mean']:.3f} ± {df_enhanced[feature].describe()['std']:.3f}") Количество признаков после feature engineering: 23 Новые производные признаки: - liquidity_buffer: -1.263 ± 0.954 - debt_sustainability: 1.813 ± 1.089 - operational_efficiency: 0.975 ± 0.096 - leverage_risk: 0.081 ± 0.148 - profitability_composite: 0.150 ± 0.052 - cash_flow_proxy: 0.400 ± 11.763 - financial_stress: 0.434 ± 0.543 - size_efficiency_interaction: 14.751 ± 7.704 После запуска функции инжиниринга признаков мы расширили исходный набор данных до 23 признаков, добавив 8 производных финансовых индикаторов, которые отражают ликвидность, долговую устойчивость, операционную эффективность, уровень левериджа, прибыльность, денежный поток, финансовую напряженность и взаимодействие размера компании с ее эффективностью. Эти признаки не только увеличили информативность выборки, но и захватили нелинейные и композиционные эффекты, которые традиционные показатели не учитывают напрямую. Их статистики показывают разный масштаб и вариативность: от стабильных метрик вроде операционной эффективности (0.975 ± 0.096) до сильно колеблющегося прокси-денежного потока (0.400 ± 11.763), что создает богатую основу для построения более устойчивой и точной кредитной модели. Создание взаимодействующих признаков требует глубокого понимания бизнес-логики. Например, показатель долговой устойчивости учитывает не только способность компании обслуживать долг (коэффициент покрытия процентов), но и ее общую долговую нагрузку. Высокое покрытие процентов может быть обманчивым, если компания имеет критически высокий уровень заемных средств. Особого внимания заслуживают нелинейные трансформации, такие как риск финансового левериджа. В реальности риск долговой нагрузки растет не линейно, а экспоненциально после определенного порога. Компания с коэффициентом заемного и собственного капитала 0.5 и 1.0 различаются не в два раза по рискованности — разница гораздо более существенная. Построение и валидация базовой логистической модели Стратифицированная k-fold cross-validation в кредитном моделировании Правильная валидация кредитных моделей требует особого подхода, учитывающего специфику финансовых данных. Стандартная валидация методом random split может привести к утечке данных (data leakage, заглядыванию в будущее), особенно если в датасете присутствуют временные зависимости или кластеризация наблюдений по отраслям или регионам. Стратифицированная k-блочная перекрестная проверка (stratified k-fold cross-validation) обеспечивает сохранение пропорций классов в каждом фолде, что особенно важно для несбалансированных данных о дефолтах. При этом я предпочитаю использовать k=10 для достижения баланса между смещением (bias) и разбросом (variance) оценок качества модели. from sklearn.metrics import roc_auc_score, log_loss, brier_score_loss from sklearn.model_selection import StratifiedKFold from sklearn.linear_model import LogisticRegression from joblib import Parallel, delayed from tqdm import tqdm import numpy as np import pandas as pd # Настройка кросс-валидации cv_strategy = StratifiedKFold(n_splits=10, shuffle=True, random_state=42) logistic_model = LogisticRegression( penalty='l1', solver='saga', C=1.0, random_state=42, max_iter=1000 ) cv_auc_scores = [] cv_log_loss_scores = [] cv_brier_scores = [] feature_importances = [] # Запуск кросс-валидации for fold_idx, (train_idx, val_idx) in enumerate(tqdm(cv_strategy.split(X_scaled, y), total=cv_strategy.get_n_splits(), desc="CV folds")): X_train_fold = X_scaled.iloc[train_idx] X_val_fold = X_scaled.iloc[val_idx] y_train_fold = y.iloc[train_idx] y_val_fold = y.iloc[val_idx] logistic_model.fit(X_train_fold, y_train_fold) y_pred_proba = logistic_model.predict_proba(X_val_fold)[:, 1] cv_auc_scores.append(roc_auc_score(y_val_fold, y_pred_proba)) cv_log_loss_scores.append(log_loss(y_val_fold, y_pred_proba)) cv_brier_scores.append(brier_score_loss(y_val_fold, y_pred_proba)) feature_importances.append(logistic_model.coef_[0]) print(f"\nCross-validation результаты (10-fold):") print(f"AUC: {np.mean(cv_auc_scores):.4f} ± {np.std(cv_auc_scores):.4f}") print(f"Log Loss: {np.mean(cv_log_loss_scores):.4f} ± {np.std(cv_log_loss_scores):.4f}") print(f"Brier Score: {np.mean(cv_brier_scores):.4f} ± {np.std(cv_brier_scores):.4f}") # Финальная модель на всех данных final_model = LogisticRegression( penalty='l1', solver='saga', C=1.0, random_state=42, max_iter=500 ) final_model.fit(X_scaled, y) feature_importance_df = pd.DataFrame({ 'feature': X_scaled.columns, 'coefficient': final_model.coef_[0], 'abs_coefficient': np.abs(final_model.coef_[0]) }).sort_values('abs_coefficient', ascending=False) print(f"\nТоп-10 наиболее важных признаков:") print(feature_importance_df.head(10)[['feature', 'coefficient']].to_string(index=False)) # Бутстрап с параллельной обработкой n_bootstrap = 200 def fit_bootstrap(i): boot_idx = np.random.choice(len(X_scaled), len(X_scaled), replace=True) X_boot = X_scaled.iloc[boot_idx] y_boot = y.iloc[boot_idx] model = LogisticRegression(penalty='l1', solver='saga', C=1.0, max_iter=500, random_state=i) model.fit(X_boot, y_boot) return model.coef_[0] bootstrap_coefficients = Parallel(n_jobs=-1)( delayed(fit_bootstrap)(i) for i in tqdm(range(n_bootstrap), desc="Bootstrap") ) bootstrap_coefficients = np.array(bootstrap_coefficients) # Доверительные интервалы confidence_intervals = [ (np.percentile(bootstrap_coefficients[:, i], 2.5), np.percentile(bootstrap_coefficients[:, i], 97.5)) for i in range(X_scaled.shape[1]) ] feature_importance_df['ci_lower'] = [ci[0] for ci in confidence_intervals] feature_importance_df['ci_upper'] = [ci[1] for ci in confidence_intervals] feature_importance_df['is_significant'] = ( (feature_importance_df['ci_lower'] > 0) | (feature_importance_df['ci_upper'] < 0) ) print(f"\nСтатистически значимые признаки (95% ДИ не содержит 0):") significant_features = feature_importance_df[feature_importance_df['is_significant']] print(significant_features[['feature', 'coefficient', 'ci_lower', 'ci_upper']].to_string(index=False)) CV folds: 0%| | 0/10 [00:00