Что было в первой части — и почему этого мало

В первом туториале Lilian Weng построила рекуррентную нейросеть на основе LSTM-ячеек для предсказания цен одной акции. Модель работала: она обучалась на скользящих окнах нормализованных цен и выдавала прогноз на несколько дней вперёд. Но у неё был существенный изъян — она понятия не имела, какую именно акцию предсказывает.

Представьте, что один специалист одновременно ведёт 100 клиентов, но не помнит, кто есть кто. Каждый разговор начинается с нуля. Именно так работала модель из Part 1 применительно к разным тикерам: никакой памяти о личности «клиента».

В Part 2 цель — наделить рекуррентную нейросеть из первой части способностью работать сразу с несколькими акциями. Решение элегантное и хорошо известное в NLP — эмбеддинги символов.

ℹ Контекст
Полный код туториала доступен в репозитории github.com/lilianweng/stock-rnn. Он написан на TensorFlow 1.x, но архитектурные идеи актуальны для любого фреймворка.

Почему одна модель на все акции — это умно

Наивный подход: обучить отдельную LSTM для каждой акции. Проблема очевидна — 500 акций S&P 500 означают 500 независимых моделей, 500 процессов обучения и нулевой перенос знаний между ними.

Альтернатива из Part 2: обучить одну модель, которая видит данные всех акций сразу, но при каждом входе знает, о каком тикере идёт речь. Это позволяет:

  • Учиться на значительно большем объёме данных
  • Переносить паттерны (например, реакция на макрособытия) между похожими акциями
  • Экономить ресурсы при инференсе

Чтобы модель могла различать паттерны разных ценовых последовательностей, символы акций используются в качестве векторов эмбеддингов — части входных данных.

Эмбеддинг тикера — это «удостоверение личности» акции, которое модель учится формировать сама в процессе обучения.


Эмбеддинги тикеров: как это работает

Идея пришла из обработки естественного языка. Там слова кодируются не разреженными one-hot векторами, а плотными числовыми представлениями — эмбеддингами. Для акций логика та же.

Шаг 1: Label Encoding тикеров

Для каждого входного ценового ряда добавляется плейсхолдер — список символов акций, которые заранее маппируются в уникальные целые числа с помощью label encoding.

# Псевдокод: маппинг тикеров в индексы
stock_symbols = ['AAPL', 'GOOG', 'NFLX', 'KO', ...]
stock_to_id = {sym: i for i, sym in enumerate(stock_symbols)}
# AAPL -> 0, GOOG -> 1, NFLX -> 2, ...

Шаг 2: Матрица эмбеддингов

В конфигурацию модели RNNConfig добавляются два параметра: embedding_size — размер каждого вектора эмбеддинга, и stock_count — количество уникальных акций в датасете. Вместе они определяют размер матрицы эмбеддингов: модель должна обучить embedding_size × stock_count дополнительных переменных по сравнению с моделью из Part 1.

class RNNConfig():
    # ... старые параметры ...
    embedding_size = 3   # размер вектора на акцию
    stock_count    = 50  # число уникальных тикеров

Шаг 3: Конкатенация с ценовыми данными

Альтернатива — конкатенировать вектор эмбеддинга с последним состоянием LSTM-ячейки и учить новые веса в выходном слое. Однако в таком случае LSTM не сможет различать цены одной акции от другой, и её мощь будет существенно ограничена. Поэтому выбран первый подход — подавать эмбеддинг прямо на вход LSTM, конкатенируя его с ценовыми фичами на каждом шаге.


graph TD
    A["Тикер: 'AAPL' → ID: 0"] --> B["Матрица эмбеддингов\n(stock_count × embed_size)"]
    B --> C["Вектор эмбеддинга\n[0.12, -0.45, 0.89]"]
    D["Ценовые данные\n[P_t, P_t-1, P_t-2]\n(input_size=3)"] --> E["Конкатенация"]
    C --> E
    E --> F["LSTM ячейки\n(lstm_size=256)"]
    F --> G["Выходной слой\n(Dense)"]
    G --> H["Предсказание цены"]


Данные и конфигурация эксперимента

Получение исторических данных

Сначала загружаются полные данные S&P 500 с Yahoo Finance (файл SP500.csv), затем скрипт data_fetcher.py скачивает котировки каждой отдельной акции в отдельный CSV-файл.

Датасет содержит поля: цена открытия, максимум, минимум, цена закрытия, скорректированная цена закрытия и объём торгов.

Нормализация: предсказываем изменения, а не цены

Чтобы решить проблему выхода за пределы обучающей выборки, цены нормализуются в каждом скользящем окне. Задача превращается в предсказание относительных изменений, а не абсолютных значений.

⚠ Важно
Если не нормализовать данные, модель будет отлично работать на обучающих диапазонах цен, но провалится на новых уровнях. Нормализация внутри каждого окна — ключевой приём для временных рядов.

Гиперпараметры эксперимента

В эксперименте с 100 акциями используется следующая конфигурация: stock_count=100, input_size=3, embed_size=3, num_steps=30, lstm_size=256, num_layers=1, max_epoch=50, keep_prob=0.8, batch_size=64, init_learning_rate=0.05, learning_rate_decay=0.99.

ПараметрЗначениеОписание
stock_count100Число уникальных тикеров
embed_size3Размер вектора эмбеддинга
input_size3Количество входных цен (дней)
num_steps30Длина последовательности (шагов LSTM)
lstm_size256Размер скрытого состояния LSTM
keep_prob0.8Вероятность дропаута (1 - dropout rate)
batch_size64Размер мини-батча
max_epoch50Максимальное число эпох

Архитектура модели и обучение

LSTM с дропаутом

Модель LSTM имеет num_layers стопок LSTM-слоёв, каждый из которых содержит lstm_size ячеек. Затем к выходу каждой ячейки применяется маска дропаута с вероятностью сохранения keep_prob.

# Упрощённый пример на TensorFlow 2.x / Keras
import tensorflow as tf

def build_model(stock_count, embed_size, input_size,
                lstm_size, num_layers, keep_prob):
    # Входы
    price_input  = tf.keras.Input(shape=(None, input_size), name='prices')
    symbol_input = tf.keras.Input(shape=(None,), dtype='int32', name='symbols')

    # Эмбеддинги тикеров
    embed = tf.keras.layers.Embedding(
        input_dim=stock_count,
        output_dim=embed_size,
        name='stock_embedding'
    )(symbol_input)

    # Конкатенируем с ценами
    merged = tf.keras.layers.Concatenate()([price_input, embed])

    # Стек LSTM с дропаутом
    x = merged
    for _ in range(num_layers):
        x = tf.keras.layers.LSTM(
            lstm_size, return_sequences=True,
            dropout=1 - keep_prob
        )(x)

    # Выходной слой
    output = tf.keras.layers.Dense(1)(x)
    return tf.keras.Model([price_input, symbol_input], output)

Оптимизатор и функция потерь

В оригинальной реализации используется оптимизатор RMSProp. Функция потерь — среднеквадратическая ошибка (MSE), что стандартно для задач регрессии на временных рядах.

💡 Совет по оптимизатору
RMSProp хорошо себя показывает на рекуррентных сетях, потому что адаптирует скорость обучения для каждого параметра отдельно. Для современных проектов можно также попробовать Adam с gradient clipping — это снизит риск взрывного градиента.

Результаты: что показала модель

Для оценки качества предсказания строятся графики для тестовых данных акций KO, AAPL, GOOG и NFLX — и общие тренды совпадают с реальными значениями.

Принимая во внимание дизайн задачи, модель опирается на все исторические точки для предсказания только следующих 5 дней (параметр input_size). При малом input_size модели не нужно беспокоиться о долгосрочной динамике роста. Если увеличить input_size, задача становится значительно сложнее.

Ключевые наблюдения

  • Краткосрочные тренды предсказываются лучше, чем долгосрочные — классическое поведение LSTM на финансовых рядах
  • Подход использует мощь LSTM-сетей для захвата временных зависимостей в последовательных данных, что хорошо подходит для предсказания цен акций.
  • Эмбеддинги помогают модели «запомнить характер» каждой акции — её волатильность, реакцию на новости, типичные паттерны
📝 Пример улучшений
Если вы хотите улучшить базовую модель, добавьте в эмбеддинг или входной вектор дополнительные фичи: объём торгов, RSI, скользящие средние. Это значительно повышает качество предсказания.

Что можно улучшить и как применить это сегодня

Важно подчеркнуть: мотивация туториала — показать, как строить и обучать RNN-модель, а не решить задачу предсказания акций наилучшим образом. Это честное признание, которое открывает дорогу к множеству улучшений.

Современные направления развития

НаправлениеИдеяОжидаемый эффект
Мультивариантный входДобавить объём, OHLC вместо только Close+10–20% точности
Attention-механизмТрансформер поверх LSTMЛучше улавливает дальние зависимости
Сентимент-анализFinBERT эмбеддинги новостейУлавливает событийную составляющую
Более богатые эмбеддингиСектор, капитализация как доп. фичиЛучшая группировка похожих акций
АнсамблированиеLSTM + градиентный бустингСнижает дисперсию ошибки

Популярный современный подход — скользящее окно размером 60 торговых дней, где каждая входная последовательность соответствует целевому значению 61-го дня.

Фондовый рынок крайне волатилен и трудно поддаётся точному прогнозу из-за множества неопределённостей. Тем не менее инвесторы и трейдеры могут выигрывать от таких моделей, принимая обоснованные решения о покупке, удержании или продаже акций. Финансовые институты также используют подобные модели для управления рисками и оптимизации инвестиционных портфелей.


Заключение

Part 2 туториала Lilian Weng делает важный концептуальный шаг: модель перестаёт быть «слепой» относительно того, чьи цены она предсказывает. Добавление эмбеддингов тикеров — это не просто техническая деталь, а архитектурное решение, которое позволяет одной сети аккумулировать знания о сотнях инструментов одновременно.

Главные выводы:

  1. Эмбеддинги — универсальный инструмент: то, что работает для слов в NLP, отлично работает для тикеров в финансах
  2. Нормализация внутри окна критична — без неё модель не обобщается на новые ценовые уровни
  3. Один input_size определяет горизонт прогноза: маленький — проще задача, большой — нужна более сложная архитектура
  4. Качество данных и фичинг важнее архитектуры: лучший эмбеддинг с правильными фичами обгонит сложную сеть на плохих данных

Архитектурные паттерны из этого туториала живут в современных моделях — только LSTM сегодня всё чаще заменяется трансформером, а эмбеддинги тикеров обогащаются фундаментальными данными о компании. Фундамент, заложенный здесь, по-прежнему актуален.