paint-brush
处理金融时间序列中的缺失数据 - 秘诀和陷阱经过@vkirilin
12,436 讀數
12,436 讀數

处理金融时间序列中的缺失数据 - 秘诀和陷阱

经过 Vladimir Kirilin10m2024/04/03
Read on Terminal Reader

太長; 讀書

我专注于处理金融时间序列中缺失数据的方法。使用一些示例数据,我表明与删除和插补相比,LOCF 通常是一种不错的首选方法,但也有其缺点 - 例如,可能会在数据中产生人为的不良跳跃。然而,插值等替代方法也有自己的问题,尤其是在实时预测/预报的背景下。
featured image - 处理金融时间序列中的缺失数据 - 秘诀和陷阱
Vladimir Kirilin HackerNoon profile picture
0-item

如果您和我一样 - 您至少处理过一次数据集中丢失的数据。或者两次。或者一次太多次了……


有时,处理那些讨厌的 NA 所需要做的就是删除它们,即删除包含丢失数据的行。然而,这可能并不总是最佳的,特别是对于时间序列数据,对于金融数据更是如此。当然,这个问题已经被充分研究过,因此存在许多替代丢弃的方法。


我将研究其中的一些(如下所列)并讨论其优点和缺点:


  1. 掉落

  2. LOCF(最后观察结转)

  3. 平均(或类似)插补

  4. 插值法


剧透警告:没有一刀切的方法!我认为 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


我们立即注意到几件事 -

  1. 尽管diff向量显然包含 NA,但平均值在某种程度上是非 NA

  2. 平均值与我们之前获得的平均值不同


现在,#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.20.21之间的差异显然在我们的噪声容限范围内。嗯,不完全是——让我们看看为什么。


洛卡夫

什么是LOCF?

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 值使用相同的逻辑?


为什么使用 LOCF(金融领域)?

首先,让我们用之前的数据测试一下我们新发现的方法:

 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 插补


我们立刻就看到了 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 天之间引入了人为的价格下降,这在某些方面甚至比我们之前的下降还要糟糕。毕竟,那一件事发生在事情可能平静下来时或之后,而这件事只是假设一切立即恢复正常。除此之外,在实践中,平衡回顾窗口可能会有些挑战性,以便我们a)捕获最近的趋势,同时b)捕获长期趋势(通常的偏差-方差权衡)。


添加第二个变量

假设我们现在想要执行一项更复杂的任务 - 当一个或两个价格序列缺少数据时,从经验数据中提取两种资产价格变动之间的相关性。当然,我们仍然可以使用丢弃,但是:


  • 即使我们可以使用它,它是最佳的吗?

  • 如果我们有很多变量怎么办 - 那么删除至少有一个 NA 的所有行可能会让我们根本没有数据!


人们想要计算相关性的原因有很多——它是几乎所有多变量模型中 EDA 的第一步,它在任何类型的投资组合构建中都得到广泛使用,等等。因此,尽可能准确地测量这个数字是非常有必要的!


为了说明这一点,让我们生成第二个变量,其与第一个变量的“内置”相关性为 0.4。为此,我们将使用一种高斯混合模型。人们可以想到的情况是,两只相关的股票都有一个重要的风险因素,但第二只股票也面临着第一只股票所没有的主要风险因素。以谷歌和 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


让我们检查“基线”经验相关性 - 即没有 NA 和跳跃。

 pd.concat([price_vec, price_vec_2], axis = 1).diff().corr().iloc[0,1] >0.4866403018044526


现在,这相当接近“理论”相关性 - 众所周知,相关性的经验测量容易产生相当大的噪声。


无异常值的 NA

下一步,我们将检查具有 NA 的案例,但没有异常值。我们还将比较如果在diff之前和之后dropna会发生什么

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。回到上面的例子 - 想象一些重大事件导致第一个风险因素激增,最终停止第一个资产的交易。当然,第二个资产也会经历这些,但程度可能较小,因此不会发生停止,因此不会出现 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 通常是处理金融时间序列中缺失数据的最具吸引力和最简单的选项。


回顾一下,优点是:

  • 从鞅/“信息流”的角度来看很有吸引力
  • 超级容易实施
  • 无需优化(与插补相反)
  • 至少妥善处理异常值


一些缺点:

  • 可能会在缺失期结束时导致大幅跳跃
  • 当用于多个变量时可能会错过一些微妙的关节动态


作为一家自营交易店的量化分析师,我在几乎所有的研究中都使用它作为有效的基准。当然,有些情况需要采取更细致的措施,但这些措施很少且相差甚远,并且通常不能通过提到的其他 3 种方法中的任何一种真正 100%“解决”。