咖啡店的第一张报表:遇见时间序列
第 1 章:咖啡店的第一张报表:遇见时间序列
1.1 推开咖啡店的门:小林的第一天
九月的第一个星期一,城市刚下过一场小雨,空气里有桂花的味道。
小林站在一栋写字楼的玻璃幕墙前,抬头看着"时光里咖啡"六个大字,心里有些忐忑。这是她大学毕业后的第一份工作——连锁咖啡品牌"时光里"运营部门的数据分析师。她本科读的就是统计学,原本以为会面对一个干净、整齐的 Excel 表格,几行代码就能把今天卖了多少杯拿铁算得一清二楚。但推开运营部那扇玻璃门的那一刻,她发现世界比她想象的更复杂。
运营总监张姐是个四十岁左右的女人,戴着细框眼镜,说话干脆得像在扔石子。她把小林带到一张桌子前面,桌上放着一台笔记本电脑,屏幕上开着一份 Excel。
"小林,欢迎你。"张姐说,"先看一份东西。"
她双击了一下图标。屏幕上的表格开始向下滚动——
``` 2020/01/01 1280 2020/01/02 1350 2020/01/03 1310 ... 2023/08/31 2120 ```
"这是我们过去三年,每一天,每一家门店的合计销售额。"张姐说,"三年,一千多个日子。"
小林点了点头——这种二维表她在学校见过。
"现在是九月初,"张姐继续说,"我们要在下个星期一之前,给采购部门提交一份下个季度的咖啡豆、牛奶、糖浆的采购建议。理由是:根据过去三年来的销售情况,预测下个季度我们大概会卖出多少杯咖啡。"
小林感觉自己的手指尖有些冰。她忽然意识到,这不是一道数学题,这是一道关于"未来"的题。
"听起来,"她问,"是要做预测?"
"是预测。"张姐看着她,"但我们不是算命先生。我们要从这些数字里,找到一些规律,然后用规律去推演未来。"
小林还没意识到,从这一刻起,她已经踏入了"时间序列"的世界。
她回到工位,把数据读进了 Python:
```python import pandas as pd
df = pd.read_excel("coffee_sales.xlsx") print(df.head()) print("行数:", df.shape[0]) ```
输出像这样:
``` date sales 0 2020-01-01 1280 1 2020-01-02 1350 2 2020-01-03 1310 3 2020-01-04 1290 4 2020-01-05 1340 行数: 1339 ```
三年,1339 天,每一行是一天。数据非常规整,没有缺失值。但小林隐约觉得,问题不会这么简单。
1.2 数字里藏着的"呼吸":时间序列的四个核心成分
小林决定先把数据画出来看看。
```python import matplotlib.pyplot as plt
df['date'] = pd.to_datetime(df['date']) df.set_index('date', inplace=True)
plt.figure(figsize=(14, 5)) plt.plot(df.index, df['sales'], linewidth=0.8) plt.title("时光里咖啡每日销售额 (2020-01 — 2023-08)") plt.xlabel("日期") plt.ylabel("销售额(元)") plt.grid(True) plt.show() ```
图出来的瞬间,小林呆住了。
那不是一条乱七八糟的折线。那条线像一条蜿蜒的河流,从左下角流向右上角,但中间又有无数大大小小的起伏。整条线看起来——怎么说呢——是"活的"。
她把这张图放大,再放大,发现了几件事:
第一件事:长期向上的趋势 (Trend)。
从 2020 年到 2023 年,销售额整体是在上升的。2020 年初每天大概卖一千多块,到 2023 年夏天已经能卖到两千多块。这条隐隐约约的"上升骨架",叫做趋势。
趋势是什么?趋势是数据在较长一段时间内持续的、单调的上升或下降方向。它不是"今天高明天高"那种短期的波动,而是像一条慢慢爬升的山路,告诉读者:数据从整体上要往哪个方向走。对咖啡店而言,趋势上升可能意味着品牌越来越受欢迎、门店越开越多、用户黏性在增强。
第二件事:周而复始的节拍——季节性 (Seasonality)。
小林把图缩回原大小,再用一张新图把 2022 年单独画出来。她发现一个有意思的现象:每年的曲线都有节奏地起伏——夏季和冬季有高峰,春秋则低一些。再放大看每周,周六和周日的销量会突然拔高,而周一到周五相对平稳。
这些有规律的、按固定周期重复出现的波动,叫做季节性。
注意:这里的"季节"不一定是春夏秋冬。在时间序列里,"季节"指的是任何周期固定的重复模式。每年一次的叫年度季节性(咖啡店的冬夏高峰),每周一次的叫周季节性(咖啡店的周末高峰),每天之内可能还有日季节性(比如早餐时段和下午茶时段会爆满)。只要周期是固定的,就叫季节性。
第三件事:时间长度飘忽的浪潮——周期 (Cycle)。
小林在反复放大缩小的过程中,发现有时候曲线会出现一些持续数月甚至更长时间的特殊形态——比如 2022 年 3 月到 6 月那一段,整体偏低于预期,然后在 7 月又恢复了。再往前,2020 年下半年有一段"猛涨",比正常趋势高出不少。事后她查了查,2020 年下半年是因为几家写字楼陆续入驻、新店开业;2022 年 3 月到 6 月是因为附近开了一家竞品咖啡馆。
这种"持续时间不固定"的起伏,叫做周期。
周期和季节性的区别在于:季节性的周期是固定的(每周 7 天,每年 365 天),而周期的间隔是飘忽不定的。经济周期就是一个典型——繁荣和衰退会持续多久,每次都不一样。打个比方:季节性是大海的潮汐,每天涨两次;周期是地球上的气候,可能连旱三年也可能连涝三年。
第四件事:解释不了的部分——残差 / 随机噪声 (Residual / Random Noise)。
除了趋势、季节性、周期之外,剩下的就是残差了。这是数据中无法被前面三种成分解释的部分——比如去年"双十一"那天,时光里咖啡搞了一个"买一赠一"的会员活动,销量飙到 3500 元,是平时的 1.7 倍。2022 年 6 月某天因为附近写字楼停电,门店提前关门,销量骤降到 600 元。这些就是噪声:随机的、不可预测的扰动。
任何一条时间序列,都可以被想象成这四种成分叠加在一起的结果。我们可以用一张图来理解这种分解:
```mermaid flowchart TD A[原始时间序列] --> B[识别趋势 T] A --> C[识别季节性 S] A --> D[识别周期 C] A --> E[识别残差 R] B --> F[长期单调方向] C --> G[固定周期波动] D --> H[非固定周期波动] E --> I[随机扰动] F --> J[可解释的成分] G --> J H --> J I --> K[难以预测的成分] ```
识别这四种成分,是理解时间序列的第一步,也是预测时间序列的第一步。
小林趴在桌上,在笔记本上写下这样一段话:
> *时间序列 = 趋势 + 季节性 + 周期 + 噪声。* > > *趋势回答"长期往哪走",季节性回答"什么时候会高、什么时候会低",周期回答"有没有大波浪",噪声回答"还有什么我没想到的"。*
这是她今天学到的第一件事。
1.3 普通方法为什么"失灵"了
小林的统计学课本里,最熟悉的方法就是线性回归:把因变量 Y 放到纵轴,自变量 X 放到横轴,画一条最"合适"的直线。她想:销售量是因变量,时间是自变量,是不是做一条 Y = aX + b 就能预测了?
她试了一下:
```python import numpy as np from sklearn.linear_model import LinearRegression
df['day_index'] = np.arange(len(df)) X = df[['day_index']] y = df['sales']
model = LinearRegression() model.fit(X, y) print("斜率:", model.coef_[0]) print("截距:", model.intercept_) ```
输出大概长这样:
``` 斜率: 0.78 截距: 1340.2 ```
数学上没问题。但是小林把这条预测线和真实数据画在同一张图上,立刻看出问题:
预测线是平直的、单调上升的斜线。但真实数据像一条蜿蜒的河流,围绕着这条线剧烈地摆动——这就是因为她忽略了"季节性"和"随机性"。
但这还不是普通线性回归在时间序列上"失灵"的根本原因。根本原因是更深层的两条假设被违反了。
第一条被违反的假设:误差项相互独立。
在普通回归里,我们做了一个关键假设:每一个数据点的"误差"是相互独立的。今天卖出多少和昨天卖出多少没有关系。这在很多场景下是合理的——比如研究身高和体重的关系,张三的体重不会影响李四的体重。
但在时间序列里,今天的销售额和昨天、上周、上个月,是紧密相关的。小林做了一个简单的实验。她计算了每一天的销售额和它前一天的销售额之间的相关系数:
```python df['lag1'] = df['sales'].shift(1) correlation = df['lag1'].corr(df['sales']) print("销售额与其前一天销售额的相关系数:", correlation) ```
输出大概是 `0.92`。
0.92——这是一个极高的相关系数。也就是说,今天的销售额几乎可以由昨天的销售额决定。
这就引出了一个关键概念——自相关 (Autocorrelation)。直白地说,自相关就是"自己的过去和现在有关"。
更系统地看自相关,可以用自相关函数 (Autocorrelation Function, ACF):
```python from statsmodels.graphics.tsaplots import plot_acf
plot_acf(df['sales'].dropna(), lags=60) plt.title("销售额的自相关函数 (lag 1 ~ 60 天)") plt.show() ```
这张图会显示:随着滞后期 (lag) 的增加,自相关系数在慢慢下降。但即使是滞后 30 天(也就是一个多月前),自相关系数依然有 0.6 左右。这说明时间序列有"长记忆"。
自相关为什么重要?因为它会让普通回归给出的"标准误"和"p 值"失真。打个比方:如果你以为你拥有 1339 个独立的数据点,但实际上它们彼此高度相关,那你真正"看到的独立信息"可能只有 200 个左右。用 1339 个数据去做的统计检验,会过度乐观——看起来很显著的结论,其实并没有那么可靠。
第二条被违反的假设:数据是平稳的。
普通回归还假设数据是"平稳 (stationary)"的——也就是说,数据的均值和方差是不随时间变化的。
但现实世界的时间序列,绝大多数都不是平稳的。咖啡店 2020 年的平均销售额和 2023 年的平均销售额就完全不是一回事。如果用 2020 年初的数据来预测 2024 年,那一定是荒谬的——三年过去了,门店更多了,用户更多了,连价格都不一样了。
怎么判断数据是不是平稳的?有一种常用的方法叫ADF 检验 (Augmented Dickey-Fuller Test):
```python from statsmodels.tsa.stattools import adfuller
result = adfuller(df['sales']) print("ADF 统计量:", result[0]) print("p 值:", result[1]) ```
如果 p 值小于 0.05,我们就说数据"拒绝原假设"——也就是"看起来是平稳的"。但小林跑出来,p 值是 0.42,差距非常大。也就是说,咖啡店的数据不是平稳的。
数据不平稳,意味着我们用普通回归得到的"参数估计"会偏倚——今天拟合出的斜率可能和明年拟合出的斜率不一样。
这两条假设的违反——自相关和非平稳性——构成了时间序列需要专门方法的根本原因。
所以,时间序列的方法核心思想就两条:
1. 承认数据之间是有依赖的——今天和昨天有关,本周和上周有关。 2. 承认数据是会变的——过去的规律可能不再适用,需要"学会"最近的规律。
理解了这两点,就理解了为什么"时间序列预测"会成为统计学和机器学习中一个独立而庞大的分支。
1.4 用 Python 听见时间的声音
小林在草稿纸上画了几个圈,又划掉。
她想:既然时间序列有四种成分,那我能不能从数据里"看"出来?
可以。她决定做几件简单的事情。
第一件事:滚动平均,让趋势浮出水面。
每一天的数据点有噪声,看不出趋势。但如果算"过去 30 天的平均",每一天的噪声会被平均掉,趋势就显现出来了。
```python df['rolling_30'] = df['sales'].rolling(window=30).mean() df['rolling_90'] = df['sales'].rolling(window=90).mean()
plt.figure(figsize=(14, 5)) plt.plot(df.index, df['sales'], alpha=0.3, label='原始数据') plt.plot(df.index, df['rolling_30'], color='red', linewidth=1.5, label='30 天滚动平均') plt.plot(df.index, df['rolling_90'], color='darkred', linewidth=2, label='90 天滚动平均') plt.legend() plt.title("滚动平均:让趋势浮出水面") plt.show() ```
出来的图,小林看了好几遍。
那条红色的 30 天平均线,确实把"上升"和"起伏"都展示出来了。再粗一点的 90 天平均线,则过滤掉了更多的短期波动,只剩下"骨架"。
这是移动平均 (Moving Average, MA) 方法的最朴素形式——把最近 N 个点的值取平均,作为"现在的趋势估计"。这种方法会"抹平"短期波动,但也能帮我们看清长期方向。
第二件事:看一周内的"季节性"。
小林想知道:每个星期里,周一到周日的销量是不是有规律?
```python df['weekday'] = df.index.dayofweek # 0 = Monday, 6 = Sunday weekday_avg = df.groupby('weekday')['sales'].mean()
plt.figure(figsize=(8, 4)) weekday_avg.plot(kind='bar', color='steelblue') plt.xticks(range(7), ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], rotation=0) plt.title("一周内各天的平均销售额") plt.ylabel("平均销售额(元)") plt.show() ```
出来的柱状图清清楚楚:周一到周五销量比较稳定,到了周六周日,柱子猛地拔高——大约比工作日高出 25%。
这就是周季节性。
第三件事:看一年内的"季节性"。
小林又按"月份"分组:
```python df['month'] = df.index.month monthly_avg = df.groupby('month')['sales'].mean()
plt.figure(figsize=(10, 4)) monthly_avg.plot(kind='bar', color='seagreen') plt.xticks(range(0, 12), ['Jan','Feb','Mar','Apr','May','Jun', 'Jul','Aug','Sep','Oct','Nov','Dec'], rotation=0) plt.title("一年内各月的平均销售额") plt.ylabel("平均销售额(元)") plt.show() ```
出来的柱状图显示:每年的 12 月、1 月、7 月、8 月销售量都高——冬天和夏天。这有两个原因:冬天人们更愿意喝热咖啡取暖,夏天冰咖啡的销量又会爆。而 3 月、4 月、9 月相对低——这两个月正是"换季"时期,温度不冷不热,咖啡的需求相对稳定。
这就是年度季节性。
第四件事:去季节性,看真实的趋势。
为了看清趋势而不被季节性干扰,小林做了一个"差分"操作——用今天的销售额减去"去年的今天"的销售额。
```python df['sales_diff_year'] = df['sales'] - df['sales'].shift(365)
plt.figure(figsize=(14, 5)) df['sales_diff_year'].plot() plt.title("同比差分 (今天 - 去年今天)") plt.ylabel("销售额差值(元)") plt.grid(True) plt.show() ```
出来的图"干净"了很多——季节性的部分被去掉了,剩下的主要是趋势的"变化"和噪声。如果这条差分曲线大部分时间在 0 以上,说明今年的销售确实在增长;如果偶尔跳水,那就是有异常事件(比如双十一、停电、竞品开店)。
第五件事:看"残差"——发现异常。
最后,小林想看看哪些天的销量"特别异常"。
```python
计算每个值与最近 30 天平均的差
df['residual'] = df['sales'] - df['rolling_30'] threshold = df['residual'].std() * 3 anomalies = df[df['residual'].abs() > threshold]
print("异常日数量:", len(anomalies)) print(anomalies.head()) ```
输出的"异常日"列表里,她一眼看到了几个熟悉的日期:
- 2020-11-11:双十一,异常高
- 2021-02-12:春节前最后一天,异常高
- 2022-06-15:写字楼停电,