资讯详情

R语言实战:基于KNN聚类的时间序列预测方法解析

📅 2026/10/15 13:51:24 | 华诺云谱 👁 阅读
R语言实战:基于KNN聚类的时间序列预测方法解析
大概半年前接了一个实际需求预测某工厂循环水系统的运行负荷为调度提供短期参考。数据拿回来倒是简单一天24个小时一个点连续一个月。可问题在于精度——ARIMA、指数平滑这类传统模型遇到周期性叠加随机波动、偶尔再来点异常数值的工业数据误差一下就难看了。后来我把思路换了一下用R语言做了个KNN聚类加最近邻匹配的时间序列预测方案。这个“R语言实战——基于KNN聚类的时间序列分析预测”思路解决的就是这类“模式重复出现但关系非线性”的预测问题先用聚类识别历史中的典型运行状态再用KNN找到当前时刻最相似的历史邻居用邻居的后续走势来完成预测。适用于有R基础、想从统计模型往机器学习过渡的人群也适合做预测的同学拿来当一个高性价比的baseline。先说清楚标题里“KNN聚类”这个写法严格来说不算标准术语。KNN是监督学习里的最近邻算法聚类通常指K-Means。实战里我理解成两步走先用聚类划分状态再在状态内做KNN回归。这篇文章就是把这套组合过程完整体现一遍包括背后的原理、R代码实现、以及我踩过的坑。1. 为什么时间序列预测我会先想到KNN加聚类1.1 传统统计模型的局限在哪大多数入门时序预测的人第一反应都是ARIMA。ARIMA本身没毛病但它有两个隐含前提一是线性关系二是序列平稳。现实中的负荷数据、流量数据、销量数据几乎都不会完全满足这两条。拿我这次碰到的负荷数据来说白天和黑夜完全是两种形态周末和工作日又是两种形态连趋势都带着缓慢爬升。你当然可以通过差分、Box-Cox变换把序列尽量拉成平稳的但变换完之后模型拟合的其实是一个被“掰弯”过的世界预测结果往往在波峰波谷处偏差明显。深度学习方案倒是能拟合复杂非线性比如LSTM、Transformer这一类。但问题是中小项目根本没有足够的样本量也没有那么多时间调参。而且业务方经常问一句“为什么预测值是这样”你很难用神经网络内部结构给出让人信服的解释。于是中间地带的机器学习方案就值得优先考虑KNN加聚类正好落在这一层能拟合非线性、模式识别能力强、结果还能拆开讲给业务听。1.2 KNN加聚类到底想解决什么问题时间序列预测换个角度看其实就是“找到历史上最像当前的一段看看它后来怎么走”。天气预报里的“相似历史日”方法就是这个思路找到过去气温、气压、湿度最像今天的几天再看那几天之后下不下雨。时序预测里把这种思路算法化就是KNN最近的邻居匹配。但直接做KNN有一个隐患如果历史数据里存在多种截然不同的运行状态比如工作日白天的高负荷模式和周末夜间的低负荷模式它们的特征向量差得很远KNN在找邻居时很可能把不同状态的样本混在一起取平均。明明当前是工作日白天结果邻居里混进来一堆周末样本预测自然被拉偏。所以需要先做一个聚类步骤把历史样本按运行状态分成几簇。预测时先判断当前属于哪一簇然后只在簇内找最近邻居。这样既避免了跨状态匹配的问题也让每个簇变成一种可解读的“典型工况”。1.3 什么时候不适合这套路必须承认KNN聚类不是万能药。以下几类场景我建议直接换方案序列呈现强趋势外推、历史数据中从未出现过的新模式、数据量太小KNN对样本密度还是有点要求的、特征维度特别高距离度量会退化。另外如果你要预测的是受促销活动、政策调整等强外部变量影响的销量KNN只看历史模式会非常吃亏这种情况下用带外部变量的回归模型或者树模型会更合适。反过来看适合这套路的场景也很明确周期性明显、模式稳定重复的数据。设备负荷、网站流量、仓库出货量、环境监测指标都属于这一类。我更愿意把它当做一个“强baseline”先跑通KNN聚类预测再决定要不要上更重的模型。2. 先把概念理清楚KNN、K-Means和时序特征2.1 别再把KNN和聚类混为一谈很多初学者会把KNN和聚类搞混因为缩写都有K。我用一个生活类比帮朋友理清过K-Means聚类特别像“分班”把成绩、性格相近的学生分到同一个班这里没有标准答案老师是拿学生的多维特征算出相似度然后自然分出几个群体。而KNN像“看邻居”你想知道新来的同学考试能考多少分找到住得最近的几个邻居看他们考了多少分取个平均。这里是有标准答案的你得先把邻居的分数拿到手才能估计新同学的分数。在时序预测里K-Means负责把历史窗口向量归成几类KNN负责在同类里挑最近的邻居、用邻居的下一期值做预测。所以标题里的“KNN聚类”更准确的表述是“K-Means聚类 KNN回归的组合应用”大家看的时候别被名词绕晕。2.2 在时间序列预测中它们怎么配合整个配合流程可以用四步概括第一步把历史序列切成长度固定的滑动窗口每个窗口变成一个特征向量第二步对这些特征向量做K-Means聚类把相似的负载形态归到一起第三步新数据到来时计算它到各个簇中心的距离判定当前属于哪个运行状态第四步只在该状态对应的历史窗口里跑KNN回归找到K个距离最近的邻居加权平均它们的“下一时刻值”得到预测结果。这里有个细节值得说KNN中的距离度量我默认用欧氏距离但前提是所有特征已经标准化。如果直接用原始数值计算距离量纲大的特征比如负荷本身会完全压过量纲小的特征比如小时的正弦编码距离就失真了。标准化是这整套流程的地基。2.3 特征窗口才是真正的建模基础把时间序列转成机器学习能吃的样本靠的是滑动窗口。比如我们预测负荷可以对第t时刻的样本用过去24小时的负荷值组成特征去预测第t1时刻的负荷值。这个“过去24小时”的窗口长度通常要结合业务周期来定一个自然日24小时就是最常见的选择。窗口内不只放原始负荷值我还会加上滚动统计量比如过去24小时的均值、标准差、最大值、最小值。这些统计量能捕捉窗口的整体水平和波动幅度对识别状态非常有用。另外一个很容易被忽视的特征是时间本身小时、星期几。如果把小时直接当成数值特征23点和0点虽然只差1小时数值上却差了23这会让距离计算产生荒谬结果。解决办法是把小时做正弦余弦编码映射到单位圆上同理星期几也做同样的编码。这个处理对于KNN这种强依赖距离的算法属于必须做的一步。3. 数据准备与特征工程动手前必须做好的事3.1 项目数据背景与目标定义为了把流程讲清楚我用一段模拟数据来复现这次项目的核心逻辑某设备的运行负荷小时级采集连续24天也就是576个时间点。数据本身带有明显的日周期白天负荷高、夜间负荷低周末整体回落同时叠加了随机噪声和一段缓慢上升趋势。这段数据在结构上非常像我在项目里遇到的真实工业负荷数据差别只是真实数据里有更多不规则的毛刺。目标定义为站在t时刻用t时刻及此前24小时的信息预测t1小时的负荷值。这是一个典型的单步预测问题长周期多步预测我会在后面的调参心得里专门讨论。3.2 预处理和特征构造要点动手写特征工程之前建议先看一遍序列的缺失和异常。我的处理习惯缺失值用线性插值异常值用滚动中位数加减3倍滚动标准差的方式识别出来然后剔除或者做平滑替换。这次模拟数据没有弄脏需求就直接跳过清洗但真实项目里这一步省不掉。特征构造的完整清单如下表特征类别具体特征说明滞后值lag_1 到 lag_24过去24小时的负荷值构成窗口主体滚动统计量均值/标准差/最大/最小值过去24小时的形态描述宽窗口今天整体水平周期编码hour_sin/hour_cos将小时映射到圆上符合时间周期性星期编码wday_sin/wday_cos区分周末与工作日预测目标next_load下一时刻的真实负荷训练时作为监督标签构造的时候要注意滞后和目标的错位lag_i 表示 t 时刻之前第 i 个小时的负荷next_load 表示 t 时刻之后1小时的负荷。每生成一行样本就相当于是用一个过去的窗口去对应一个未来的结果。3.3 训练集与测试集的划分陷阱时序预测的样本划分绝对不能随机抽样。很多人容易顺手用sample()打乱数据这对普通回归没问题对时序就是灾难未来的样本如果混进训练集等于让模型“偷看”了未来。我的做法是按时间顺序切分比如前70%做训练后30%做测试。更严谨的说法是“前向验证”也就是训练集永远在测试集之前。还有一个更隐蔽的坑标准化。如果先对整个序列做标准化再划分测试集的统计量已经参与了训练集数据变换这也属于泄漏。正确做法是只从训练集算出min和max测试集转换的时候直接套用训练集的min和max。这个细节我特别想强调因为它在实际中太容易出错。4. R语言完整实现聚类加KNN预测实战4.1 生成模拟数据与探索先加载需要用到的包准备好模拟数据。这一步相当于把真实读数的部分替换成“造数”流程上完全一致。library(tidyverse) library(zoo) library(cluster) library(FNN) set.seed(42) n_days - 24 n - n_days * 24 ts_df - tibble( time seq(as.POSIXct(2025-01-01 00:00), by hour, length.out n), hour as.integer(format(time, %H)), wday as.integer(format(time, %u)) ) %% mutate( daily pmax(0.1, 0.5 0.7 * cos((hour - 15) / 24 * 2 * pi)), weekend ifelse(wday 6, 0.65, 1), trend 1 0.0005 * (1:n), load 40 * daily * weekend * trend rnorm(n, 0, 0.5) )日常高峰出现在下午15点左右凌晨3点前后最低周六周日整体负荷打65折趋势项使得每天缓慢上移。这样造出来的数据既包含周期又包含状态差异非常适合演示聚类加KNN。拿到数据后我习惯先画个曲线看一眼确认目录周期确实存在再看一眼周末的凹陷是否明显。这一步用ggplot2就能完成30秒解决问题。4.2 滑动窗口特征与标准化接下来是核心的特征构造环节。我直接在一个数据框里扩展滞后列、滚动统计量和时间编码。p - 24 for (i in 1:p) { ts_df[[paste0(lag_, i)]] - dplyr::lag(ts_df$load, i) } ts_df$next_load - dplyr::lead(ts_df$load, 1) ts_df$roll_mean - rollapplyr(ts_df$load, 24, mean, fill NA) ts_df$roll_sd - rollapplyr(ts_df$load, 24, sd, fill NA) ts_df$roll_max - rollapplyr(ts_df$load, 24, max, fill NA) ts_df$roll_min - rollapplyr(ts_df$load, 24, min, fill NA) ts_df - ts_df %% mutate( hour_sin sin(2 * pi * hour / 24), hour_cos cos(2 * pi * hour / 24), wday_sin sin(2 * pi * wday / 7), wday_cos cos(2 * pi * wday / 7) ) ts_clean - na.omit(ts_df)滞后值用lag()预测目标用lead()这组错位是时间序列监督化的关键。lag_1对应的是当前时刻前1小时的负荷next_load是当前时刻后1小时的负荷所以样本并非“未来预测未来”而是“现在窗口预测未来一步”。由于第一天的前24小时没有足够历史来生成滞后特征最后1小时也没有next_loadna.omit()会把这些行去掉。最终保留的完整样本是第25个时刻到第575个时刻的数据足够模型使用。特征和标签准备好后按时间顺序划分训练集与测试集split_idx - floor(nrow(ts_clean) * 0.7) train - ts_clean[1:split_idx, ] test - ts_clean[(split_idx 1):nrow(ts_clean), ] train_y - train$next_load test_y - test$next_load feature_cols - c( paste0(lag_, 1:p), roll_mean, roll_sd, roll_max, roll_min, hour_sin, hour_cos, wday_sin, wday_cos )标准化这里我单独写了一个函数避免把测试集信息混进训练集eps - 1e-6 scale_df - function(df, min_vec, max_vec) { out - as.data.frame(lapply(seq_along(df), function(i) { (df[[i]] - min_vec[i]) / (max_vec[i] - min_vec[i] eps) })) names(out) - names(df) out } train_min - sapply(train[feature_cols], min) train_max - sapply(train[feature_cols], max) train_feat - scale_df(train[feature_cols], train_min, train_max) test_feat - scale_df(test[feature_cols], train_min, train_max)注意eps非常小防止某些滚动特征在窗口内恒定时出现除以0的情况。实际数据里滚动标准差偶尔会出现极小值这个保险很重要。4.3 用K-Means识别典型运行状态这组特征构造好以后我先做聚类目的是把历史样本分成几个有业务含义的状态。选K值我通常会看两个指标肘部法则的组内平方和以及轮廓系数。set.seed(1) wss - sapply(2:10, function(k) { kmeans(as.matrix(train_feat), centers k, nstart 10)$tot.withinss }) print(wss)输出的组内平方和一开始下降很快后面趋于平缓那个拐点就是候选K值。再看轮廓系数sil_score - sapply(2:8, function(k) { km - kmeans(as.matrix(train_feat), centers k, nstart 10) mean(silhouette(km$cluster, dist(train_feat))[, 3]) }) names(sil_score) - 2:8 print(sil_score)轮廓系数取值在-1到1之间越接近1说明簇内越紧密、簇间距离越大。不要盲目追求最高值还要结合业务解释。我最终在模拟数据上选3个簇因为从业务上看数据天然呈现三类状态工作日白天高负荷、工作日夜间接平缓、周末整体偏低。选3既符合统计指标也方便把聚类结果讲给业务人员听。确定K值后重新跑一次完整聚类set.seed(1) km_final - kmeans(as.matrix(train_feat), centers 3, nstart 25) train$cluster - km_final$cluster centroids - km_final$centers print(centroids)聚类中心是标准化后的特征平均看中心向量就能反推每个簇的特点。比如某个簇的hour_cos明显为正而hour_sin接近0说明该簇集中在白天某个时段某个簇的roll_mean很高说明整体负荷处于高位。这种可解释性是K-Means在这个流程里最大的价值。测试集没有参与聚类但我们要判断它落在哪个状态里方法是计算每个测试样本到三个簇中心的距离取最小距离对应的簇test_cluster - apply(test_feat, 1, function(x) { which.min(apply(centroids, 1, function(c) sqrt(sum((c - as.numeric(x))^2)))) })这里计算的是欧氏距离因为特征都标准化过距离不会受到量纲干扰。到这一步训练集和测试集都有了簇标签预测环节就可以开始了。4.4 KNN回归预测与效果评估先用FNN包的knn.reg做最直接的组内KNN回归。所谓组内就是只在同一个簇的历史样本里寻找邻居k_value - 5 pred_knn - numeric(nrow(test_feat)) for (i in seq_len(nrow(test_feat))) { cid - test_cluster[i] in_cluster - which(train$cluster cid) if (length(in_cluster) k_value) { in_cluster - seq_len(nrow(train_feat)) } fit - knn.reg( train as.matrix(train_feat[in_cluster, ]), test as.matrix(test_feat[i, , drop FALSE]), y train_y[in_cluster], k k_value ) pred_knn[i] - fit$pred }为什么不一次性传入全部测试样本原因在于每个测试样本可能属于不同簇属于A簇的样本不应该被B簇的邻居参与平均所以分组处理更严谨。如果某个簇的样本数比k还小代码会回退到全局KNN避免报错但在数据量充足时基本不会触发。基础版的KNN回归用的是平权平均也就是所有邻居对预测值的贡献一样大。实际使用时距离更近的邻居显然更应该影响预测结果于是我还写了一个距离倒数加权的版本weighted_knn - function(x_train, y_train, x_test, k) { x_train - as.matrix(x_train) x_test - as.numeric(x_test) d - apply(x_train, 1, function(row) sqrt(sum((row - x_test)^2))) idx - order(d)[1:k] w - 1 / (d[idx] 1e-6) sum(w * y_train[idx]) / sum(w) } pred_weighted - numeric(nrow(test_feat)) for (i in seq_len(nrow(test_feat))) { cid - test_cluster[i] in_cluster - which(train$cluster cid) if (length(in_cluster) k_value) { in_cluster - seq_len(nrow(train_feat)) } pred_weighted[i] - weighted_knn( train_feat[in_cluster, ], train_y[in_cluster], test_feat[i, , drop FALSE], k k_value ) }公式上w 1 / (d 1e-6)确保距离越近权重越大同时避免零距离。这个加权版本通常比平权版更平滑在拐点位置的预测滞后会小一些。评估指标我用最常见的MAE和RMSEcalc_metrics - function(actual, pred) { c(MAE mean(abs(actual - pred)), RMSE sqrt(mean((actual - pred)^2))) } calc_metrics(test_y, pred_knn) calc_metrics(test_y, pred_weighted)在这组模拟数据上加权KNN的误差整体优于平权版本对波峰波谷的还原度也更好。如果拿auto.arima做对比ARIMA在夜间平稳段表现尚可但一到工作日午后负荷快速爬升和周末突然下跌的位置预测误差就会被明显拉开。这也是KNN聚类方案最大的优势它本质上是“用历史相似日推演当前日”天然具备对突变形态的记忆能力。library(forecast) fit_arima - auto.arima(train$load, seasonal TRUE, stepwise FALSE) pred_arima - forecast(fit_arima, h length(test_y))$mean calc_metrics(test_y, pred_arima)这段代码大家可以在真实数据上跑重点不是那串数值而是观察误差曲线集中在哪些时段。通常ARIMA误差大的位置恰好是KNN聚类方案表现最稳的位置。5. 我踩过的坑和调参心得5.1 数据泄漏与顺序问题这类项目出问题十个有八个出在数据泄漏上。最常见的三种泄漏第一对全序列做标准化再划分测试集信息间接进入了训练第二随机打乱样本再训练未来信息混进了历史第三构造滚动特征时误用了中心化窗口比如用t时刻前后的平均值当特征导致预测时“偷看”了未来数据。排查方法很简单拿到每个测试样本的特征反问自己一句“站在t时刻这些特征真的都能知道吗”答案是否就是泄漏了。5.2 K值、窗口长度、聚类数怎么选先说KNN的K值。K太小比如1预测被单个邻居主导噪声很大K太大比如20局部细节会被平均掉。我的做法很简单在验证集上扫K1到20画出误差曲线选择曲线进入平台期的那个K。一般每小时级负荷数据K5到10是常见区间。窗口长度p的选择可以看自相关图和偏自相关图但更实用的还是业务周期。预测小时级负荷24是天然选择预测周级销量窗口拉长到84甚至更多。窗口越长特征维度越高KNN在高维空间的距离区分度会下降所以不要盲目拉长。聚类数K的选择用轮廓系数做参考但最终拍板看业务解释。如果一个聚类中心描述出来的是“午夜低负荷平稳段”另一个是“午后高峰段”业务方点头就可以定下来。反之如果某个簇怎么解释都不通说明K选得不合适。5.3 预测区间与多步预测KNN相比许多黑箱模型还有个隐藏好处邻居的y值分布可以直接用来构建预测区间。比如K9个邻居的“下一时刻值”取第10和第90百分位数就得到一个近似的预测区间。这在调度场景里非常实用能直接告诉业务方“明天这个时段负荷大概率落在什么范围”。多步预测就要格外小心。继续往后预测24小时如果采用滚动迭代法每预测一步就把预测值当作已知历史拼进窗口继续预测下一步误差会逐级累积越往后越飘。我的经验是短期滚动迭代1到4步可行中期往上建议改成“直接以t时刻的窗口预测t24”也就是训练时把目标改为lead(load, 24)这样模型直接学习24小时后的形态避免误差累积。代价是训练样本里早期样本也会少24个数据量变小但结果通常比滚动累积稳。5.4 什么时候千万别用这套最后说点反直觉的经验。KNN聚类预测在“模式稳定”的数据上很香但遇到三类情况我会直接放弃第一历史数据里完全没有当前这种形态比如设备刚切换了运行策略聚类和最近邻都找不到相似参考第二特征维度超过50距离度量会稀释到几乎无法区分远近这种情况不如换树模型第三对预测延迟要求极高T1实时预测还能跑每秒要预测上千条流式数据时KNN暴力搜索就力不从心了这时只能上近似最近邻库或者换序列模型。从我个人的使用体会来说KNN聚类预测最实用的定位是给时序项目做一个可解释的强baseline。我甚至会把聚类中心保存下来新数据一到就先算簇标签、再找邻居整个流程封装成R函数接口干净。这个思路的后续扩展空间其实很大聚类结果可以直接做成运行状态识别规则喂给告警系统KNN的邻居分布可以当不确定性估计加权方案可以继续演化成核回归。先跑通这套再谈更复杂的模型是我在时间序列分析预测项目里最常用的路径。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑