分解是数据科学中颇具趣味的一类方法。其核心思想是把复杂事物拆解为更简单、更易解释的部分。这可以用于矩阵、模型,或信号。
由于这一理念十分通用,它有许多具体形式。在线性代数中,有QR 分解 、特征分解 、奇异值分解,以及 Cholesky 分解等。在其他领域,还有傅里叶变换、主成分分析 ,甚至用 LDA 等方法进行的主题建模。回归模型中的变异性可以分解为平方和组成部分:SST、SSR 和 SSE 。我们甚至可以把决策树 看作一种分解形式。
这里,我们将回顾我们的最爱之一:时间序列分解。如果读完之后您对此产生兴趣,欢迎参加由 Rob Hyndman 教授主讲的 Forecasting in R 课程。
什么是时间序列分解?
时间序列分解是一种将时间序列拆分为独立组成部分的方法,通常包括趋势或趋势-周期、季节性和残差。 时间序列分解的目的在于更好地理解潜在模式,并提升预测的准确性。
时间序列有哪些组成部分?
我们来看看时间序列的各个组成部分。这可能是理解该概念的最佳方式。
趋势(Trend)组件
顾名思义,时间序列的趋势是长期走势。需要说明的是,趋势组件通常被理解为趋势-周期,其中的“周期”被看作是长期且不规则的模式。汽车销量过去被认为遵循七年一周期(不论原因如何),不过随着电动车的兴起,这一说法或许已不再适用。
人们从趋势中得到的关键信息通常是:从长期来看,数据是在上升、下降,还是保持稳定?过去数十年全球海洋温度上升就是一个明显(且不妙)的趋势。
季节性(Seasonal)组件
季节性组件需要更详细的说明,因为它稍微复杂一些。时间序列的季节性是指在固定间隔内重复出现的短期循环。比如按日、按月、按周、按季度。关键在于这些间隔必须相当规律。您或许会看到,如果人们周日更喜欢在家自己煮咖啡,那么咖啡店订单会呈现每周季节性。(我们将在下一节探讨这个咖啡店订单的问题。)
关于季节性,还有些复杂之处。想想一年中的周数并非整数(约 52.18)。再想想各个月份并非等长:二月有 28(或 29)天,但七月总是 31 天。
更进一步,由于每年略多于 52 周,而且月份也不等长,您无法将大多数月份整除为完整的周。这在看日历时很直观。但对数据分析师来说,不太直观的是:当您在按周粒度观察数据,再把它汇总到按月级别时,可能会看到不一致的现象,比如某一年的一月有四个周一,下一年的一月却有五个。
残差 / 不规则 / 余项组件
在去除趋势-周期和季节性组件之后剩下的一切,我们称之为“残差组件”,其中包括数据中的异常事件和随机噪声。与名称可能带给您的印象相反,残差组件并非无用的副产物。所有数据都存在无法解释的变异性,量化这种变异性的大小很重要。有趣的是,在表现良好的序列中,残差往往呈现为 正态分布(高斯分布 ),在这种情况下,我们可能会用假设检验 来评估“黑天鹅”事件。
时间序列分解模型
既然我们已经讨论了时间序列的构成部分,还需要说明的是,时间序列分解实际上有两类通用形式。我们先从加性模型开始。
加性分解
下面是加性分解的公式:
请注意公式中的加号。它的含义是:将趋势与季节性相加,再加上残差,就能还原原始时间序列。一般来说,当季节性波动在时间上基本保持恒定时,我们使用加性分解。
乘性分解
下面是乘性分解的公式。请注意该版本中的乘号。
当季节性波动会随趋势强度而变化时,我们使用乘性模型。也就是说,当您观察到季节性效应的幅度会随着整体序列的升降而变大或变小时。我们在这里描述的,实际上是一种异方差性 。
时间序列分解技术
到目前为止,我们已经介绍了时间序列分解包含的要素,以及两种主要的通用类型。接下来,我们要讨论最常见的两种方法。每种方法都可以采用加性或乘性模型。
经典分解
经典分解更容易理解。它起源于 20 世纪 20 年代,但至今仍然适用。 为了展示经典分解,我们用 Excel 演示。经典分解(与我们稍后要展示的 STL 一样)既可用于加性模型,也可用于乘性模型。本示例中,我们创建加性版本。
如果您想亲自尝试,可使用 Kaggle 上这个很有意思的咖啡销售数据集 中的 index_1 CSV。供您参考,我们已按日聚合并汇总了销售额。
首先 ,我们计算趋势-周期。 我们识别出每周季节性,而一周有七天,因此季节周期为奇数,这意味着应使用对称窗口进行中心化移动平均,即 7 天移动平均。请务必注意 Excel 表中的 对齐与中心化。
接着 ,通过用原始序列减去趋势,得到去趋势后的序列。
然后 ,为求季节性组件,我们对每个季节周期内的去趋势值取平均。 一个重要补充:您会发现季节性组件的和很可能并不严格等于 0。请务必通过归一化 进行调整:
计算季节性组件的均值。
用每个季节性取值减去该季节性均值。
最后 ,对于余项(残差),我们用原始序列减去趋势与季节性组件。
如果我们发现序列的季节性效应会随数据水平变化,就应考虑使用乘性分解。乘性分解的步骤大致相同,只是将减法替换为除法。具体而言,我们会 通过用原始序列除以趋势-周期来得到去趋势序列。随后, 为求余项,我们将原始序列除以趋势与季节性组件的乘积。( 或者,我们也可以对原始序列取自然对数,再应用加性分解。两者在功能上等价。)
STL 分解
STL 分解指基于 LOESS 的季节-趋势分解,是一种更现代的分解方法。它更难 解释,但通常比经典方法更灵活。STL 使用一种称为 LOESS 的方法:对数据的多个重叠小片段拟合简单回归模型 ,并在各个局部加权回归中分别提取趋势与季节性组件。
该方法解决了经典分解的特定缺点,即在经典方法中,首尾观测值无法得到趋势-周期或余项估计。您可以在上面的 Excel 空白单元格(第 3、4、5 行以及第 20、21、22 行)中看到这一点。
还需说明的是,经典分解所识别的趋势-周期会平滑快速的升降,因此可能掩盖序列中发生的重要变化。最后,经典分解默认存在固定的季节性模式,但在数据量足够大时,我们有时会看到季节性模式发生变化。
我们先为原始序列绘制时间图。与之前一样,第一步是对 Kaggle 数据集进行聚合与汇总,以得到我们关注的序列。
coffee_sales_data <- read.csv( "/Users/x/Desktop/index_1.csv" )
coffee_sales_data <- as.data.frame( coffee_sales_data) 技术支持
library( dplyr)
library( ggplot2)
coffee_sales_data | >
group_by( date) | >
summarize( money = sum( money) ) | >
ggplot( aes( x = as.Date( date) , y = money) ) +
geom_line( color = '#01ef63' ) +
labs( title = "Coffee Sales" ) +
labs( subtitle = "Time series" ) 技术支持
接下来,我们将序列分解为趋势、季节性和余项。分解图顶部的框显示了原始序列,只是略显压缩。
coffee_sales_data | >
group_by( date) | >
summarize( money = sum( money) ) | >
mutate( date = as.Date( date) ) | >
as_tsibble( ) | >
filter( date > '2024-02-28' ) | >
filter( date < '2024-05-01' ) | >
model(
STL( money ~ season( window = "periodic" ) )
) | >
components( ) | >
autoplot( color = '#203147' ) +
labs( title = "Coffee Sales" ) +
labs( subtitle = "Classical additive decomposition" )
coffee_sales_data | >
group_by( date) | >
summarize( money = sum( money) ) | >
mutate( date = as.Date( date) ) | >
as_tsibble( ) | >
filter( date > '2024-02-28' ) | >
filter( date < '2024-05-01' ) | >
model(
STL( money ~ season( window = "periodic" ) )
) | >
components( ) -> ts_components
ts_components | >
autoplot( color = '#203147' ) +
labs( title = "Coffee Sales" ) +
labs( subtitle = "Classical additive decomposition" ) 技术支持
请注意,与经典分解不同,我们得到的数据框在起始或末尾没有缺口。
print(ts_components)技术支持
STL 分解也存在一些缺点,如果您感兴趣,我们已将相关信息放在常见问题中。
时间序列分解与季节性调整
很多时候,创建季节性调整版本的数据很有帮助。时间序列分解对此大有裨益。要创建季调版本,我们只需将趋势与余项相加;或者从原始序列中减去季节性组件;两种做法最终等价。
seasonally_adjusted <- components | >
mutate( seasonally_adjusted = money - season_week)
season_adjust_data <- seasonally_adjusted | >
select( date, money, season_adjust) | >
pivot_longer( cols = c( money, season_adjust) , names_to = "series" , values_to = "value" )
season_adjust_data | >
autoplot( ) + facet_wrap( ~ series) +
scale_color_manual( values = c(
"money" = '#203147' ,
"season_adjust" = '#01ef63'
) ) +
facet_wrap( ~ series) +
labs( title = "Coffee Sales: Original vs Seasonally Adjusted" ) 技术支持
我们发现,在这个特定数据集中,季节性并不算特别强,这本身也很有意思。
季调数据的价值在于,它能帮助我们理解序列的某些特征。首先,我们可以判断潜在趋势是否由季节性驱动。其次,我们可以更仔细地审视数据中的异常事件。我们认为周五的促销之所以有效,是因为“周五”,还是因为“促销”?借助时间序列分解与季节性调整,我们可以检验这类问题。
时间序列分解与预测
您也许会惊讶于时间序列分解对预测同样有帮助。我们主要从两方面思考。其一,了解时间序列的组成部分,有助于我们选择合适的预测模型。如果季节性很强,我们可能会倾向于使用 Holt-Winters 指数平滑或 ETS 方法,这些方法专门用于处理趋势和季节性。
其二,我们甚至可以在预测中直接使用分解后的数据,从而获得更多选项。例如,我们可以对季调后的数据运行非季节性预测模型(稍后再把季节性组件加回去)。当我们想使用更灵活的模型(如标准 ARIMA 和 线性回归 )时,季节性调整尤其有用。
下面,我们创建一个简单的 ARIMA 模型来预测季调后的数据。
season_adjust_data_filtered <- season_adjust_data | >
filter( series == "season_adjust" ) | >
as_tsibble( index = date)
seasonal_adjust_arima_model <- season_adjust_data_filtered | >
model(
ARIMA( value)
)
seasonal_adjust_arima_model | >
forecast( h = "14 days" ) | >
autoplot( season_adjust_data_filtered, color = '#01ef63' ) +
labs( title = "Forecast of Seasonally Adjusted Coffee Sales" ,
subtitle = "Simple ARIMA Model" ,
y = "Money (Seasonally Adjusted)" ,
x = "Date" ) 技术支持
我们也可以先对季调序列进行预测,再把季节性组件加回去,从而得到带季节性的预测。所有这一切都得益于最初的分解。
historical_data <- components | >
as_tibble( ) | >
select( date, money) | >
mutate( source = "historical" )
forecast_data <- final_forecast | >
as_tibble( ) | >
transmute( date, money = money_forecast) | >
mutate( source = "forecast" )
full_data <- bind_rows( historical_data, forecast_data) | >
as_tsibble( index = date, validate = FALSE )
full_data | >
ggplot( aes( x = date, y = money, color = source) ) +
geom_line( ) +
scale_color_manual( values = c(
"historical" = "#203147" ,
"forecast" = "#01ef63"
) ) +
labs(
title = "Coffee Sales: Actual and Forecast" ,
subtitle = "Historical vs Forecast" ,
y = "Money" ,
x = "Date"
) 技术支持
结语
希望您喜欢这篇时间序列分解的文章!请记住,分解只是时间序列分析的一部分。若想进一步精进,欢迎报名由 Rob Hyndman 教授主讲的 Forecasting in R 课程,他也参与了我们用于可视化的 R 包的开发。如果您对本文的内容感兴趣,但更偏好 Python,我们也提供 Visualizing Time Series Data in Python 课程,非常适合此类任务。
分解是数据科学中颇具趣味的一类方法。其核心思想是把复杂事物拆解为更简单、更易解释的部分。这可以用于矩阵、模型,或信号。
由于这一理念十分通用,它有许多具体形式。在线性代数中,有QR 分解、特征分解、奇异值分解,以及 Cholesky 分解等。在其他领域,还有傅里叶变换、主成分分析,甚至用 LDA 等方法进行的主题建模。回归模型中的变异性可以分解为平方和组成部分:SST、SSR 和 SSE。我们甚至可以把决策树看作一种分解形式。
这里,我们将回顾我们的最爱之一:时间序列分解。如果读完之后您对此产生兴趣,欢迎参加由 Rob Hyndman 教授主讲的 Forecasting in R 课程。
什么是时间序列分解?
时间序列分解是一种将时间序列拆分为独立组成部分的方法,通常包括趋势或趋势-周期、季节性和残差。时间序列分解的目的在于更好地理解潜在模式,并提升预测的准确性。
时间序列有哪些组成部分?
我们来看看时间序列的各个组成部分。这可能是理解该概念的最佳方式。
趋势(Trend)组件
顾名思义,时间序列的趋势是长期走势。需要说明的是,趋势组件通常被理解为趋势-周期,其中的“周期”被看作是长期且不规则的模式。汽车销量过去被认为遵循七年一周期(不论原因如何),不过随着电动车的兴起,这一说法或许已不再适用。
人们从趋势中得到的关键信息通常是:从长期来看,数据是在上升、下降,还是保持稳定?过去数十年全球海洋温度上升就是一个明显(且不妙)的趋势。
季节性(Seasonal)组件
季节性组件需要更详细的说明,因为它稍微复杂一些。时间序列的季节性是指在固定间隔内重复出现的短期循环。比如按日、按月、按周、按季度。关键在于这些间隔必须相当规律。您或许会看到,如果人们周日更喜欢在家自己煮咖啡,那么咖啡店订单会呈现每周季节性。(我们将在下一节探讨这个咖啡店订单的问题。)
关于季节性,还有些复杂之处。想想一年中的周数并非整数(约 52.18)。再想想各个月份并非等长:二月有 28(或 29)天,但七月总是 31 天。
更进一步,由于每年略多于 52 周,而且月份也不等长,您无法将大多数月份整除为完整的周。这在看日历时很直观。但对数据分析师来说,不太直观的是:当您在按周粒度观察数据,再把它汇总到按月级别时,可能会看到不一致的现象,比如某一年的一月有四个周一,下一年的一月却有五个。
残差 / 不规则 / 余项组件
在去除趋势-周期和季节性组件之后剩下的一切,我们称之为“残差组件”,其中包括数据中的异常事件和随机噪声。与名称可能带给您的印象相反,残差组件并非无用的副产物。所有数据都存在无法解释的变异性,量化这种变异性的大小很重要。有趣的是,在表现良好的序列中,残差往往呈现为正态分布(高斯分布),在这种情况下,我们可能会用假设检验来评估“黑天鹅”事件。
时间序列分解模型
既然我们已经讨论了时间序列的构成部分,还需要说明的是,时间序列分解实际上有两类通用形式。我们先从加性模型开始。
加性分解
下面是加性分解的公式:
请注意公式中的加号。它的含义是:将趋势与季节性相加,再加上残差,就能还原原始时间序列。一般来说,当季节性波动在时间上基本保持恒定时,我们使用加性分解。
乘性分解
下面是乘性分解的公式。请注意该版本中的乘号。
当季节性波动会随趋势强度而变化时,我们使用乘性模型。也就是说,当您观察到季节性效应的幅度会随着整体序列的升降而变大或变小时。我们在这里描述的,实际上是一种异方差性。
时间序列分解技术
到目前为止,我们已经介绍了时间序列分解包含的要素,以及两种主要的通用类型。接下来,我们要讨论最常见的两种方法。每种方法都可以采用加性或乘性模型。
经典分解
经典分解更容易理解。它起源于 20 世纪 20 年代,但至今仍然适用。为了展示经典分解,我们用 Excel 演示。经典分解(与我们稍后要展示的 STL 一样)既可用于加性模型,也可用于乘性模型。本示例中,我们创建加性版本。
如果您想亲自尝试,可使用 Kaggle 上这个很有意思的咖啡销售数据集中的
index_1CSV。供您参考,我们已按日聚合并汇总了销售额。首先,我们计算趋势-周期。我们识别出每周季节性,而一周有七天,因此季节周期为奇数,这意味着应使用对称窗口进行中心化移动平均,即 7 天移动平均。请务必注意 Excel 表中的对齐与中心化。
接着,通过用原始序列减去趋势,得到去趋势后的序列。
然后,为求季节性组件,我们对每个季节周期内的去趋势值取平均。一个重要补充:您会发现季节性组件的和很可能并不严格等于 0。请务必通过归一化进行调整:
计算季节性组件的均值。
用每个季节性取值减去该季节性均值。
最后,对于余项(残差),我们用原始序列减去趋势与季节性组件。
如果我们发现序列的季节性效应会随数据水平变化,就应考虑使用乘性分解。乘性分解的步骤大致相同,只是将减法替换为除法。具体而言,我们会通过用原始序列除以趋势-周期来得到去趋势序列。随后,为求余项,我们将原始序列除以趋势与季节性组件的乘积。(或者,我们也可以对原始序列取自然对数,再应用加性分解。两者在功能上等价。)
STL 分解
STL 分解指基于 LOESS 的季节-趋势分解,是一种更现代的分解方法。它更难解释,但通常比经典方法更灵活。STL 使用一种称为 LOESS 的方法:对数据的多个重叠小片段拟合简单回归模型,并在各个局部加权回归中分别提取趋势与季节性组件。
该方法解决了经典分解的特定缺点,即在经典方法中,首尾观测值无法得到趋势-周期或余项估计。您可以在上面的 Excel 空白单元格(第 3、4、5 行以及第 20、21、22 行)中看到这一点。
还需说明的是,经典分解所识别的趋势-周期会平滑快速的升降,因此可能掩盖序列中发生的重要变化。最后,经典分解默认存在固定的季节性模式,但在数据量足够大时,我们有时会看到季节性模式发生变化。
我们先为原始序列绘制时间图。与之前一样,第一步是对 Kaggle 数据集进行聚合与汇总,以得到我们关注的序列。
接下来,我们将序列分解为趋势、季节性和余项。分解图顶部的框显示了原始序列,只是略显压缩。
请注意,与经典分解不同,我们得到的数据框在起始或末尾没有缺口。
STL 分解也存在一些缺点,如果您感兴趣,我们已将相关信息放在常见问题中。
时间序列分解与季节性调整
很多时候,创建季节性调整版本的数据很有帮助。时间序列分解对此大有裨益。要创建季调版本,我们只需将趋势与余项相加;或者从原始序列中减去季节性组件;两种做法最终等价。
我们发现,在这个特定数据集中,季节性并不算特别强,这本身也很有意思。
季调数据的价值在于,它能帮助我们理解序列的某些特征。首先,我们可以判断潜在趋势是否由季节性驱动。其次,我们可以更仔细地审视数据中的异常事件。我们认为周五的促销之所以有效,是因为“周五”,还是因为“促销”?借助时间序列分解与季节性调整,我们可以检验这类问题。
时间序列分解与预测
您也许会惊讶于时间序列分解对预测同样有帮助。我们主要从两方面思考。其一,了解时间序列的组成部分,有助于我们选择合适的预测模型。如果季节性很强,我们可能会倾向于使用 Holt-Winters 指数平滑或 ETS 方法,这些方法专门用于处理趋势和季节性。
其二,我们甚至可以在预测中直接使用分解后的数据,从而获得更多选项。例如,我们可以对季调后的数据运行非季节性预测模型(稍后再把季节性组件加回去)。当我们想使用更灵活的模型(如标准 ARIMA 和 线性回归)时,季节性调整尤其有用。
下面,我们创建一个简单的 ARIMA 模型来预测季调后的数据。
我们也可以先对季调序列进行预测,再把季节性组件加回去,从而得到带季节性的预测。所有这一切都得益于最初的分解。
结语
希望您喜欢这篇时间序列分解的文章!请记住,分解只是时间序列分析的一部分。若想进一步精进,欢迎报名由 Rob Hyndman 教授主讲的 Forecasting in R 课程,他也参与了我们用于可视化的 R 包的开发。如果您对本文的内容感兴趣,但更偏好 Python,我们也提供 Visualizing Time Series Data in Python 课程,非常适合此类任务。