哈喽,大家好~
在时间序列的业务中,真正难的地方不在于“它会变”,而在于它是同时按很多种节奏在变。
比如有些变化是分钟级的抖动,有些变化是天级的周期,有些变化是月级的趋势,甚至还会夹杂突发异常。你如果只用一种尺度去看,就很容易把问题看扁了:短期噪声会淹没长期趋势,长期平滑又会吃掉关键的局部变化。所以,多尺度建模的核心价值,就是一句话:
把同一条时间序列,拆成不同时间尺度下的结构,再把这些结构重新组织起来分析。
这件事为什么重要?
因为现实业务里的时间序列,几乎都不是“单频率、单规律、单噪声”的理想数据。工业监控要同时看设备瞬时波动和长期老化,金融风控要同时看高频跳动和中长期趋势,能源预测要同时看日周期、周周期、季节性和节假日扰动。
不从多尺度入手,模型经常就是“平均看起来还行,但关键点全错”。
多尺度建模到底在干什么
比如一首歌中,这首歌里有鼓点、有主旋律、有和声、还有偶尔的杂音。你如果把整首歌只压成一个频段去听,那很多信息就丢了。鼓点体现短期节奏,主旋律体现中期结构,和声体现更长期的层次,杂音则是异常和噪声。
时间序列也是一样。
一条复杂时间序列里,通常会同时包含这几类东西:
-
趋势(Trend):长期往上还是往下 -
季节性/周期性(Seasonality):比如每天、每周、每月重复出现的模式 -
局部波动(Local fluctuation):短时间内的小起伏 -
异常冲击(Shock / Anomaly):突然暴涨、暴跌、故障尖峰 -
噪声(Noise):没有明确规律的随机扰动
多尺度建模做的事情,就是把这些不同节奏的变化分开看。
比如:
-
用短窗口看局部变化 -
用中窗口看周期规律 -
用长窗口看整体趋势
最后再把这些尺度的信息综合起来,做预测、分类、异常检测或者解释分析。
所以你可以把它理解成:
这里:
-
表示原始时间序列在时刻 的取值 -
表示短期尺度成分 -
表示中期尺度成分 -
表示长期尺度成分 -
表示噪声或无法解释的残差
这不是说现实里一定能完美拆成这四块,而是告诉你一个非常重要的思考方式:复杂时间序列,不要只用一把尺子量。
多尺度建模的核心原理
多尺度建模常见有三种思路:
-
基于平滑/窗口的方法 -
基于频域分解的方法 -
基于深度学习多分支结构的方法
用不同窗口观察同一条序列
最朴素的思路,就是用不同长度的窗口看数据。
假设原始序列是:
我们可以构造:
-
短窗口特征:看最近 个点 -
中窗口特征:看最近 个点 -
长窗口特征:看最近 个点
其中 。
比如:
-
:看最近 12 个时间点,抓局部波动 -
:看最近 48 个时间点,抓中期周期 -
:看最近 96 个时间点,抓长期趋势
为什么这样做有效?因为不同长度的窗口,本质上是在用不同的“视野”看问题。
就像你看地图:
-
放大看街区,看到的是短期局部结构 -
缩小看整个城市,看到的是全局路线
时间序列也一样。
移动平均是最简单的多尺度分解
最经典的长期趋势提取办法,是移动平均。
长度为 的移动平均定义为:
这里:
-
表示在时刻 的 阶移动平均 -
越大,平滑效果越强,更偏向长期趋势 -
越小,保留更多局部波动
举个非常小的数字例子。
假设最近 5 个点是:
如果做 3 点移动平均,那么最后一个位置的平滑值是:
如果做 5 点移动平均,那么最后一个位置的平滑值是:
你看出来了吧:
-
3 点平均更敏感,保留更多短期波动 -
5 点平均更平滑,更接近长期趋势
所以,多尺度建模里,经常会同时用多个窗口的移动平均、卷积核或者池化操作,来提取不同层次的信息。
从残差中看到局部结构
有了长期趋势后,我们还会去看原始序列减去平滑序列后的残差:
这个 通常会更突出短期变化和异常点。
比如:
-
原始值 -
长窗口均值
那么残差是:
这说明当前点明显高于长期背景,有局部增强或异常冲击。
所以在多尺度分析里,一个常见套路是:
-
大窗口提趋势 -
小窗口提局部 -
原始值与趋势差值得到残差 -
把这些信息一起喂给模型
深度学习里怎么做多尺度
到了深度学习阶段,做法就更自然了。
你可以把同一段序列,同时送进多个分支:
-
小卷积核分支:抓短期局部模式 -
中卷积核分支:抓中期重复结构 -
大卷积核分支:抓长期趋势依赖
最后把这些分支的输出拼接起来,再做预测。
如果用 1D 卷积表示,一个卷积操作可以写成:
这里:
-
是卷积核大小,也可以理解成感受野范围 -
是卷积权重 -
是偏置 -
是激活函数 -
是提取出来的特征
当 不同时,模型感受到的时间尺度就不同:
-
小 :关注局部细节 -
大 :关注更长依赖
所以多尺度卷积网络,本质上就是让模型在不同时间视野下同时学习。
一个完整案例
这里我们自己构造一个“很像业务真实情况”的时间序列数据。每一部分规律都是可控的,可以清楚地看到模型到底学到了什么~
我们的目标是:
基于过去一段时间的数据,预测下一个时间点的值。
这个序列包含:
-
长期上升趋势 -
两种不同频率的周期 -
非线性扰动 -
随机噪声 -
少量异常点
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
np.random.seed(42)
T = 1200
t = np.arange(T)
trend = 0.01 * t
seasonal_1 = 2.5 * np.sin(2 * np.pi * t / 24) # 短周期
seasonal_2 = 4.0 * np.sin(2 * np.pi * t / 168) # 长周期
nonlinear = 1.2 * np.sin(2 * np.pi * t / 24) * np.cos(2 * np.pi * t / 48)
noise = np.random.normal(0, 0.8, T)
series = 20 + trend + seasonal_1 + seasonal_2 + nonlinear + noise
# 加一些异常点
anomaly_idx = np.random.choice(np.arange(100, 1100), size=20, replace=False)
series[anomaly_idx] += np.random.choice([6, -6, 8, -8], size=20)
df = pd.DataFrame({
"time": t,
"value": series
})
这段数据你可以把它想成:
-
一个设备监控指标 -
或者某类平台流量 -
或者城市区域能耗
它绝对不是简单的一条正弦线,而是多个尺度叠加的复杂结构。
可视化分析
原始时间序列全貌图:
plt.figure(figsize=(16, 5))
plt.plot(df["time"], df["value"], color="#ff006e", linewidth=1.8, label="Original Series")
plt.scatter(anomaly_idx, series[anomaly_idx], color="#ffd60a", s=40, label="Anomalies", edgecolors="black")
plt.title("Complex Time Series with Trend, Multi-seasonality and Anomalies", fontsize=14)
plt.xlabel("Time")
plt.ylabel("Value")
plt.legend()
plt.grid(alpha=0.25)
plt.show()
这张图是总览图~
能先从整体上看到几个东西:
-
整体是缓慢上升的,说明存在长期趋势 -
局部有明显重复波动,说明存在周期结构 -
有些点突然冲上去或掉下去,说明有异常冲击 -
波动不是纯规则的,说明噪声和非线性扰动也在里面
这一步非常重要。很多人上来就建模,但根本没看懂数据长什么样。时间序列分析里,先看图,是最基本的习惯。
不同窗口移动平均,观察多尺度趋势:
df["ma_12"] = df["value"].rolling(window=12).mean()
df["ma_48"] = df["value"].rolling(window=48).mean()
df["ma_168"] = df["value"].rolling(window=168).mean()
plt.figure(figsize=(16, 6))
plt.plot(df["time"], df["value"], color="#3a86ff", alpha=0.45, label="Original")
plt.plot(df["time"], df["ma_12"], color="#ff006e", linewidth=2, label="MA-12")
plt.plot(df["time"], df["ma_48"], color="#fb5607", linewidth=2, label="MA-48")
plt.plot(df["time"], df["ma_168"], color="#8338ec", linewidth=2.5, label="MA-168")
plt.title("Multi-scale Moving Averages", fontsize=14)
plt.xlabel("Time")
plt.ylabel("Value")
plt.legend()
plt.grid(alpha=0.25)
plt.show()
这张图就是多尺度最直观的体现。
-
MA-12更接近原始曲线,保留了较多短期波动 -
MA-48开始变平滑,更适合看中尺度变化 -
MA-168非常平滑,更像长期趋势线
你从这张图就能很直观地理解:时间尺度一变,看到的规律就变了。
如果业务上你关心的是“下一小时会不会抖”,那短窗口更有价值。
如果你关心“这个月设备是否持续劣化”,那长窗口才更重要。
趋势与残差分解图:
trend_component = df["ma_168"]
residual_component = df["value"] - trend_component
plt.figure(figsize=(16, 8))
plt.subplot(2, 1, 1)
plt.plot(df["time"], df["value"], color="#06d6a0", alpha=0.5, label="Original")
plt.plot(df["time"], trend_component, color="#ef476f", linewidth=2.5, label="Long-term Trend")
plt.title("Original Series and Long-term Trend", fontsize=13)
plt.legend()
plt.grid(alpha=0.25)
plt.subplot(2, 1, 2)
plt.plot(df["time"], residual_component, color="#118ab2", linewidth=1.5, label="Residual")
plt.axhline(0, color="black", linestyle="--", alpha=0.6)
plt.title("Residual after Removing Long-term Trend", fontsize=13)
plt.legend()
plt.grid(alpha=0.25)
plt.tight_layout()
plt.show()
这张图在告诉我们:如果把长期趋势拿掉,剩下来的是什么?
剩下来的残差里通常会有:
-
更明显的局部周期 -
更突出的异常点 -
更容易识别的短期波动结构
这在异常检测、局部模式识别里特别有用。因为有时候原始序列看着波动很正常,但一旦减去长期趋势,异常会突然“跳出来”。
多尺度特征热力图
我们构造几个多尺度特征:
-
原始值 -
短期均值 -
中期均值 -
长期均值 -
短期残差 -
中长期差值
features = pd.DataFrame({
"value": df["value"],
"ma_12": df["ma_12"],
"ma_48": df["ma_48"],
"ma_168": df["ma_168"],
"resid_12": df["value"] - df["ma_12"],
"resid_48": df["value"] - df["ma_48"],
})
features = features.dropna()
corr = features.corr()
plt.figure(figsize=(10, 7))
plt.imshow(corr, cmap="turbo", interpolation="nearest")
plt.colorbar()
plt.xticks(range(len(corr.columns)), corr.columns, rotation=45)
plt.yticks(range(len(corr.columns)), corr.columns)
plt.title("Correlation Heatmap of Multi-scale Features", fontsize=14)
plt.tight_layout()
plt.show()
热力图不是为了好看,它是在告诉你:不同尺度的特征之间,到底是相似、互补,还是冲突。
比如:
-
value和ma_12往往相关性高,说明短期平滑还保留了原始结构 -
ma_168更接近长期趋势,和残差类特征关系会不同 -
resid_12、resid_48对异常和局部波动更敏感
如果多个尺度特征全都高度重复,那你加再多特征也没意义。
但如果它们展示的是不同层面的信息,多尺度建模就能带来真正增益。
开始建模:构造多尺度输入样本
现在我们要把这个问题转成监督学习。
假设我们希望用过去 96 个点,预测下一个点。
同时,我们把过去 96 个点按照多尺度方式喂给模型。
这里采用:
-
原始窗口:96 -
下采样中尺度窗口 -
下采样长尺度窗口
数据标准化与序列切片:
from sklearn.preprocessing import StandardScaler
import torch
from torch.utils.data import Dataset, DataLoader
values = df["value"].values.reshape(-1, 1)
scaler = StandardScaler()
values_scaled = scaler.fit_transform(values).flatten()
seq_len = 96
def create_multiscale_sequences(data, seq_len=96):
X_short, X_mid, X_long, y = [], [], [], []
for i in range(len(data) - seq_len - 1):
seq = data[i:i+seq_len]
target = data[i+seq_len]
short_scale = seq
mid_scale = seq[::2] # 每2个点取一个
long_scale = seq[::4] # 每4个点取一个
X_short.append(short_scale)
X_mid.append(mid_scale)
X_long.append(long_scale)
y.append(target)
return np.array(X_short), np.array(X_mid), np.array(X_long), np.array(y)
X_short, X_mid, X_long, y = create_multiscale_sequences(values_scaled, seq_len)
train_size = int(0.8 * len(y))
X_short_train, X_short_test = X_short[:train_size], X_short[train_size:]
X_mid_train, X_mid_test = X_mid[:train_size], X_mid[train_size:]
X_long_train, X_long_test = X_long[:train_size], X_long[train_size:]
y_train, y_test = y[:train_size], y[train_size:]
这里的处理逻辑要看明白。
同一段 96 长度的序列,我们做了三种表达:
-
short_scale:完整保留,抓局部细节 -
mid_scale:隔 2 取点,压缩时间分辨率,突出中尺度模式 -
long_scale:隔 4 取点,更关注长依赖趋势
这就是一个非常实用的多尺度建模技巧:同一条时间序列,用不同采样密度表达不同时间视角。
用 PyTorch 搭一个多尺度卷积网络
class MultiScaleDataset(Dataset):
def __init__(self, xs, xm, xl, y):
self.xs = torch.tensor(xs, dtype=torch.float32).unsqueeze(1)
self.xm = torch.tensor(xm, dtype=torch.float32).unsqueeze(1)
self.xl = torch.tensor(xl, dtype=torch.float32).unsqueeze(1)
self.y = torch.tensor(y, dtype=torch.float32)
def __len__(self):
return len(self.y)
def __getitem__(self, idx):
return self.xs[idx], self.xm[idx], self.xl[idx], self.y[idx]
train_dataset = MultiScaleDataset(X_short_train, X_mid_train, X_long_train, y_train)
test_dataset = MultiScaleDataset(X_short_test, X_mid_test, X_long_test, y_test)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=32, shuffle=False)
多尺度模型结构:
import torch.nn as nn
import torch.nn.functional as F
class MultiScaleCNN(nn.Module):
def __init__(self):
super(MultiScaleCNN, self).__init__()
self.short_branch = nn.Sequential(
nn.Conv1d(1, 16, kernel_size=3, padding=1),
nn.ReLU(),
nn.AdaptiveAvgPool1d(16)
)
self.mid_branch = nn.Sequential(
nn.Conv1d(1, 16, kernel_size=5, padding=2),
nn.ReLU(),
nn.AdaptiveAvgPool1d(16)
)
self.long_branch = nn.Sequential(
nn.Conv1d(1, 16, kernel_size=7, padding=3),
nn.ReLU(),
nn.AdaptiveAvgPool1d(16)
)
self.fc = nn.Sequential(
nn.Linear(16 * 16 * 3, 64),
nn.ReLU(),
nn.Linear(64, 1)
)
def forward(self, xs, xm, xl):
fs = self.short_branch(xs)
fm = self.mid_branch(xm)
fl = self.long_branch(xl)
fusion = torch.cat([fs.flatten(1), fm.flatten(1), fl.flatten(1)], dim=1)
out = self.fc(fusion)
return out.squeeze(-1)
这个网络的设计思路很清楚:
-
短尺度分支用较小卷积核,擅长抓局部变化 -
中尺度分支用中等卷积核,擅长抓重复结构 -
长尺度分支用较大卷积核,擅长抓长期依赖 -
最后把 3 个分支融合做预测
你不要把它看成很复杂的模型,本质上它就是在模拟人类观察序列时的多视角分析过程。
训练模型并评估效果:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = MultiScaleCNN().to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.MSELoss()
epochs = 20
for epoch in range(epochs):
model.train()
train_loss = 0
for xs, xm, xl, target in train_loader:
xs, xm, xl, target = xs.to(device), xm.to(device), xl.to(device), target.to(device)
optimizer.zero_grad()
pred = model(xs, xm, xl)
loss = criterion(pred, target)
loss.backward()
optimizer.step()
train_loss += loss.item() * xs.size(0)
train_loss /= len(train_loader.dataset)
print(f"Epoch {epoch+1}/{epochs}, Train Loss: {train_loss:.6f}")
预测测试集:
model.eval()
preds = []
targets = []
with torch.no_grad():
for xs, xm, xl, target in test_loader:
xs, xm, xl = xs.to(device), xm.to(device), xl.to(device)
pred = model(xs, xm, xl)
preds.extend(pred.cpu().numpy())
targets.extend(target.numpy())
preds = np.array(preds).reshape(-1, 1)
targets = np.array(targets).reshape(-1, 1)
preds_inv = scaler.inverse_transform(preds).flatten()
targets_inv = scaler.inverse_transform(targets).flatten()
预测结果图:
from sklearn.metrics import mean_squared_error, mean_absolute_error
rmse = np.sqrt(mean_squared_error(targets_inv, preds_inv))
mae = mean_absolute_error(targets_inv, preds_inv)
plt.figure(figsize=(16, 5))
plt.plot(targets_inv, color="#ff006e", linewidth=2, label="True")
plt.plot(preds_inv, color="#00b4d8", linewidth=2, label="Predicted")
plt.title(f"Prediction Result of Multi-scale CNN | RMSE={rmse:.3f}, MAE={mae:.3f}", fontsize=14)
plt.xlabel("Test Time Step")
plt.ylabel("Value")
plt.legend()
plt.grid(alpha=0.25)
plt.show()
这张预测图主要看两个点:
第一,模型是不是跟住了整体趋势。
第二,模型能不能在局部波动时也不掉队。
如果你的模型只能跟趋势,但局部全错,那说明它只学到了长尺度。
如果它只能跟局部抖动,但长期趋势偏掉,那说明它只学到了短尺度。
而多尺度模型的优势,就在于这两头都尽量兼顾。
尺度不是越多越好
很多人一听多尺度,就开始上 5 个、8 个、10 个窗口。最后模型变复杂了,效果反而下降。
原因很简单:如果这些尺度表达的信息高度重复,那只是给模型增加冗余输入。
比较实用的做法是:
-
一个短尺度 -
一个中尺度 -
一个长尺度
先把这三个层次打透,再考虑细化。
继续升级
到这里,已经把多尺度建模的核心逻辑吃透了。
但如果你想进一步往工业级方案走,可以从下面几个方向继续升级。
第一个方向,是把简单卷积升级成 TCN、LSTM、Transformer 的多尺度结构。
比如不同尺度各自编码,再做注意力融合,这在复杂预测任务里很常见。
第二个方向,是引入更正式的分解方法。
比如 STL 分解、小波变换、EMD 分解,把趋势、季节项、残差先拆出来,再分别建模。
第三个方向,是把多尺度分析和异常检测结合。
很多工业故障并不是单点异常,而是某个尺度上的模式变化。这个时候,多尺度残差会非常有用。
第四个方向,是做多变量时间序列。
真实业务里通常不止一条序列,而是多个指标一起变化。这个时候不仅有时间尺度,还有变量间关联尺度,问题会更有挑战,也更有价值。
总结
多尺度建模的本质,不是换一个花哨名字,而是承认一个现实:复杂时间序列不是按一个节奏在变化,而是多个时间节奏叠加在一起变化。
所以分析和建模时,必须同时照顾短期波动、中期周期和长期趋势,这样你对数据的理解才完整,模型效果才稳定。
大家接下来可以继续尝试两件事:
第一,把本文的单变量案例改成多变量输入,比如加入温度、节假日、设备状态等辅助特征。
第二,把本文的多尺度 CNN 升级成多尺度 Transformer 或 TCN,对比不同模型在趋势、峰值和异常段上的表现。

