Ускорение численных вычислений в 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), независимым от исходного языка и целевой платформы. Процесс состоит из трех этапов:

  1. Numba транслирует Python-байткод в Numba IR — упрощенное представление с типизированными переменными;
  2. Numba IR преобразуется в LLVM IR с применением оптимизаций: развертывание циклов, векторизация SIMD-инструкциями, устранение избыточных вычислений;
  3. 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 с малым массивом прогревает компилятор — без этого замер включил бы время компиляции. Результаты обеих функций численно совпадают, что подтверждает корректность оптимизации.

👉🏻  RFM-анализ с помощью Python

Поддерживаемые типы и операции

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

Давайте разберемся что тут происходит:

  1. Функция calculate_momentum_matrix обрабатывает матрицу цен целиком. Внешний цикл итерирует по инструментам, внутренний — по датам;
  2. Для каждой ячейки вычисляется относительное изменение цены за период lookback. Такая структура эффективна для кеширования — процессор загружает строки матрицы блоками;
  3. Функция rank_instruments определяет топовые инструменты по momentum на каждую дату;
  4. Функция np.argsort с параметром [::-1] возвращает индексы в порядке убывания momentum;
  5. Цикл по 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

Что делает этот код?

  1. Функция monte_carlo_var оценивает максимальные потери портфеля с заданной вероятностью через симуляции будущих траекторий доходности;
  2. Цикл prange распределяет симуляции по всем доступным ядрам процессора. Каждая итерация независима — генерирует случайный путь, накапливает доходность, сохраняет результат;
  3. После завершения всех симуляций вычисляются квантили распределения убытков.

Параллелизация дает ускорение близкое к числу физических ядер (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

Что тут важно отметить:

  1. Декоратор `@vectorize` требует явного указания сигнатуры типов в формате `[output_type(input_type1, input_type2, …)]`;
  2. Параметр `target=’parallel’` включает многопоточность для обработки больших массивов;
  3. Функция `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 — компиляция при загрузке модуля через явное указание сигнатур типов. Это переносит задержку компиляции с момента первого использования на импорт модуля, упрощает отладку ошибок типизации.

👉🏻  Показатели Альфа, Бета, коэффициент Шарпа: Расчеты в Python

Ниже пример такой реализации:

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: <class 'range'>)  :: Function(<class 'range'>)
    #   $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<int64, bool>
    #   $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 операции), выигрыш минимален. Основная польза проявляется в трех сценариях:

  1. циклы с зависимостями между итерациями;
  2. условная логика внутри циклов;
  3. комплексные вычисления без готовых NumPy аналогов.
👉🏻  Модели ценообразования активов: CAPM и APT

Сравнительная таблица скорости работы алгоритмов Python, Numpy, Numba

Рис. 1: Сравнительная таблица скорости работы алгоритмов Python, Numpy, Numba

Числа получены на процессоре Intel i7-10700K для типичных операций в анализе временных рядов. Скользящее среднее реализовано через явный цикл для честного сравнения — NumPy имеет оптимизированные функции типа np.convolve, но они не всегда применимы к комплексной логике. Для матричного умножения NumPy использует оптимизированные BLAS библиотеки, Numba не превосходит их.

Критический порог эффективности Numba — массивы от 1000 элементов. Ниже этого размера накладные расходы JIT-компиляции и вызова скомпилированной функции сопоставимы с временем вычислений. Также важно учитывать: для микробенчмарков на малых данных результаты искажаются — важно тестировать Numba на реальных объемах.

Практические рекомендации по применению

Используйте Numba для:

  1. Обработки временных рядов с комплексной логикой: расчет индикаторов с условиями, детекция паттернов, фильтрация сигналов;
  2. Симуляций и стохастических методов: Монте-Карло для оценки рисков, генерация синтетических данных, bootstrap процедуры;
  3. Бэктестинга торговых стратегий: пошаговое моделирование, учет проскальзываний и комиссий, управление позициями;
  4. Оптимизации гиперпараметров: grid search или random search по сотням комбинаций параметров;
  5. Обработки больших объемов тиковых данных: агрегация в бары, расчет микроструктурных метрик.

Не используйте Numba для:

  1. Простых операций, где NumPy уже эффективен: покомпонентная арифметика, линейная алгебра, базовые статистики;
  2. Прототипирования с частыми изменениями кода: каждая правка требует рекомпиляции;
  3. Работы с нечисловыми данными: текст, объекты Python, данные с комплексной структурой;
  4. Интеграции с библиотеками без Numba-поддержки: операции с датафреймами pandas (используйте .values для NumPy массивов)

Оптимальная стратегия: профилируйте код стандартным Python profiler, идентифицируйте узкие места, применяйте Numba точечно к критическим функциям. Преждевременная оптимизация усложняет код без измеримых выгод. Начинайте с чистого Python или NumPy, добавляйте @njit только там, где бенчмарки показывают проблемы производительности.

Комбинирование с другими инструментами

Numba хорошо интегрируется с экосистемой научного Python:

  1. Для задач машинного обучения Numba эффективно комбинируется с PyTorch: предобработка данных в Numba, обучение моделей в PyTorch;
  2. Библиотека Dask использует Numba для распределенных вычислений — функции с @njit автоматически параллелятся на кластере;
  3. Для работы с GPU используйте @cuda.jit вместо @njit. Бэкенд CUDA требует видеокарту NVIDIA и компилирует функции в ядра CUDA (CUDA kernels). Такой подход эффективен для независимо параллельных задач: обработки миллионов независимых симуляций, расчета опционных цен методом конечных разностей, обучения простых нейросетей;
  4. 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 в полноценную платформу для высокопроизводительных вычислений, расширяя область применения от прототипирования до продакшен систем, анализирующих данные в реальном времени.