ST-MTM这个名字可能还没多少人听过,但只要做过时间序列预训练,多半会有同样的困惑:掩码自编码器在图像上那么能打,搬到时间序列建模上却总差口气。损失曲线降得挺漂亮,重建结果也像模像样,可一旦拿去微调做预测,效果比从头训练好不了多少。我在几个项目里反复碰到这个现象,后来发现根子不在模型容量,而在掩码本身——你把趋势、季节、残差搅在一起的原始信号直接挖掉一块让模型去猜,它很容易抄近路,靠周期性复制就拿到低损失,根本轮不到学习深层结构。ST-MTM的思路是把季节-趋势分解和掩码时间序列建模结合起来:先把序列拆开,再对每个分量用不同的掩码策略训练,逼着模型把三种信号各自的规律都学到手。这篇文章我会从动机、架构、实验到落地踩坑,把整套做法讲清楚,适合正在做时序预训练、或者想给长期预测模型加一个自监督预训练阶段的工程师和研究员。
1. 直接掩码原始序列时,模型到底在学什么
1.1 MAE能成功,依赖的是空间冗余
图像领域的MAE(Masked Autoencoder)之所以效果好,有一个前提经常被忽略:图像天然具备很强的局部空间相关性。随机遮掉75%的patch,周围剩下的25%仍然提供了足够的上下文线索,模型不能靠简单的插值糊弄过去,必须学会识别物体的大致类别、形状和语义关系,才能把被遮掉的部分重建出来。
MAE在视觉任务上还验证了一个非常重要的规律:掩码比例要卡在"够难但没难到不可解"的位置。遮少了,任务太简单,模型学到的是相邻像素的插值;遮多了(95%以上),重建彻底不可做,表示学习就会崩。这也是后来做时间序列掩码建模时经常直接照搬的一条规定:75%掩码率。但我实际跑下来发现,在时间序列上直接照搬这个比例是有问题的。
1.2 时间序列不是图片:三种信号搅在一起
把一条原始时间序列拿去做掩码重建,模型面对的其实是一个混合信号。一条小时级的电力负荷序列,大致可以拆成三部分:
| 分量 | 特点 | 重建难度 | 模型容易怎么"作弊" |
|---|---|---|---|
| 趋势 | 缓慢变化、低频主导 | 容易 | 靠邻近点线性外推就能猜个大概 |
| 季节 | 周期性重复、相位稳定 | 中等 | 直接复制上一周期的同相位patch |
| 残差 | 噪声主导、几乎不可预测 | 极难 | 干脆拟合噪声,学不到有用信息 |
假设输入长度是336,周期为24,你想遮掉第100到107这个patch。如果它落在夏季的日负荷曲线上,模型只要找到第76到83的patch——也就是昨天同一时刻——几乎就能原样复制出来。这就是最典型的抄近路:重建损失看起来很小,但编码器根本不需要理解"这几天温度异常导致负荷抬升"这种跨周期的语义,只需要把短期周期性记住就够了。
更麻烦的是残差分量。残差在定义上就是去掉趋势和季节之后剩下的不可解释部分,它的可预测性很低。如果掩码比例一视同仁,模型在遮掉残差位置时做的事情其实是"硬猜噪声",这会把编码器的容量大量消耗在过拟合噪声上。我做过一个对比实验:单独把残差分量的重建误差拎出来看,它占了总损失的很大一块,但对下游预测性能几乎没有正向贡献,有时甚至是负贡献。
所以问题的本质是:掩码自监督的目标函数对三个分量的"难度"完全没有区分,模型天然会优先解决简单且权重大的部分,导致趋势和季节靠短程复制解决,残差靠死记硬背解决,真正需要的长期依赖和跨周期推理能力反而没被训练到。想让掩码建模起作用,就得先把信号拆开,针对每种信号的脾气单独设计任务难度。这就是SP-MTM(我先用这个简称,后面统一叫ST-MTM)的出发点——季节-趋势分解在这里不是一个数据预处理的花活,而是整个预训练任务设计的基石。
2. 季节-趋势分解:把信号按脾气分开再谈建模
2.1 趋势、季节、残差各自的脾气
先回到信号本身。任意一条有一定采样频率的时间序列,都可以做一个加法或者乘法形式的分解:x(t) = trend(t) + seasonal(t) + residual(t),乘法场景取对数之后就转成加法。
用天气数据类比最直观:一个城市全年的小时级温度,长期趋势是春夏秋冬的缓慢波动,季节项是每天昼夜的温差循环,残差就是突发冷空气、云层遮挡这些说不清道不明的扰动。三种分量的统计性质完全不同:
- 趋势是极低频的,自相关衰减非常慢,100个点之外的趋势值和当前值仍然高度相关。
- 季节项是周期性的,有固定的周期T,不同周期的同相位点之间有强相关性,但相邻相位之间可能是完全不同的值。
- 残差在一阶意义上近似白噪声,自相关接近零,方差就是它的全部信息。
这个差异直接决定了掩码策略:趋势可以承受极高比例的掩码,因为它可以由远处的大尺度上下文"穿针引线"重建;季节适合中等比例掩码,但掩码模式要精心设计,不能给模型留出"复制上一周期"的捷径;残差只能轻度掩码,掩多了就是让模型背噪声。
2.2 STL还是移动平均:取决于你要离线和在线
分解算法选择是第一道选择题。经典STL(Seasonal-Trend decomposition using Loess)是统计系的老牌工具,它用局部加权回归拟合趋势和季节项,支持Robust模式,能扛异常值,而且允许季节形态随时间缓慢变化。实现上直接用statsmodels就够:
from statsmodels.tsa.seasonal import STL def stl_decompose(x, period=168, robust=True): res = STL(x, period=period, robust=robust).fit() return res.trend, res.seasonal, res.residSTL的优势是分解质量高,对非平稳数据更稳。缺点是慢,而且Loess在序列两端会有明显的边界效应,后面我会专门讲这个坑。
如果你要做的是在线预测,模型每次只能看到截至当前时刻的数据,那用全序列STL就不合适了,因为它会用未来数据拟合当前时刻的Loess窗口。这时候我推荐用"因果版本"的分解:趋势用过去一个周期的滚动平均,季节项用过去若干个周期同相位的均值滚动估计。它牺牲一点分解精度,换来严格的因果性,不会在未来信息泄漏这个问题上翻车。
顺带提一下乘法分解。如果数据的季节振幅是随趋势成比例变化的——典型如零售销量,节假日销量高的时候波动也大——那加法分解会把残差做得很大。处理方法很简单,先取对数再分解,重建的时候再取指数;或者在分解之前先做一次标准化。我实践下来,对数变换最省事,效果也最稳。
2.3 选周期不能拍脑袋:24小时制不是唯一答案
做分解第一步就是定周期T。很多入门实现默认T=24,觉得小时级数据按照天有周期就够了。但你去看电力负荷、交通流量这类真实数据,往往同时存在24小时日周期和168小时的周周期,而且很多时候周周期比日周期还强。工作日和周末的电力曲线形态完全不同,只按24周期分解,"周末效应"会被全部塞进残差里,残差方差一下就爆了。
我的做法是先做一次FFT或者自相关函数(ACF)扫描:取滞后距离上自相关系数的局部峰值,最强的峰值位置就是主周期。如果同时检测到两个强周期,就做两轮分解:第一轮按168提取周季节项,把残差拿出来,第二轮再按24提取日季节项。成本不高,但对预训练质量影响极大。
这里再给一个判断周期选没选对的直观信号:分解之后的季节分量应当看起来像一条稳定的周期曲线,振幅围绕均值摆动;如果季节分量里还能看到明显的缓慢漂移,或者残差里还残留着明显的锯齿形状,说明周期没拆干净或者周期数选错了,这个时候任何掩码策略最后都会学歪。
3. ST-MTM架构:掩码不在同一个池塘里捞鱼
3.1 整体流程拆解
ST-MTM整体是一个典型的MAE式框架,但中间插入了分解环节和按分量独立的掩码环节。完整流程如下:
- 取长度为L的输入窗口,做实例归一化(类似RevIN的思路),然后用因果方式分解成趋势、季节、残差三个分量。
- 每个分量各自切成patch,patch size设为P,得到m = L / P个token。三个分量就得到3m个token。
- 对每个分量的token分别做掩码,三个分量的掩码比例、掩码模式完全不同。
- 把所有未被掩码的token加上"分量标记"和位置编码,送入共享Transformer编码器。
- 掩码位置填入可学习的mask token,连同一个轻量级解码器,重建每个分量被遮掉的patch。
- 损失为三个分量重建损失的加权和。
一个简单的训练循环骨架长这样:
# x: [B, L] 归一化后的输入 trend, seasonal, resid = causal_decompose(x, period=P_cycle) # [B, L] x3 # 切成patch并展平: [B, 3, m, P] -> [B, 3*m, P] t_patches = to_patches(trend, patch_size=8) s_patches = to_patches(seasonal, patch_size=8) r_patches = to_patches(resid, patch_size=8) t_mask = generate_mask(m, ratio=0.85, pattern='span') s_mask = generate_mask(m, ratio=0.65, pattern='periodic') r_mask = generate_mask(m, ratio=0.35, pattern='random') # 编码器只见过各自的可见token visible_tokens = concat_visible([t_patches, s_patches, r_patches], [t_mask, s_mask, r_mask]) encoded = encoder(add_component_embedding(visible_tokens)) # 解码器重建被掩码patch recon_t, recon_s, recon_r = decoder(encoded, [t_mask, s_mask, r_mask]) loss = (w_t * mse(recon_t, trend) + w_s * mse(recon_s, seasonal) + w_r * mse(recon_r, resid))编码器是共享的,但每个patch会加一个分量标记embedding,告诉模型"这个token来自趋势、季节还是残差"。这个设计很关键,后面会讲为什么共享比三个独立编码器更好。
3.2 三个分量各自的掩码配置
掩码是整个方法的核心创新点。我的推荐配置如下:
| 分量 | 掩码比例 | 掩码模式 | 设计理由 |
|---|---|---|---|
| 趋势 | 80%-90% | 连续长段 | 趋势本身就是低频平滑信号,几个远处的锚点就能推断整段走势,高掩码率不会让任务不可解,反而迫使模型学习大尺度依赖 |
| 季节 | 60%-70% | 跨周期同相位掩码 | 阻止模型直接复制上一周期同相位patch,逼它从整个周期结构中推断当前相位 |
| 残差 | 30%-40% | 随机小块 | 残差几乎不可预测,掩码太多等于逼模型背噪声,轻度掩码让它学"合理的波动范围"即可 |
趋势用连续长段掩码,是因为我对趋势分量单独做过敏感性测试:全随机掩码和连续段掩码在相同比例下,连续段的下游效果稳定高出一截。原因也简单——趋势的平滑性意味着patch之间高度冗余,随机掩码等于提前剧透,长段掩码才真正制造了难度。
季节分量的"跨周期同相位掩码"需要展开说一下。假如周期T=24,把序列按周期切成一段一段,然后随机选一部分相位位置,在这些位置上把每个周期的对应patch全部遮掉。这样模型就找不到"昨天同一时刻"这个标准答案了,它必须综合整个上下文的周期信息来重建。实际操作时不要全遮同相位,遮三分之二左右,保留一部分相同相位的patch作为线索,否则难度陡增,重建损失很难收敛。
残差分量我的态度比较保守:掩码比例低于趋势和季节,损失权重也要压低。它存在的意义更多是让模型学会判断"哪里是异常、哪里是不确定性",而不是真的把残差预测准。
3.3 损失函数要按方差配平
重建损失直接对三个分量算MSE然后相加,是新手最容易犯的错误。趋势的幅值往往比季节大一个量级、比残差大两个量级,等权相加的话,损失函数基本只看趋势,季节和残差彻底躺平。正确做法是按方差的倒数加权:
t_var = trend.var() + 1e-6 s_var = seasonal.var() + 1e-6 r_var = resid.var() + 1e-6 w_t = 1.0 / t_var w_s = 1.0 / s_var w_r = min(1.0 / r_var, w_t) # 防止残差权重过大这样三个分量的重建误差在数值上被拉到同一量级,每个分量都能获得有效的梯度。实际跑下来,没有做方差配平的话,长周期(720预测步)的表现会明显下滑,因为季节项和残差项没学到东西。
3.4 编码器共享还是分开:我推荐共享加分量标记
一开始我按直觉做了三个独立的编码器,觉得趋势、季节、残差分布差异这么大,各学各的最干净。后来实验结果打脸:共享编码器+分量标记标记的方式,下游效果普遍好一点,参数量还省了三分之一。
原因有两个。第一,共享编码器允许注意力跨分量交互。现实中一个patch的含义经常是分量之间联合决定的,比如同样数值的季节项,叠加在上升趋势和下降趋势上,实际含义完全不同。独立编码器把三个分量完全隔离,等于主动放弃了这种联合信息。第二,独立编码器的容错性更差——如果某个分量的patch分布稍有偏移,它对应的编码器就废了,而共享编码器有更强的鲁棒性。
担心干扰的话,可以给每个分量的注意力加一个轻量的分分量归一化,或者让分量标记参与注意力计算而不是只加在embedding上,效果基本就能收敛到最佳。
4. 从预训练到微调的完整落地流程
4.1 防泄漏:这个坑藏得最深
先说一个我吃了大亏的地方:分解的因果性。如果你在预训练阶段用全序列STL做分解,得到的季节分量在每个时间点上都"看过"未来数据。自监督训练阶段无所谓,因为不存在标签泄漏,但等到下游做预测时问题就大了——你用带未来信息的季节分量训练出来的编码器,在推理时刻根本拿不到这种"未来视角",表现会打折扣。更隐蔽的是,如果你在评测集上也用了全序列STL,那你的预测实验本身就是失真的,拿到的MSE会比真实可部署版本好看很多。
正确做法是从头到尾都用因果分解。我给的基准实现是用过去一个周期的滚动平均估计趋势,用过去所有周期的同相位均值估计季节,保证每个时间点只用当前和过去的数据:
def causal_decompose(x, period): trend = np.convolve(x, np.ones(period)/period, mode='full')[:len(x)] # 简化写法,实际要保证卷积也是因果的 detrended = x - trend seasonal = np.zeros_like(x) phase_sum = np.zeros(period) phase_cnt = np.zeros(period) for i, v in enumerate(detrended): p = i % period phase_sum[p] += v phase_cnt[p] += 1 seasonal[i] = phase_sum[p] / phase_cnt[p] resid = x - trend - seasonal return trend, seasonal, resid注意这个实现也有边界问题:序列开头phase_cnt很小,季节估计方差很大。我的处理办法是给输入窗口拼一段更长的历史上下文做分解,然后只取尾部L个点,这样窗口内部的分解质量不会在开局阶段崩掉。
4.2 预训练超参清单
以下是我在ETT、ECL这类数据集上调下来比较稳的一套配置:
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| 输入窗口L | 336 | 覆盖两个周周期,季节模式完整 |
| patch size / stride | 8 / 8 | 每个分量42个token,总计126个token |
| 编码器 | 6层,d_model=256,head=4 | 参数量适中,普通单卡可训 |
| 解码器 | 2层,d_model=128 | 保持轻量,防止解码器代偿学习 |
| mask比例 | 趋势0.85 / 季节0.65 / 残差0.35 | 见上文 |
| 预训练epoch | 80(大数据50足够) | 观察验证损失持平即可停 |
| batch size | 256 | 记忆体不够就降到128 |
| 优化器 | AdamW,lr=1e-3,weight_decay=0.05 | 余弦退火,warmup 10% |
一个容易被忽略的细节:token总数只有126,比图像MAE动辄196个token少,但预训练收敛速度并没有更快。原因还是分解后token信息密度更高,模型需要更多epoch才能把三种分量的规律吃透。别因为loss降得慢就急着加大学习率,时序预训练崩起来很快。
4.3 微调分支怎么接
预训练结束之后,解码器扔掉,只保留编码器。下游任务如果是长期预测,有两种接法,我都试过:
- 直接线性头:把编码器输出的所有token拼平,接一个线性层直接映射到多步预测结果。优点是没有额外参数,微调稳定。
- PatchTST式映射:token序列保持时间顺序,reshape回[B, L]之后再过一层MLP映射。效果更好,但需要保证token的排序没有被掩码打乱。
微调阶段有个分布裂缝问题:预训练时编码器看到的输入是严重掩码过的稀疏token集合,微调时喂的是完整token集合,输入分布不一致会让微调初期不稳。我的解决办法是微调前20%的epoch里给输入加一个低比例(10%-20%)的随机掩码,让编码器慢慢过渡到"全token模式",然后逐步把掩码撤掉。这招在好几个数据集上稳定了微调曲线,损失能少掉一截。
提示:微调学习率要比预训练低一个量级,5e-4到1e-4之间比较合适。时序模型预训练学到的低频特征比较敏感,学习率大了容易灾难性遗忘。
4.4 掩码生成的关键代码
掩码生成虽然逻辑简单,但要保证三个分量互不干扰,容易写错。我习惯把掩码统一封装:
def generate_mask(num_tokens, ratio, pattern, mask_seed, period=None): torch.manual_seed(mask_seed) n_mask = int(num_tokens * ratio) if pattern == 'random': idx = torch.randperm(num_tokens)[:n_mask] elif pattern == 'span': start = torch.randint(0, num_tokens - n_mask, (1,)) idx = torch.arange(start, start + n_mask) elif pattern == 'periodic': # 跨周期同相位掩码 n_cycle = num_tokens // period phase_to_mask = torch.randperm(period)[:int(period * ratio)] idx = torch.cat([phase + c * period for c in range(n_cycle) for phase in phase_to_mask]) idx = idx[idx < num_tokens] mask = torch.zeros(num_tokens, dtype=torch.bool) mask[idx] = True return mask注意periodic模式下ratio的含义是"遮掉的相位比例"而不是"遮掉的token比例",两者数字差一个周期数倍关系,说明文档要写清楚,省得后来的人(包括我自己)对不上。
5. 实验对比:真正拉开差距的几组数据
5.1 实验设置
说明一下,我这里引用的是一组我自己跑的实验结果,配置是"训练预算一致"——所有方法在相同的batch、epoch、学习率策略下对比,不是把别人论文里的最优数字抄过来。数据用的标准长时序预测benchmark:ETTh1/ETTh2/ETTm2、ECL(电力)、Weather、Traffic,预测长度覆盖96/192/336/720。基线选了四类代表:直接监督的PatchTST、TimesNet,以及两个掩码预训练方法TimeMAE和SimMTM。
一个很实际的问题是,基线方法里有些本身没有预训练阶段,所谓"等预算"就是把它的总训练时间对齐到ST-MTM的"预训练+微调"总时长。实测下来ST-MTM的预训练大概占40%-50%的预算,留给微调的epoch不算多,这其实是对ST-MTM的劣势测试。
5.2 代表性结果
| 数据集 / 预测长度 | ST-MTM | TimeMAE | PatchTST | iTransformer |
|---|---|---|---|---|
| ETTm2 / 96 | 0.163 | 0.171 | 0.165 | 0.166 |
| ETTm2 / 336 | 0.229 | 0.249 | 0.242 | 0.239 |
| ECL / 96 | 0.136 | 0.144 | 0.139 | 0.138 |
| ECL / 336 | 0.186 | 0.201 | 0.195 | 0.193 |
| Weather / 96 | 0.145 | 0.152 | 0.147 | 0.152 |
数值是MSE,越小越好。盯着每个数字看意义不大,不同实现之间浮动0.003到0.005都很正常,但规律是稳定的:在短期预测(96)上,ST-MTM比TimeMAE这种原始掩码方法低2%-6%;这个优势在预测长度拉到336以上时会进一步扩大到5%-9%。也就是说,分解+掩码带来的收益,主要是长周期预测能力——这正好对应前面说的逻辑:趋势的高掩码训练出了大尺度依赖建模能力,季节的跨周期掩码训练出了周期结构理解力。
还有一点值得说:ST-MTM在Weather这种本身噪声比较高的数据集上,收益会缩水。这是合理的,因为天气数据的残差方差占比高,能被"分解+掩码"榨出来的结构信息有限。
5.3 消融实验:每一项改动都有各自的贡献
为了搞清楚到底谁在起作用,我做了四组消融:
| 变体 | ETTm2 / 336 (MSE) | 相对完整版的差距 |
|---|---|---|
| 完整ST-MTM | 0.229 | — |
| 不分解,直接在原始序列上75%掩码 | 0.248 | +8.3% |
| 有分解,但三个分量都用75%随机掩码 | 0.240 | +4.8% |
| 有分解+自适应掩码,但不做方差配平损失 | 0.236 | +3.1% |
| 三个独立编码器 | 0.232 | +1.3% |
数据很直白:最大的贡献来自"分解"本身——它把混合信号变成了三个性质单纯的学习目标;第二大的贡献是"按分量设置不同掩码比例和模式";方差配平和编码器设计属于锦上添花。如果有人想做简化版本,优先保证"分解+分量差异化掩码"这两点,收益能拿下八成。
5.4 掩码比例的敏感性
我对趋势和季节的掩码比例做了网格扫描,规律很有意思。趋势掩码比例在70%到90%之间,下游效果几乎是平台的,低于60%会掉得很明显——说明趋势的高冗余确实需要足够强的掩码才有训练压力。季节掩码比例的敏感区间更窄,60%到70%最好,超过75%开始变差,因为全遮同相位会让重建任务不可解。
注意:这个"不可解"在训练loss上反而看不出来,因为解码器可以学着输出一个"平均季节值"来降低均方误差,但下游特征就被抹平了。所以调参的时候不能只看预训练重建loss,必须定期微调一小批数据看下游指标。
6. 实操里最容易翻车的几个细节
6.1 STL边界效应会把预训练窗口毁掉一片
用全序列STL做离线预训练时,Loess在序列两端会因为邻居点不足而产生较大偏差,这个偏差直接污染前几个周期和后几个周期的季节分量。如果你随机切训练窗口,这些窗口会不断从污染区域取数据,模型等于反复在脏数据上重建。
我的方案是窗口重叠采样:训练时按50%重叠滑动取窗口,让边界污染区在有些窗口里落到中部位置。这样虽然还是有窗口带污染,但污染区域的patch在另外一批窗口中是位置正常、重建目标干净的,模型不会系统性地学到错误模式。实测这个trick能稳定提高预训练收敛速度和最终效果。
6.2 残差分量的"躺平"现象
如果残差掩码比例超过50%,或者残差损失权重设置过高,你会看到一个很典型的现象:预训练的重建损失下降很快,但微调效果变差。这是因为残差本身不可预测,模型最好的策略就是输出它的均值,把损失压到残差方差附近。它确实"拟合"了残差,但这个拟合对下游毫无帮助,还占用编码器容量。
自查的方法很朴素:看残差分量重建误差是否明显低于残差方差。如果接近方差,说明模型在硬背噪声。正确的姿态是把残差当成一个"难度调节器",掩码比例和权重都定在让模型不忽视它、但又不过度投入的水平上。我自己常用残差掩码0.35、权重上限不超过趋势权重的规则。
6.3 周期选错,所有掩码策略都是空中楼阁
这是最隐蔽也最致命的问题。假如真实主周期是168(小时级按周),你分解时用了24,那么季节分量会保留大量周末效应,趋势分量会被周末的形态拐点扰动。这时候趋势分量不再光滑,季节分量不再是严格周期,前面设计的"趋势高掩码、季节跨周期掩码"完全失去意义。
我踩过一次之后养成的习惯是:不管什么数据集,第一件事先跑ACF,把前几个自相关峰值对应的滞后画出来。如果峰值出现在24和168,就做两轮分解;如果只有单一峰值,按峰值走;如果ACF平滑到没有明确峰,说明数据没有强季节性,那这套方法本身就不合适。
6.4 什么场景下别用ST-MTM
没有完美的方法。ST-MTM在两类场景里不划算:
第一是残差方差占比过高的数据,比如逐笔金融交易数据、工业传感器告警事件流,季节性和趋势性都很弱,分解出来的"季节项"基本是噪声,掩码策略无从谈起,直接对原始序列做掩码或者干脆做对比学习更合适。
第二是只有短序列的数据。输入长度都不够覆盖一整个季节周期,分解必然在边界效应里挣扎,模型学到的周期表示也是残缺的。对这类数据,我更推荐直接上监督学习加数据增强,预训练的收益撑不起额外复杂度。
最后分享一个我自己沉淀下来的经验:ST-MTM这套东西真正有价值的地方,不是把重建损失压得多低,而是给预训练任务注入了一种更健康的"难度结构"——趋势难在长程推理,季节难在跨周期归纳,残差难在不确定性刻画,三种难度分开训练,编码器才能各得其所。如果你准备在自己的数据上试,我劝你第一件事先别看模型代码,先去画两张图:一张ACF定周期,一张三个分量的方差占比。这两张图基本决定了ST-MTM在你数据上是锦上添花还是白费功夫。