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

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

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

Часть первая дала пошаговое руководство по следующим шагам:

  1. Чтение данных
  2. Извлечение дневного диапазона (максимум — минимум)
  3. Удаление тренда
  4. Обработка выбросов
  5. Масштабирование данных

Я бы прочитал Часть первую, прежде чем продолжить. В этой части я пройдусь по:

7. Извлечение данных о тренде в виде нового столбца
8. Придание данным правильной формы для использования в качестве входных данных для модели keras LSTM

Эта статья доступна в формате Jupyter Notebook для Части первой и Части второй здесь:

https://github.com/jrkosinski/articles/tree/main/lstm-preprocessing

И в полном виде с минимальными комментариями:

https://github.com/jrkosinski/articles/tree/main/lstm-preprocessing/complete.ipynb

Повторный тренд

Ранее в процессе мы «удалили» тренд из данных, извлекая ежедневное процентное изменение. Тенденция, конечно, по-прежнему проявляется в ежедневных процентных изменениях, но уже не так заметна. Однако тренд может быть важной характеристикой данных, и для некоторых целей может потребоваться извлечь и его.

Есть много способов извлечь тренд из временного ряда. Можно использовать арифметическую декомпозицию или склоняться к использованию простой MA или EMA, но проблема (может быть проблемой, в зависимости от того, что вы делаете) заключается в том, что скользящие средние имеют тенденцию отставание Чтобы точно зафиксировать точные максимумы и минимумы на основе заданной степени детализации, я создал функцию, которая различает несезонные (или квазисезонные) максимумы и минимумы и проводит между ними линии тренда. В этом примере я сделаю это, а затем извлеку из него диапазон значений, в котором:
0: указывает на то, что тренд разворачивается вниз в этой точке (это высокая точка)
1: указывает на то, что в этой точке тренд разворачивается вверх (это нижняя точка)
0,5: указывает, что текущий тренд продолжается

Эта техника может быть не очень хороша для удаления шума из серии, но она хороша для извлечения опорных точек. Ниже показана его демонстрация с линиями тренда, наложенными на исходный ценовой ряд, на нескольких небольших сегментах ряда (маленькие сегменты более удобны для просмотра).

Обратите внимание, что передача меньшего значения для параметра period будет фиксировать больше ценовых скачков, а передача большего значения, как правило, захватывает меньше из них. их.

Функция extract_trend и связанный с ней код слишком велики, чтобы их можно было удобно поместить в блок кода, но их можно найти здесь:

https://github.com/jrkosinski/articles/blob/main/lstm-preprocessing/lib/Trend.py

В следующем коде функция extract_trend используется для получения и отображения тренда из первых 450 точек данных в ряду, по 150 точек данных за раз (это просто для того, чтобы сделать то, что он делает, более понятным и наглядным).

step_size = 150
range_size = 10

for i in range(3): 
    # extract trend from a subset of the price series 
    series = df['Adj Close'][:i*step_size+step_size]
    trend = extract_trend(series, range_size)
    
    # scatterplot: highs are red, lows are green 
    highs_x, highs_y = trend.as_scatterplot('hi')
    lows_x, lows_y = trend.as_scatterplot('lo')

    plt.scatter(highs_x, highs_y, color='red')
    plt.scatter(lows_x, lows_y, color='green')
    plt.plot(series)
    
    # overlay trend lines 
    plt.plot(trend.as_price_series(series[0]))
    plt.show()
    
    # show boolean 
    plt.plot(trend.as_boolean(series[0]))
    plt.show()
    range_size *= 2

Затем эти данные будут добавлены в качестве нового столбца в DataFrame под названием «Тренд». Обратите внимание, что данные уже нормализованы между 0 и 1, поэтому масштабирование не требуется. И нет необходимости обрабатывать выбросы, потому что их нет.

trend = extract_trend(df['Adj Close'], 100)
df['Trend'] = trend.as_boolean(df['Adj Close'][0])
print(len(trend.as_boolean(df['Adj Close'][0])))

# remove Adj Close, as we have extracted what we need from it
df.pop("Adj Close")
df.head()

Формирование данных

Наконец, у нас есть 3 столбца (или функции): диапазон, изменение и тренд.

Предположим, что тренд — это то, что мы хотим, чтобы модель предсказывала.

Входные данные для keras LSTM требуют трехмерного массива формы:
(s, t, f)

s = выборки: количество выборок в наборе данных (т. е. количество строк данных)
t = временные шаги: количество временных шагов для ввода для каждой выборки (иногда также называемой "запаздыванием")
f = характеристики: количество учитываемых отдельных характеристик; в данном случае 3 (Диапазон, Изменение, Тренд)

LSTM может прогнозировать несколько выходных функций и может делать это с переменным смещением и шириной. Но для простоты мы предположим для этого примера, что выходное смещение равно 1, LSTM будет прогнозировать только одну выходную функцию (изменение) и только для одного временного шага: изменение следующего дня.

Также обратите внимание, что выходной объект не обязательно должен быть одним из входных. В этом случае Изменение присутствует как на входе, так и на выходе.

X представляет входные значения.
y представляет прогнозируемые или ожидаемые значения.

X: диапазон (t[-10:0]), изменение (t[-10:0]), тенденция (t[-10:0])
y: изменение (t+1)

Шаги:
1. Извлеките значения «y» или значения, которые необходимо спрогнозировать. Это контролируемое обучение, так что это все «правильные» ответы для обучения.

2. Окно с соответствующим количеством временных шагов для каждого входа

3. Добавьте по одному примеру каждой функции в каждое окно.

Поскольку LSTM хранит в памяти более свежие входные данные, данные подаются в него в окне прямого обхода размером заранее определенное количество временных шагов. Каждый дискретный вход содержит несколько перекрывающихся окон, и каждое окно содержит один пример каждой функции. Легче объяснить на примере:

Необработанные входные данные имеют 10 строк по 2 функции в каждой: f1, f2. Это выглядит так:

_df1 = pd.DataFrame()
_df1['f1'] = ['r0f1', 'r1f1', 'r2f1', 'r3f1', 'r4f1', 'r5f1', 'r6f1', 'r7f1', 'r8f1'] 
_df1['f2'] = ['r0f2', 'r1f2', 'r2f2', 'r3f2', 'r4f2', 'r5f2', 'r6f2', 'r7f2', 'r8f2'] 
_df1.head(9)

Таким образом, самое внешнее измерение трехмерного входного массива будет состоять из 10 элементов. Каждый из этих элементов будет массивом, поэтому давайте создадим его для начала:

[ [] [] [] [] [] [] [] [] [] [] ]

Это массив, содержащий 10 пустых массивов.

Сколько временных шагов отставания? Скажем, 3. Таким образом, каждый из этих пустых массивов будет иметь внутри 3 массива. Каждый из этих самых внутренних массивов будет содержать 2 функции.

Для упрощения сначала создайте массив из 3-элементных массивов, где каждый элемент внутреннего массива представляет одну строку. Поскольку это ежедневные данные, мы будем называть строку 0 d0, строку 1 — d1 и так далее.

Функция для получения значений X из набора данных, правильно сформированного в виде трехмерного массива в форме (выборки, временные шаги, функции):

Есть пропущенные значения, потому что в начале две записи отсутствуют предыдущие данные для t-2 и t-1, а в конце невозможно сделать прогноз, потому что у нас нет будущего значения y; это ожидается. Если мы удалим эти строки с отсутствующими данными, у нас останется:

Чтобы объяснить это по-другому, каждая выборка будет содержать все функции из нескольких точек данных, а некоторые данные из выборки n+1 будут перекрывать данные выборки n.

Образец s0 будет содержать данные временных интерваловt0–t2 (поэтому t0, t1 и t2).

Образец s1 будет содержать данные временного шага t1–t3 (t1, t2, t3)

Образец s2 будет содержать данные временного шага t2–t4 (t2, t3, t4 >)

И это при задержке 3, которую мы используем в качестве удобного примера. При задержке 4 s0 будет содержать t0–t3, s1 будет содержать t1–t4 и т. д. на.

Эта функция выполнит необходимое формирование и преобразование для X и выведет X, смещенный и сформированный правильно, в виде трехмерного массива:

# extract X with the given number of timesteps
# df: the DataFrame
# ntimesteps: number of timesteps
#
def extract_X(df: pd.DataFrame, ntimesteps: int): 
    features = len(df.columns)
    X = list()
    
    #offset for timesteps
    offsets = list()
    for i in range (ntimesteps, 0, -1): 
        offsets.append(df.shift(i))
        
    #combine timestep columns into rows 
    combined = pd.concat(offsets, axis=1)
    combined = combined.tail(-ntimesteps) 
    combined.drop(combined.tail(1).index, inplace=True)
    
    #reshape each row (timesteps, features)
    for i in range(len(combined)): 
        row = combined.iloc[i].to_numpy()
        xrow = list()
        for n in range(ntimesteps): 
            xrow.append(row[n*features:(n*features)+features])
        X.append(xrow)
    
    #return as numpy array
    return np.array(X)

И функция для получения значений y из набора данных, имеющих правильную форму (в виде одномерного массива):

# extract y column (the col to be predicted)
# df: the DataFrame
# col_name: the name of the column to be predicted 
# ntimesteps: number of timesteps
#
def extract_y(df: pd.DataFrame, col_name: str, ntimesteps: int): 
    shifted = df.shift(-1)
    shifted = shifted.head(-2)
    shifted = shifted.tail(-(ntimesteps-1))
    return shifted[col_name].values

Замените каждый день (строку) массивом, содержащим две функции этого дня (строка). Таким образом, d0 становится двухэлементным массивом [r0f1, r0f2].

_y1 = extract_y(_df1, 'f2', 3)
_x1 = extract_X(_df1, 3)
print(_x1)

Номера строк идут последовательно в каждом столбце сверху вниз и порядковые слева направо. Это входной формат. Поскольку первые две строки содержат нули, мы бы их удалили. Таким образом, мы получаем количество строк, равное r = (r — (timesteps — 1)).

Теперь значения y представляют собой просто скалярный массив функций 2 из каждой строки, но сдвинутый назад на 1.

class DataSet:
    def __init__(self, X, y): 
        if X.ndim != 3: 
            raise Exception("Expected a 3-dimensional array for X")
        if y.ndim != 1: 
            raise Exception("Expected a 1-dimensional array for y")
        if len(X) != len(y): 
            raise Exception("Length of X and y must be the same")
        
        self.X = X
        self.y = y
    
    # pct% of the dataset will be split off and returned as a new DataSet
    def split(self, pct:float): 
        count = int(self.size * pct)
        new_dataset = DataSet(self.X[:count], self.y[:count])
        self.X = self.X[:-count]
        self.y = self.y[:-count]
        return new_dataset
        
    @property
    def size(self): 
        return len(self.X)

И три строки для вызова этих методов в наборе данных, чтобы получить значения X и y:

timesteps = 10
X = extract_X(df, timesteps)
y = extract_y(df, 'Trend', timesteps)

Наконец, мы можем взять все масштабированные, обработанные, сформированные данные в целом и разделить их на наборы для обучения, проверки и тестирования с разделением примерно 70–20–10:

train = DataSet(X, y)
val = train.split(0.3)
test = val.split(0.3)

print(f'train set has {train.size} samples')
print(f'val set has {val.size} samples')
print(f'test set has {test.size} samples')

набор поездов содержит 2240 образцов
набор val содержит 672 образца
набор тестов содержит 288 образцов

print('train X shape:', train.X.shape)
print('val X shape', val.X.shape)
print('test X shape:', test.X.shape)

train X shape: (2240, 10, 4)
val X shape (672, 10, 4)
test X shape: (288, 10, 4)

И это входная форма для LSTM с тензорным потоком.

Заключение

Итак, у нас было, начиная с Части 1 и до сих пор,

  • Данные ценового ряда без тренда
  • Данные ценового ряда с извлеченным дневным диапазоном
  • Данные ценового ряда нормализованы, а выбросы удалены
  • Долгосрочный тренд, извлеченный из ряда
  • Окончательный набор данных имеет правильную форму для ввода в модель keras LSTM.
  • Наконец, готовые данные разбиваются на наборы для обучения, проверки и тестирования.

И цель состояла в том, чтобы продемонстрировать лишь несколько из бесконечных способов, которыми данные временных/ценовых рядов могут быть предварительно обработаны перед использованием в модели прогнозирования.

В: Действительно ли практично прогнозировать тренд таким образом? Будет ли модель, обученная на основе этих данных, успешно предсказывать что-то полезное?
О:Нет! Или, вероятно, нет. Это не должно быть практическим примером чего-либо, кроме различных способов подготовки и предварительной обработки данных для ввода в модель, в частности, в модель keras LSTM. На самом деле я не запускал это через модель и не собираюсь.

В: Какой смысл извлекать тренд из ценовых данных, а затем повторно представлять его в другой форме?
О: Тенденция была неявной в цене и в столбце "Изменение" и остается даже после преобразований. Это неявно, но недостаточно заметно, чтобы быть полезным. Для модели было бы очень-очень сложно извлечь эту особенность (тренд) самостоятельно, а модели не обладают бесконечной вычислительной мощностью. Часть процесса исследования состоит в том, чтобы пробовать разные вещи, выдвигая на первый план различные функции и наблюдая, как модель работает с ними. Нельзя ожидать, что модель сделает всю работу сама.

В: Если модель сама по себе не может извлечь важные функции из необработанных данных, для чего она нужна?
О:Модель может извлекать важные функции, но ее возможности для этого не безграничны, и ей нужна помощь. Обычно модель просто выполняет последние, но очень важные шаги в процессе, который можно было бы выполнить с помощью статистического анализа, не связанного с машинным обучением, с участием человека, но, возможно, это заняло бы чрезмерно много времени или больших усилий. Если у вас нет доступа к модельным сетям, которые находятся под замком в АНБ (шутка здесь), вам нужно иметь некоторое представление о том, что вы хотите, чтобы модель делала, и помочь ей начать работу.
Здесь можно провести аналогию с обучением математике маленького ребенка. Если вы поместите ребенка в библиотеку, полную учебников по математике, маловероятно, что ребенок выучит умножение, несмотря на то, что вся необходимая информация доступна. Информацию нужно извлекать, превращать в примеры и истории и вводить. Возможно, однажды этот ребенок откроет для себя новые методы или доказательства, которые продвинут область математики.

Спасибо за чтение. Эта статья доступна в формате Jupyter Notebook для Части первой и Части второй здесь:

https://github.com/jrkosinski/articles/tree/main/lstm-preprocessing

И в полном виде с минимальными комментариями:



Спасибо за прочтение.