Если вы чем-то похожи на меня — вы хотя бы раз сталкивались с недостающими данными в своих наборах данных. Или дважды. Или слишком много раз…
Иногда все, что нужно для обработки этих надоедливых NA, — это удалить их, то есть удалить строки, содержащие недостающие данные. Однако это не всегда может быть оптимальным, особенно для данных временных рядов и тем более для финансовых. Конечно, эта проблема хорошо изучена, поэтому существует множество альтернатив дропсу.
Я рассмотрю некоторые из них (перечислены ниже) и обсужу плюсы и минусы:
падение
LOCF (последнее наблюдение перенесено)
Среднее (или подобное) вменение
Интерполяция
Спойлер: универсального подхода не существует! Я утверждаю, что LOCF обычно является хорошим выбором для финансирования, но не лишен и недостатков. Имея это в виду, позвольте мне описать методы и данные, которые я буду использовать для их демонстрации.
Примечание: если быть педантичным, то все методы 2–4 являются примерами некоторого вменения.
Давайте начнем с нескольких примеров того, почему вообще стоит беспокоиться о падении. Чтобы проиллюстрировать это, я сгенерировал некоторые чрезмерно упрощенные ежедневные данные о ценах на акции, предполагая, что они следуют случайному блужданию без дрейфа (т. е. средняя долгосрочная цена должна оставаться постоянной) – тем не менее, не самое точное, но благоприятное предположение.
np.random.seed(10) # needed for reproducibility price_moves = 3*pd.Series(np.random.randn(100)) # generating random "steps" with 0 mean price_vec = 100 + price_moves.cumsum() # generating brownian motion price_vec.plot()
Что ж, сюжет выглядит вполне благонамеренным.
Предположим, мы теперь хотим узнать эмпирическое среднее значение ежедневных различий в ценах:
price_vec.diff().mean() #sample mean >0.20030544816842052
Очевидно, ненулевое, в отличие от порождающего ряда – но это всего лишь выборочный шум. Все идет нормально.
Теперь давайте немного деформируем эти данные, удалив несколько точек данных:
price_vec_na_simple = price_vec.copy() price_vec_na_simple.iloc[90:95] = np.array([np.NaN, np.NaN, np.NaN, np.NaN, np.NaN]) # price_vec_na_simple.diff().mean() >0.1433356258183252
Мы сразу замечаем пару вещей -
Среднее значение каким-то образом не является NA, хотя вектор diff
явно будет содержать NA.
Среднее значение отличается от того, которое мы получили ранее
Теперь №1 довольно прост: pd.mean
автоматически удаляет NA по умолчанию.
А как насчет №2? Давайте переосмыслим то, что мы вычисляем.
Легко показать, что, по крайней мере, без NA, средняя разница цен должна быть просто (price_vec[99]-price_vec[0])/99
- действительно, когда мы суммируем разницу цен, все «промежуточные» части сокращаются, вот так (price_vec[1] - price_vec[0]) + (price_vec[2] - price_vec[1]) + ..
!
Теперь, когда вставлены недостающие данные, если мы сначала возьмем разности, а затем отбросим NA
, это сокращение будет нарушено - некоторые простые математические вычисления показывают, что вы теперь вычисляете (price_vec[99] - price_vec[0] - price_vec[95] + price_vec[89])/93
.
Чтобы продемонстрировать это, обратите внимание, что следующие два термина теперь опущены — price_vec[95] - price_vec[94]
и price_vec[90] - price_vec[89]
, поскольку (NA - any number)
оценивается как NA и затем отбрасывается.
Давайте проверим это:
(price_vec[99] - price_vec[0])/99 >0.20030544816842052 (price_vec[99] - price_vec[0] - price_vec[95] + price_vec[89])/93 >0.1433356258183252
Теперь становится яснее, как мы можем исправить ситуацию — нам нужно сначала отбросить NA, а затем diff
—
price_vec_na_simple.dropna().diff().mean() >0.21095999328376203
Среднее значение почти вернулось туда, где оно должно быть — небольшое расхождение происходит потому, что у нас теперь меньше членов в среднем — 94 вместо 99.
Хорошо, похоже, если нас интересует только среднее значение, мы можем просто использовать dropna
(при условии, что мы делаем это правильно)? В конце концов, разница между 0.2
и 0.21
явно находится в пределах нашей толерантности к шуму. Ну не совсем - посмотрим почему.
LOCF означает «Последнее наблюдение перенесено». Идея, лежащая в основе этого, очень проста: если я записываю данные через некоторые интервалы времени, которые могут быть или не быть регулярными, если наблюдение за каким-то конкретным интервалом отсутствует, мы просто предполагаем, что с нашей переменной ничего не изменилось, и заменяем ее последней не- пропущенное значение (например — [3, 5, NA, 8] → [3, 5, 5, 8]). Можно спросить: а зачем вообще заботиться об интервале с отсутствующим наблюдением, т.е. не просто удалить его, как в методе «отбрасывания»? Что ж, ответ кроется в свойственном «падению» дефекте, о котором я не упомянул выше.
Предположим, вы записываете несколько величин одновременно, особенно те, которые обычно не меняются слишком сильно и быстро, например, ежечасные записи температуры и влажности. Предположим, у вас есть оба значения для 10:00, 11:00 и 12:00, но только влажность в 13:00. Вы просто удалите эту «строку», то есть притворитесь, что у вас нет показаний за 13:00? Что ж, это нормально, если у вас есть только две переменные, даже если вы только что удалили некоторую потенциально ценную информацию (влажность в 13:00). Но если у вас много таких случаев или много переменных одновременно, удаление может привести к тому, что у вас вообще не останется данных!
Очень привлекательная альтернатива — просто предположить, что с 12:00 до 13:00 ничего не изменилось в температуре. Ведь если бы кто-то пришел к нам в 12:30 и спросил - «какая сейчас температура», мы бы с полным правом ответили показаниями 12:00 (если, конечно, мы не сможем сразу получить новые показания). ). Почему бы не использовать ту же логику для значения 13:00?
Во-первых, давайте проверим наш новый подход на предыдущих данных:
price_vec_na_simple.ffill().diff().mean() # ffill performs LOCF by default >0.20030544816842052
Похоже, мы точно восстановили прежнее значение! Кроме того, если вы хотите провести дальнейшее исследование данных о разнице цен — теперь они выглядят более «упорядоченными», поскольку в них есть записи для каждого дня, хотя пять из этих записей теперь равны 0 (зачем? попробуйте запустить price_vec_na_simple.ffill().diff().iloc[90:95]
чтобы убедиться в этом сами).
Кроме того, в финансах недостающие данные и данные, выходящие за рамки , часто объединяются. Позвольте мне проиллюстрировать это:
#inflate two observations, delete three next ones price_moves_na[90] += 20 price_moves_na[91] += 30 price_moves_na[92] -= 50 # to "deflate" the price shock back price_vec_na = (100 + price_moves_na.cumsum()) price_vec_na[92:95] = [np.NaN, np.NaN, np.NaN] price_vec_na.tail(20).plot() price_vec_na.diff().dropna().mean() >0.7093365245831178
Мы видим, что после резкого роста цен данные просто недоступны 3 дня подряд. Это не такой уж «искусственный» пример, как может показаться! Представьте себе, что после скачка торговля остановилась, по крайней мере, на этой конкретной бирже. Потом все немного успокоилось, и цена вернулась в нормальный режим. Возможно, произошло что-то постепенное. происходящее за кулисами, которое на самом деле «соединило» точки между спайком и постспайком, успокойтесь.Но вы этого не знаете и не имеете на это никаких данных!
Какое предположение будет наиболее естественным, если у нас нет новых данных? Напомним, что наша модель генерации данных была в основном основана на изменении цен. Так что, если новых данных нет, возможно, цена вообще не меняется? Именно это и предполагает LOCF (перенос последнего наблюдения).
Примечание для любознательного читателя: возможно, более фундаментальный взгляд на то, почему LOCF особенно подходит для данных о ценах на акции, заключается в том, что он обычно моделируется как мартингейл . Грубо говоря, мартингейл — это нечто, где лучшее предположение на завтра — это то, что мы видим сегодня, или E[x_{t+1} | x_t] = x_t
Хорошо, вернемся к фактическим данным! Давайте рассмотрим последствия LOCF как визуально, так и численно:
price_vec_na.ffill().tail(20).plot() price_vec_na.ffill().diff().mean() >0.20030544816842052
Мы сразу видим плюсы и минусы LOCF (в буквальном смысле)! Во-первых, среднее значение возвращается туда, где мы «ожидаем», что оно будет, т.е. к неизмененному эмпирическому значению. Однако мы вводим довольно некрасивый период, когда цена выходит за пределы «типичного» и искусственное падение цены между 94 и 95 днями.
Давайте сравним результаты, которые мы получаем из LOCF, со (средним) вменением. Это очень распространенный выбор для обработки NA, особенно для данных, не относящихся к временным рядам. Однако, если это сделать наивно, у этого метода будет много недостатков при использовании для финансовых данных.
Если вы просто используете среднее значение для всей выборки, вы вносите очевидную предвзятую ошибку, т. е. используете будущие данные для приписания прошлых значений.
Использование какого-либо ретроспективного или скользящего среднего, безусловно, лучше, однако иногда это может вступать в противоречие с представлением «базового уровня» мартингейла, которое мы описали ранее.
Давайте рассмотрим это немного подробнее. Я воспользуюсь ретроспективным расчетом наших старых данных -
price_vec_na_impute = price_vec_na.copy() price_vec_na_impute[price_vec_na_impute.isna()] = price_vec_na.iloc[:90].mean() price_vec_na_impute.diff().mean() >0.20030544816842052
Мы восстанавливаем «правильное» среднее изменение цены, так же, как и LOCF. НО мы вводим искусственное падение цен между 91 и 92 днями, которое в некотором смысле даже хуже того, которое было у нас раньше. В конце концов, это произошло, когда или после того, как все, вероятно, успокоилось, тогда как этот просто предполагает, что все сразу же возвращается в норму. Помимо этого, на практике может быть довольно сложно сбалансировать окно ретроспективного анализа, чтобы мы а) фиксировали недавние тенденции, но также б) фиксировали долгосрочные тенденции (обычный компромисс между смещением и отклонением).
Предположим, теперь мы хотим выполнить более сложную задачу — извлечь корреляцию между движениями цен двух активов из эмпирических данных, когда в одном или обоих ценовых рядах отсутствуют данные. Конечно, мы все еще можем использовать удаление, но:
даже если мы сможем его использовать, оптимально ли это?
что, если у нас много переменных — тогда удаление всех строк хотя бы с одним NA может оставить нас вообще без данных!
Существует множество причин, по которым может потребоваться вычисление корреляции: это первый шаг EDA почти в каждой модели с несколькими переменными, он довольно широко используется при построении любого портфеля и так далее. Поэтому измерение этого числа как можно точнее совершенно необходимо!
Для иллюстрации давайте сгенерируем вторую переменную со «встроенной» корреляцией 0,4 к первой. Для этого мы воспользуемся своего рода моделью гауссовой смеси . Можно представить себе картину двух коррелирующих акций, которые имеют общий важный фактор риска, но вторая акция также подвержена серьезному фактору риска, которого нет у первой. Возьмем, к примеру, Google и Facebook: первым фактором может быть общее мнение о технологическом секторе, а вторым — конкуренция с конкурирующими социальными сетями.
np.random.seed(2) # needed to ensure a fixed second series price_moves_2 = pd.Series(np.random.randn(100)) price_vec_2 = 50+(0.4*price_moves/3 + np.sqrt(1-0.4**2)*price_moves_2).cumsum() # all this math to ensure we get a 0.4 "theoretical" correlation with the first one
Проверим «базовую» эмпирическую корреляцию – то есть без НС и скачков.
pd.concat([price_vec, price_vec_2], axis = 1).diff().corr().iloc[0,1] >0.4866403018044526
Это достаточно близко к «теоретической» корреляции — хорошо известно, что эмпирическое измерение корреляции подвержено довольно большому шуму.
В качестве следующего шага мы рассмотрим случай с NA, но без выбросов. Мы также сравним, что произойдет, если мы dropna
до и после diff
pd.concat([price_vec_na_simple, price_vec_2], axis = 1).diff().corr().iloc[0,1] # implicit dropna after diff >0.5022675176281746 pd.concat([price_vec_na_simple, price_vec_2], axis = 1).dropna().diff().corr().iloc[0,1] >0.5287405341268966
Оба результата довольно близки и не слишком далеки от «эмпирического» значения, которое мы получили ранее. Давайте проверим, что LOCF и вменение также работают нормально:
pd.concat([price_vec_na_simple, price_vec_2], axis = 1).ffill().diff().corr().iloc[0,1] >0.5049380499525835 price_vec_na_simple_impute = price_vec_na_simple.copy() price_vec_na_simple_impute[price_vec_na_simple_impute.isna()] = price_vec_na_simple_impute.iloc[:90].mean() pd.concat([price_vec_na_simple_impute, price_vec_2], axis = 1).ffill().diff().corr().iloc[0,1] >0.4866728183859715
Сравнивая приведенные выше 4 результата, мы видим, что все методы работают достаточно хорошо. Возможно, тогда нам следует ожидать того же и в случае выброса?
Помните, чтобы оставаться последовательными, нам нужно подвергнуть второй ценовой ряд тем же ценовым шокам, что и первый, но без следующих NA. Возвращаясь к приведенному выше примеру, представьте себе какое-то крупное событие, вызывающее резкий скачок первого фактора риска, что в конечном итоге останавливает торговлю первым активом. Второй актив, конечно, тоже испытает это, но, возможно, в меньшей степени, и поэтому не будет никаких остановок и, следовательно, никаких NA.
price_vec_na_2 = 50+(0.4*price_moves_na/3 + np.sqrt(1-0.4**2)*price_moves_2).cumsum()
Давайте еще раз сравним производительность всех наших методов —
pd.concat([price_vec_na, price_vec_na_2], axis = 1).diff().corr().iloc[0,1] >0.6527112906179914 pd.concat([price_vec_na, price_vec_na_2], axis = 1).dropna().diff().corr().iloc[0,1] >0.7122391279139506
Это большая разница как с теоретической, так и с эмпирической ценностью! А как насчет LOCF и вменения?
pd.concat([price_vec_na, price_vec_na_2], axis = 1).ffill().diff().corr().iloc[0,1] >0.33178239830519984 pd.concat([price_vec_na_impute, price_vec_na_2], axis = 1).dropna().diff().corr().iloc[0,1] >0.7280990594963112
Теперь мы наконец-то видим, чего стоит LOCF! Он явно превосходит все остальные методы!
Конечно, это не 100% надежность. Во-первых, применяя LOCF, мы обеспечиваем значительное падение цен, когда исчезают недостающие данные. Если он совпадает с некоторыми выбросами на втором ценовом векторе, результаты могут существенно измениться. (*Упражнение для читателя — переверните знак движения цены price_vec_na_2[95]
и проверьте, как это влияет на результаты). Не совсем ясно, «чисто ли» просто ввести это снижение цены вместо, например, интерполяции между ценовым пиком price_vec_na[91]
и «нормальным» значением после этого price_vec_na[95]
. Однако, особенно при «живом» использовании, интерполяция на самом деле невозможна! В конце концов, если сегодня день №93, как мы можем интерполировать, используя будущее значение, записанное в конце дня №95? Для исторического исследования - конечно, такой вариант остается, но тогда остается неясным, как его интерпретировать и использовать для реального прогнозирования! В заключение, интерполяция во временном измерении возможна, но несколько более сомнительна.
Я попытался провести небольшое тематическое исследование, чтобы представить и объяснить, почему LOCF часто является наиболее привлекательным и простым вариантом для обработки недостающих данных в финансовых временных рядах.
Подводя итог, плюсы:
Некоторые минусы:
Будучи количественным специалистом в магазине по торговле реквизитом, я использую его почти во всех своих исследованиях в качестве эффективной основы. Некоторые ситуации, конечно, требуют более тонких мер, но их немного, и они обычно не на 100% «решаются» ни одним из трех других упомянутых методов.