做监控大屏和时序分析项目的时候你可能也遇到过这种尴尬数据库里取出来一百万个点图表接口把数据全量扔给前端浏览器画出来直接是一坨黑线鼠标拖不动加载转圈转得人心慌。更麻烦的是想抽稀吧均匀抽稀把尖峰毛刺全干没了滑动平均更是把曲线抹成老太太怎么看怎么不像原始数据。后来我把 LTTBLargest-Triangle-Three-Buckets算法搬进来同样的数据、同样的目标点数画出来的走势跟原始曲线几乎一个模子刻出来的前端加载速度却快了十几倍。今天就把这个算法从原理到代码、再到工程落地里的各种坑一次性讲透。这篇东西适合谁一类是做可视化系统的前端/全栈工程师天天被大数据量曲线卡得头疼一类是做时序数据分析、量化回测、GNSS 时间序列处理、甚至深度学习时序预测的工程师需要在不破坏趋势形态的前提下压缩数据。如果你只是拿到一张几万行的表想随便画画图那直接用可视化库自带的抽稀就行不用往下看但如果你对曲线形态有要求或者想给 LSTM 之类的模型做预处理LTTB 会是帮你省下大量时间的好东西。1. 为什么要给时间序列降维1.1 一个让人头疼的高频场景时序数据大概是所有数据形态里最容易“撑爆”基础设施的一种。传感器每秒采一条一天就是 86400 条GNSS 基准站接收机 1Hz 或 5Hz 的观测值解算下来一个月的数据量轻松过百万量化交易里的逐笔委托一分钟就能产生上万条记录。屏幕分辨率就那么大1920 像素宽的浏览器面板其实最多只能容纳两三千个点不重叠。超过这个量级画出来的不是曲线是一团墨。于是“降维”就成了绕不开的环节。很多人第一反应是“干脆后端查数据的时候 limit 一下”但这样干有几个致命问题抽样起点不同曲线每次画出来都不一样均匀取点会漏掉尖峰和突变而时序数据里恰恰是尖峰和突变最值钱。做异常检测的、做故障排查的看到一条被抽平了的曲线等于没有看到任何信息。1.2 降维不是简单删点真正合格的时序降维不是把数据“删少”而是把数据“压缩”成一份信息密度相近的稀疏版本。我个人的判断标准是三个词形状保真、极值保留、趋势稳定。形状保真就是降采样后的曲线跟原始曲线叠在一起肉眼几乎看不出差别至少走势不能走样。极值保留是所有局部峰值、谷值、突变点都必须留下来因为监控场景里这些点通常意味着告警、异常、转折。趋势稳定是说降维不能改变序列的单调性、周期性形态比如原本一个明显上升段不能抽完变成平的这会让后续分析得出错误结论。这三个要求听上去简单同时做到很难。均匀抽稀保不住极值滑动平均会改变趋势而真正能同时兼顾的算法不多LTTB 就是其中一个。1.3 谁适合用 LTTBLTTB 最典型的应用场景是数据可视化但它能去的地方远比这多。我实际用下来的几个方向监控大盘/实时曲线前端需要绘制上千条指标曲线后端只传输降采样后的结果节省带宽和渲染开销。数据分析预处理跑一些统计模型之前先用 LTTB 把几百万点压到几万点后续计算速度快很多且对结论影响很小。深度学习时序预测在用 LSTM、Transformer 等模型做时间序列预测之前把高频数据降采样到一个合理的输入长度模型训练更快特征也更突出。科学仪器观测数据GNSS 时间序列、气象站点数据、地震波数据的存储和快速浏览先降维再入库、先降维再出图都是很成熟的做法。反过来有一种情况我强烈不建议用 LTTB如果你需要做精确的统计计算比如求原始数据的均值、方差、峰值精确位置或者做精度要求高的归算降维后的数据不能替代原始数据参与计算。LTTB 是“看起来像”不是“算起来同”这个边界必须划清楚。2. LTTB 核心思想与算法原理2.1 几何直觉三角形面积最大LTTB 的全称是 Largest-Triangle-Three-Buckets翻译过来就是“最大三角形三桶算法”。它最早出自 Steinarsson 在 2013 年发表的论文最初就是为了解决大规模时间序列可视化时的抽稀问题。核心思想一句话就能说清在每一段区间里选一个点让“上一个已选点、当前候选点、下一段的平均中心点”组成的三角形面积最大。为什么要看三角形面积这里有个很漂亮的几何直觉。面积越大的三角形说明三个点之间的连线“弯折”得越剧烈也就是数据形态变化最集中的地方。我们保留的是这种变化最剧烈的点自然就把曲线的骨架留下来了。你可以想象在一块木板上钉三颗图钉图钉之间的距离撑得越开说明它覆盖的区域越能代表局部形态的起伏。生活里也有类似的逻辑。你拍一段跑步视频要压缩成几帧 GIF肯定会保留起跑、冲刺、撞线这种动作变化大的瞬间而不是每秒钟都挑一张姿势差不多的画面。LTTB 做的事就是自动判断哪一帧“姿态变化最大”。2.2 分桶逻辑与算法步骤LTTB 的完整步骤可以拆成四步首尾固定。第一个点和最后一个点必定保留因为它们是序列的边界是信息锚点。确定桶的大小。假设原始数据有 n 个点目标降维到 threshold 个点去掉首尾两个点后中间还剩 threshold - 2 个点要选。于是把索引 1 到 n-2 之间的区域均匀切分成 threshold - 2 个桶每个桶的宽度是 (n - 2) / (threshold - 2)。逐个桶计算三角形面积。对于当前桶里每一个候选点用“上一个已选中的点 A”“当前候选点 B”“下一个桶所有点的坐标平均值 C”构造三角形计算面积选择面积最大的那个点作为这个桶的代表点。遍历完所有桶后把最后一个点追加进去得到最终降维序列。注意第三步里的 C 点不是真实数据点而是“下一个桶内所有点的平均位置”。这个设计很聪明让每个桶的选择既依赖上一段的实际走向又瞄着下一段的整体趋势而不是被某个孤立点带偏。这样做出来的抽稀结果天然带有一种“预测下一步走势”的意味。2.3 平均点 vs 真实点的细节考量你可能想问为什么下一桶要用平均点而不是像普通算法那样直接用下一个真实点因为真实点里可能藏着噪点或异常值拿它当参照物很容易把当前桶的选择带歪。平均点相当于对下一段趋势做了一次平滑它代表的是这一段数据的“重心”。这里有论文和后续实现里反复被验证的经验用平均点比用中点或端点稳定得多。我自己在带毛刺的传感器噪声数据上对比过用平均点的版本抽稀后曲线明显更干净极少出现因为参照点偏移导致连续桶之间出现锯齿跳变。这个设计看起来简单实际是 LTTB 能保持形态的关键所在。3. 主流时序降维方案横向对比3.1 均匀抽稀最省事但最不保形均匀抽稀就是每隔 K 个点取一个代码三行写完复杂度 O(n)速度最快。但它的缺点同样明显尖峰、突变点很可能落在被跳过的区间里而且如果数据本身的采样间隔不均匀抽出来的时间轴还是乱的。在曲线平缓的场景下勉强能用一旦数据波动剧烈均匀抽稀基本等于“随机丢数据”。3.2 滑动窗口平均平滑但丢细节滑动窗口平均是先把窗口内的点算一个均值然后输出均值作为一个新点。它能把高频噪声压下去让曲线看起来更“顺滑”但副作用是所有的极值都被抹平了峰值高度显著下降原本尖锐的毛刺变成了缓坡。对需要观察异常突变的场景来说这是不可接受的。3.3 Min-Max 抽稀保极值但点数膨胀为了保住极值有人会在每个窗口里同时保留最大值和最小值两个点也就是 Min-Max 抽稀。这样极值保住了但点数会比均匀抽稀多一倍且对曲线的整体形态贡献有限。更麻烦的是如果窗口内噪声很大Min-Max 反而会把噪声当作“极值”留下来曲线看起来更毛糙。3.4 方案对比表算法保形能力极值保留复杂度适用场景均匀抽稀差差O(n)快速浏览、低质量需求滑动窗口平均中差O(n)降噪、平滑Min-Max 抽稀中好O(n)需要峰值信息的场景LTTB好好O(n)可视化、模型预处理从表里能看出来LTTB 在综合表现上几乎没有短板。它复杂度同样是 O(n)但保形能力和极值保留都明显优于前三种方案。这也是为什么它这几年在国内的时间序列处理项目里出镜率越来越高。3.5 为什么 LTTB 在可视化里更稳我做过的几个项目里最直观的感受是LTTB 抽稀后的曲线和原始曲线在 CadenceViewer 里叠图几乎看不到两条线的分叉尤其在一些周期性的波形上LTTB 连波峰波谷的交替节奏都保留得相当完整。其他算法一到高频段就容易断崖式失真。这套算法的优势在于把“形态”作为第一优先级而不是单纯地把点删掉所以工程上非常可靠。4. Python 实现 LTTB4.1 一个可用的最简版本实现网上关于 LTTB 的实现不少但很多是纯 Python 循环数据量一上来就慢得没办法用。我给出的是经过向量化优化的版本直接可以用 numpy 跑几百万个点的数据也能接受。import numpy as np def lttb(data: np.ndarray, threshold: int) - np.ndarray: LTTB 降维算法 :param data: 二维数组每行为一个点 [x, y] :param threshold: 目标点数 :return: 降维后的二维数组 if threshold data.shape[0]: return data if threshold 3: return data[[0, -1]] data np.asarray(data, dtypefloat) n data.shape[0] # 每个桶包含多少原始点首尾两点固定所以分母减 2 every (n - 2) / (threshold - 2) result [data[0]] a 0 # 上一次选中的点的索引 for i in range(threshold - 2): # 当前桶区间 range_start int(i * every) 1 range_end int((i 1) * every) 1 # 防止最后一个桶越界导致空切片或缺失 range_end min(range_end, n) # 下一桶的平均中心点 avg_start range_start avg_end range_end if avg_end avg_start: avg_end avg_start 1 avg_x data[avg_start:avg_end, 0].mean() avg_y data[avg_start:avg_end, 1].mean() # 向量化计算当前桶内每个候选点的三角形面积 x_a data[a, 0] y_a data[a, 1] x_candidates data[range_start:range_end, 0] y_candidates data[range_start:range_end, 1] area np.abs( (x_a - avg_x) * (y_candidates - y_a) - (x_a - x_candidates) * (avg_y - y_a) ) * 0.5 max_idx np.argmax(area) range_start result.append(data[max_idx]) a max_idx result.append(data[-1]) return np.array(result)4.2 代码逐段解读代码里有几个地方值得细看。首先every (n - 2) / (threshold - 2)是桶宽的计算。因为首尾点被固定了中间剩余的 n-2 个点要选出 threshold-2 个每个桶的平均宽度就是总点数除以目标点数。这个桶宽是浮点数所以每个桶的起始索引用int(i * every) 1来取整保证所有桶把中间区域完整覆盖既不重叠也不留空洞。其次面积计算的公式本质上是二维向量的叉积取绝对值再除以 2这是 1.1 节里“三点围成三角形面积”的直接实现。叉积比用海伦公式少一次开方运算数值上更稳定速度也更快。最后np.argmax(area) range_start是向量化的关键。它一次性算出当前桶内所有候选点的面积然后找出最大面积的下标。如果写成内层for j in range(range_start, range_end)逐个算数据量大的时候会很煎熬换成 numpy 的数组计算基本是一瞬的事。4.3 调用示例模拟一条带毛刺的曲线我拿一个模拟场景跑一遍方便你直观感受效果。假设数据是一条正弦波叠加随机噪声再人为塞进去几个尖峰import numpy as np import matplotlib.pyplot as plt np.random.seed(42) x np.linspace(0, 100, 20000) y 5 * np.sin(x / 5) np.random.normal(0, 1.2, sizex.shape) y[500] 30 # 人为尖峰 y[7200] -28 # 人为尖峰 y[15000] 26 # 人为尖峰 data np.column_stack((x, y)) sampled lttb(data, threshold500) print(原始点数:, data.shape[0]) print(降采样后点数:, sampled.shape[0])跑完之后原始数据 2 万点被压到 500 点压缩率 40:1。对比图形的话你画出来会发现三条尖峰全部保住了波形的起伏节奏完全一致几乎挑不出毛病。我把这个用例发给团队前端同事看他第一反应是“后端是不是又偷偷多传了数据”因为形态实在太像了。如果再极端一点把 threshold 压到 100 点也就是 200:1 的压缩率LTTB 依然能保住大的尖峰和整体趋势只是细节上会有轻微的圆角化。这是所有降采样算法都逃不掉的物理极限但 LTTB 至少是“体面地退化”不会像均匀抽稀那样直接断崖式失真。5. 参数选择与工程化落地5.1 目标点数怎么定threshold 的选择直接影响降维效果和使用体验。我一般按下面几个思路来定可视化场景参考屏幕像素宽度。假设图表区是 1920 宽目标点数设成 1500 到 3000 就足够再多也就超出像素分辩能力白白增加带宽。如果有多条曲线同时展示单条的 threshold 要相应降低。分析场景看你后续算法能接受的最大序列长度。比如 LSTM 输入窗口上限是 1000那 threshold 就设为 1000 左右剩下的交给模型自己学习。通用经验值目标点数不低于原始点数的 1%一般就不会对形态造成明显影响。低于 0.1% 时要小心形态可能开始走样。5.2 时间戳不均匀怎么办LTTB 的一个隐藏优势是它天然支持不均匀间隔的时间轴。因为它计算三角形面积时直接使用 x 坐标而不是依赖索引的等距假设。只要你把时间戳转成数值型比如 Unix 时间戳、日期偏移量按时间顺序排好序传进去就行。换句话说它不像滑动窗口平均那样要求数据等间隔。需要注意一点如果时间戳跨度特别大比如跨了几个小时甚至几天x 数值会非常大这时候建议先把时间戳做一下标准化比如减去起点后换算成秒避免 x 和 y 量级差太多影响叉积计算的精度。5.3 直接接入 Pandas DataFrame生产环境里数据大概率装在 DataFrame 里接入 LTTB 只需要把两列转成 numpy 数组跑完后拼接回来import pandas as pd df pd.read_csv(sensor_data.csv) # 假设有 ts 和 value 两列 # 转成 numpy 二维数组 data np.column_stack((df[ts].astype(float), df[value].astype(float))) # 执行降维 sampled lttb(data, threshold2000) # 转回 DataFrame df_sampled pd.DataFrame(sampled, columns[ts, value])这一步看起来平平无奇但很多做 pandas 头歌作业或者接手别人脚本的朋友最容易卡在“numpy 数组和 DataFrame 怎么互换”上。其实只要转成二维数组统一处理再转回来后续所有 pandas 操作都能无缝继续。5.4 大数据量性能优化LTTB 的整体复杂度是 O(n)即每个原始点只参与一次面积计算。但在几千万级别的数据上Python 层的外部排序、numpy 的多次切片也会积累时间开销。我实测过两个优化方向很有效一是用更底层的循环替代 Python 级循环。上面的实现里外部for i in range(threshold - 2)循环其实没法完全去掉因为每个桶的选点结果会作为下一个桶的“上一个选中点”前后有依赖关系。如果数据量真的奔着亿级去可以考虑用 numba 把这个函数 JIT 编译一下能提速几十倍。注意 numba 环境下不能直接用 numpy 的高级索引切片需要改成纯数值循环。二是把数据先分块再并行。如果业务上原本就是很多条不同曲线可以直接用多进程并行跑 LTTB。曲线之间的降维互不依赖四条曲线就能开四个 worker提速非常直接。5.5 做 LSTM/深度学习时序预测时的正确姿势现在很多同学用 LSTM 做时间序列预测第一步就是加载全量历史数据结果动辄几十万甚至上百万条模型压根训不动。我的建议是先用 LTTB 把序列压到模型输入适合的长度比如 5000 到 10000 个点然后再做归一化和滑窗构造样本。这样既保留了原始曲线的整体形态和突变信息又把训练成本控制在一个合理范围。这里有个容易踩的误区必须先降维、后归一化。如果先做归一化LTTB 计算面积时 x 和 y 还能正常做叉积但降维后的点落在归一化空间里物理含义就变了再反归一化会引入额外的误差。流程固定成“原始数据 - LTTB 降维 - 归一化 - 模型输入”就没这个问题。6. 常见问题与踩坑实录6.1 空桶和越界最后一个桶算不出点这是刚实现时最容易踩的坑。因为 every 是个浮点数按int((i 1) * every) 1计算时最后一个桶的结束索引可能超过数组长度导致切片为空面积数组为空np.argmax直接抛异常。解决办法在代码里已经写了range_end min(range_end, n)并且对avg_end avg_start的情况做兜底。我第一次跑一百万条数据时就是在这里翻车的报错信息还没头没尾排查了很久才发现是越界导致空切片。6.2 threshold 设太小导致算法退化如果 threshold 设成 2代码里直接返回首尾两个点这是安全的兜底。但 threshold 设成 8、10 这种很小但又不小于 3 的情况桶的数量很少每个桶覆盖区间很大LTTB 的效果会退化成“按大区间选几个点”这时候曲线的细节保留效果就不如 threshold 设大一些。我的建议是最小阈值尽量别低于 20否则形态保真的意义就不大了。6.3 重复时间戳和 NaN 值原始数据里如果有 NaN面积计算会得到 NaNnp.argmax在全是 NaN 的数组上的行为不保证符合预期。所以降维前必须清洗数据至少要把 NaN 行去掉。重复时间戳不影响 LTTB 本身但会影响可视化效果建议按时间排好序后如果同时间戳多条数据先算个均值再降维。6.4 降采样后不能还原这是必须明确的一点。LTTB 是有损降维只保留形态信息不保留原始值的完整分布。如果你需要做精确的统计分析降维后的数据不能用来算原始数据的均值方差也别指望用降维后的点反推出原始数据的每一个值。它是给“人看”和“模型看”的不是给“计算器算”的。6.5 面积计算出现负数面积公式里的绝对值符号不能省。叉积本身是带符号的表示三角形的方向如果不取绝对值面积可能为负argmax的结果就直接错了。很多初学者把网上的公式抄下来漏了np.abs或最后的* 0.5出来的曲线奇奇怪怪。0.5 倍其实不影响 argmax 的结果但面积的实际意义还是保留一下方便调试时看数值。6.6 和 pandas 结合时的时间列类型问题上面给的示例里时间列必须转成 float 才能参与计算。如果 pandas 里是datetime64类型直接转 float 会变成纳秒级时间戳数值特别大可能造成数值精度问题。稳妥做法是先把 datetime 减掉第一行的时间得到相对秒数df[ts_seconds] (df[ts] - df[ts].iloc[0]).dt.total_seconds()再用这一列当 x 坐标。这样 x 数值一般都在几千到几十万和 y 值量级匹配面积计算也更稳定。我在实际项目里最深的体会是LTTB 这种算法属于“知道的人觉得是神器不知道的人一直在用笨办法”的典型代表。它不复杂几十行代码就能落地但带来的体验提升是质的飞跃。从监控大盘到 LSTM 预测前的数据压缩这套算法我都用过不止一遍每次都能省下大量排查和优化时间。如果你手头恰好有数据量大、曲线卡顿、或者模型训练输入过长的问题不妨先跑一版 LTTB 试试多半会有惊喜。后续如果你们感兴趣我还可以把 LTTB 和 Min-Max、动态时间归一等变体组合使用的心得整理出来那些场景更有意思。