
时序数据的可视化这件事我这些年反复做了很多遍从最初把一列数字直接丢进绘图函数、出来一张糊成毛线团的图到后来能给团队输出一套别人愿意反复打开的看板中间踩的坑基本都踩过一遍。这篇就聊第一层当手里握着一份带时间戳的数据怎么把它变成一张能看出问题的图。它不解决业务结论只解决“看得见、看得准、看得下去”这三件事。适合刚接手数据看板的新人也适合做了几年但总被抱怨“图太丑、图看不出东西”的老手对照着复盘。后面涉及的工具、参数、代码我都会给到可直接跑的版本但更重要的是每一步为什么这么做。1. 先看清数据长什么样再决定画什么很多人拿到时序数据的第一反应是“画条折线”这个反射弧太短了。时序数据的可视化核心矛盾从来不是绘图函数怎么调而是数据本身的形态和你想要回答的问题对不上。同样一份按分钟采样的设备温度数据你想回答“这个月有没有漂移”和“昨天下午那半小时发生了什么”需要的图完全是两码事。前者要的是趋势的宏观形状后者要的是局部的细节纹理一张图同时满足这两个诉求的结果通常是两边都不满意。所以我的固定动作是先花五分钟做数据体检看时间跨度、采样间隔、量纲范围、缺失比例、异常值比例这五个指标然后再动笔选图。这五分钟能省掉后面半小时的返工。1.1 时序数据绕不开的四个硬特征时序数据和普通表格数据最大的区别在于它自带一个强约束——时间轴是有序的、连续的、有时区语义的。这个约束带来四个必须提前处理的特征任何一个没处理好图就是错的。第一个特征是采样间隔是否均匀。数据库里存的是“有变化才写一条”还是“每分钟固定写一条”这两种数据画出来的图完全不一样。前者直接连线会在时间轴上拉出长斜线把“这段时间没有数据”伪装成“数值在缓慢变化”这是最隐蔽也最常见的误导。后者才适合直接连线。第二个特征是量纲差异极大。同一个系统里温度可能是 20 到 30压力可能是 0 到 5000流量可能是 0.1 到 0.3。你把它们放进同一张图的同一个 Y 轴结果是流量那条线贴着底边变成一条直线肉眼完全看不出变化。这不是数据的问题是图的问题。第三个特征是时间跨度与分辨率的矛盾。一年的分钟级数据有 52 万行直接画到 1200 像素宽的画布上每个像素要承载 400 多个点绘图库要么卡死要么自动抽稀要么画出一片实心色块。你必须主动决定哪些点该留、哪些该舍。第四个特征是缺失与异常的边界模糊。传感器掉线产生的空值和传感器故障产生的跳变在图上都表现为“曲线不正常”。但处理方式完全不同前者要留白后者要么修正要么标注。图上看不出这个区别读图的人就会得出完全相反的结论。提示数据体检这一步不要跳。哪怕只写三行代码打印df.index.to_series().diff().value_counts().head()也能立刻看出采样间隔是不是均匀。1.2 工具选型先问清楚这张图给谁看选工具不是选最新的是选最匹配使用场景的。我一般按“谁来读这张图”和“读几次”两个维度来分。下面这张表是我这几年沉淀下来的对照基本覆盖了日常遇到的绝大多数情况。场景推荐方案关键理由需要注意一次性探索、自己看本地脚本直接出图改一行看一次反馈最快别在这阶段投入排版精力写进报告或文档静态图导出高分辨率位图或矢量图不依赖运行环境随处可看字号要按最终展示尺寸反推交付给业务同事自查交互式网页图表可以缩放、悬停看具体数值点数要控制否则浏览器卡死长期监控看板专门的时序看板系统自动刷新、告警联动图表数量要做减法别堆大屏展示定制前端图表库视觉冲击力强、可控性高一定要做退化方案数据断了别白屏这里我想多说一句“交互式”这个选项。很多人以为加了交互就是升级其实交互是有成本的读者需要额外的操作才能获得信息而操作本身会打断阅读的连贯性。如果一张图的核心结论是“整体在上升”那静态图更合适如果核心结论是“找到那个异常的尖峰”那交互式图表的缩放和悬停就是刚需。判断标准很简单结论能不能在一眼之内说清能就用静态不能再上交互。还有个小经验工具选择上最容易犯的错是“混用”。同一个看板里一半图是静态导出的图片一半是前端渲染的交互图结果字体、配色、边距全不一样看起来像几个人拼出来的。要么统一走静态导出要么统一走前端渲染这个一致性比单个图好看重要得多。2. 画图之前的数据整形八成翻车都发生在这里我统计过自己返工的原因真正因为绘图参数没调好的不到两成剩下八成都在数据整形这一步。时间索引没设对、时区搞混、降采样方式选错、缺失值处理成 0这些问题在图上是“看起来怪怪的”但你说不出哪里怪于是开始怀疑绘图库浪费一整天。2.1 时间戳、时区与索引三件事一次说清时间戳本身只是一串数字它有没有意义取决于你赋予它的时区。本地时间字符串“2024-03-01 08:00”如果不带时区信息在跨机器的环境里就会被解释成不同的时刻图上就会出现莫名其妙偏移八小时的峰谷。我的做法是入库时就统一转成带时区的时间对象或者统一存成整数时间戳绝不存裸字符串。索引方面绘图库大多能接受两种形式一是把时间列显式传给 x 参数二是把时间设为索引后直接画。后者更省事但有个副作用——一旦索引不唯一比如同一秒有两条记录后续的重采样、切片行为会变得不可预期。我倾向于在整形阶段就把重复时间戳聚合成一条而不是留到绘图时才发现。import pandas as pd # 统一转成带时区的时间对象无法解析的直接置空而不是抛错 t pd.to_datetime(df[ts], errorscoerce, utcTrue) df df.assign(tst).dropna(subset[ts]) # 同一时刻的多条记录取均值保证索引唯一 df df.groupby(ts, as_indexTrue)[value].mean().sort_index()这段代码里errorscoerce是关键它把解析失败的行变成空值让你能统计出有多少脏数据而不是让一行脏数据中断整个流程。sort_index()同样不能省很多绘图库默认按数组顺序连线索引乱序会导致曲线来回穿插看起来像数据在剧烈震荡实际只是排序问题。2.2 降采样屏幕宽度决定你能画多少点这是最容易被忽略的一步也是性能问题的主要来源。核心逻辑很简单一个像素画不出两条线。假设你的画布显示宽度是 1200 像素那么横向最多能区分 1200 个位置超过这个数量的点必然被压缩到一起要么互相覆盖变成色块要么被绘图库悄悄丢掉一部分。举个具体的例子。一份七天的分钟级数据行数是 7 × 24 × 60 10080 行。画到 1200 像素宽的图上平均每个像素要承载 8.4 个点。这种情况下你把原始数据全塞进去得到的就是一条加粗的、边缘毛糙的带子看不出任何细节。解决方式不是简单粗暴地每隔 N 个取一个那样会漏掉尖峰。我一般根据不同诉求选三种聚合方式聚合方式适用场景副作用均值看整体趋势、噪声大的信号会削平尖峰异常被平均掉最大值与最小值找异常、看波动区间点数量翻倍但信息不丢分位数如 5% 与 95%数据抖动剧烈、有离群点需要读者理解分位数含义我的默认选择是“均值 最大值”两条线叠着画均值线给趋势最大值线给异常。如果图太挤就只保留均值线但一定要在图注里写清楚“已按小时聚合取均值”否则读者会以为看到的是原始精度。聚合的粒度怎么定有个简单的算法目标点数 ≈ 画布宽度像素数 × 1.5。1200 像素的图控制在 1800 个点左右最舒服既不浪费渲染性能也不会因为点太少而让曲线出现折角。由此反推聚合粒度10080 行想降到 1800 点聚合系数约 5.6向上取整成 6 分钟不太符合习惯那就按 10 分钟聚合得到 1008 个点曲线依然平滑。2.3 缺失值与异常值别让一条直线骗了人缺失值处理有个默认的坑很多绘图库遇到空值会直接跳过把前后的点连起来于是“数据中断了六小时”在图上变成了一条平滑的斜线。这条斜线的斜率毫无物理意义但读图的人会认真解读它。正确做法是在缺失处断开曲线。在 pandas 里把缺失位置补成NaN大多数绘图库会自动断线如果用的是不支持自动断线的库就要在缺失处插入一个空行或者手动分段绘制。# 重采样到固定频率缺失位置补 NaN不要 fillna(0) s df[value].resample(10min).mean() # 超过设定阈值的中断插入显式断点 gap s.index.to_series().diff() pd.Timedelta(30min) # 后续绘图时按 gap 分段每段单独画注意这里绝对不能用fillna(0)。零在多数物理量里都是有意义的实际值把缺失填成 0 会在图上制造出一连串向下的深谷读者会误判为设备反复归零。如果一定要填充用前值填充或者线性插值都行但必须在图注里声明“已插值补齐”。异常值则要区分对待。我的判断标准是这个异常是数据错误还是真实事件。前者应该剔除或替换后者必须保留并高亮。判断方法通常是结合多个信号交叉验证比如温度突然飙升同时电流骤降那大概率是传感器故障如果温度飙升同时电流也上升那是真实的负载变化。图的作用就是把这个交叉验证过程可视化出来所以多序列同图在这类场景下几乎是必需的。3. 图表选型不同问题用不同图折线图是时序可视化的默认选项但它只解决一类问题连续变化的趋势。遇到周期性、分布性、密度性的问题时折线图就力不从心了。这一节我按“你想回答什么问题”来组织而不是按“有哪些图”来组织因为后者容易变成图表百科看完还是不知道怎么选。3.1 折线、面积、阶梯三者的边界在哪折线图适合的是连续量比如温度、压力、负载率两个采样点之间的值在物理上是连续过渡的。面积图适合的是累积量或占比比如队列长度、在线人数填充色传达的是“这个量堆在这里”。阶梯图适合的是状态量比如设备档位、开关状态、配置版本这类值在两个采样点之间不变只在切换瞬间跳变。用折线图画状态量是个典型错误。设备从 1 档切到 3 档中间不存在 2 档但折线图会在两点之间画一条斜线视觉上暗示存在过渡过程。阶梯图用wherepost参数就能正确表达“保持原值直到下一次变更”。面积图则要注意堆叠顺序。多个序列堆叠时下面那条线的波动会被上面那条继承读起来很费劲。如果目的是看总量构成堆叠面积图合适如果目的是比较各序列各自的走势宁可画多条折线也不要堆叠。3.2 多序列同图三种解法的取舍回到前面说的量纲问题。多个量纲差异大的序列想放在一张图里只有三条路每条都有代价。第一条路是双 Y 轴。左轴一个量纲右轴另一个量纲。优点是省地方两条线都能看清缺点是两条线的交叉点、相对高低完全是人为设定的没有任何物理意义但读者会本能地去解读“谁超过谁”。我的原则是双轴最多两条线且必须用颜色明确对应左右轴轴标签也要染色否则一定被误读。第二条路是标准化。把所有序列减去均值除以标准差或者缩放到 0 到 1 区间统一放到一个轴上。优点是相对变化一目了然缺点是绝对数值消失了读者看不到“到底是多少”。适合做趋势对比不适合做数值汇报。第三条路是小倍数图。上下堆叠多个子图共享同一条时间轴每个子图独立 Y 轴。这是我最推荐的方式信息损失最小误读风险最低。代价是纵向占地方一屏放不下太多序列一般控制在 4 到 6 个以内。超过这个数量读者的眼睛就开始在子图之间来回跳反而抓不住重点。import matplotlib.pyplot as plt fig, axes plt.subplots(3, 1, figsize(12, 7), sharexTrue, gridspec_kw{hspace: 0.15}) for ax, col in zip(axes, [temp, pressure, flow]): ax.plot(s.index, s[col], linewidth0.8) ax.set_ylabel(col) ax.grid(alpha0.3) axes[-1].set_xlabel(time)sharexTrue是这段代码的灵魂它保证所有子图的时间轴完全对齐读者从上往下扫一遍就能看出几个量的变化是否同步。hspace调小是为了压缩子图之间的空白让整组图看起来是一个整体而不是三张独立的图。3.3 分布与周期视角折线图看不到的东西有些问题折线图天生看不见。比如“这个月的负载分布是什么样的”“每天的峰值集中在几点”这类问题需要换视角。热力图适合表达二维的周期规律。横轴是一天 24 小时纵轴是日期颜色深浅代表数值大小。一张图就能看出“每周三下午是高峰”“周末整体偏低”这类规律而折线图需要你逐日对比才能发现。箱线图适合表达分布。把一天切成 24 个箱子每个箱子画一条箱线就能看出每个时段的数值范围和中位数。折线图只能给你一条线箱线图给你的是整个分布异常值是否集中一目了然。日历热力图适合看长周期的稀疏事件。比如某项操作全年只发生了十几次折线图上就是十几个孤立的点日历图能把它们在时间维度上铺开一眼看出集中在哪几个月。这三种图我一般放在趋势图的下面作为补充视角。顺序很重要先给整体趋势再给局部细节读者的认知路径是从“大概什么样”到“具体什么样”反过来会让人摸不着头脑。4. 从零跑通一套可复用的出图流程前面讲的都是判断这一节给可执行的东西。我会用一份刻意造过坑的模拟数据走完整流程包括缺失段、异常尖峰、量纲差异和时区处理这样你拿去改改就能套到自己的数据上。4.1 构造一份带坑的模拟数据import numpy as np import pandas as pd rng np.random.default_rng(20240301) idx pd.date_range(2024-03-01, periods7*24*60, freq1min, tzUTC) n len(idx) # 日周期 缓慢趋势 噪声 daily 30 * np.sin(2 * np.pi * np.arange(n) / (24 * 60)) trend np.linspace(0, 12, n) noise rng.normal(0, 2.5, n) temp 120 daily trend noise # 人为制造 6 小时缺失 temp[2000:2360] np.nan # 人为制造 3 个尖峰异常 for pos in (3600, 7200, 9600): temp[pos:pos3] 85 df pd.DataFrame({temp: temp}, indexidx) # 再挂一个量纲完全不同的序列 df[flow] 0.25 0.03 * np.sin(2 * np.pi * np.arange(n) / (12 * 60)) rng.normal(0, 0.004, n)这份数据里temp在 120 附近波动flow在 0.25 附近波动量纲差了两个数量级直接同轴画必然有一条贴底。中间 6 小时的缺失段能检验断线逻辑三个尖峰能检验异常识别。时区用了 UTC避免任何本地时间歧义。4.2 静态出图把细节一次调到位静态图的难点不在画在排版。我见过太多图数据是对的但因为字号太小、边距太挤、坐标轴刻度重叠导致根本没法放进报告。下面这套参数是我反复调整后固定下来的模板。import matplotlib matplotlib.use(Agg) import matplotlib.pyplot as plt import matplotlib.dates as mdates plt.rcParams[font.size] 10 plt.rcParams[axes.unicode_minus] False s df[temp].resample(10min).mean() fig, ax plt.subplots(figsize(12, 4.5), dpi150) ax.plot(s.index, s.values, linewidth0.9, color#1f5fa8, labeltemp (10min mean)) # 把异常点单独标出来而不是靠肉眼在图里找 anom s[s s.quantile(0.999)] ax.scatter(anom.index, anom.values, s28, color#c0392b, zorder5, labeloutlier) ax.xaxis.set_major_locator(mdates.DayLocator()) ax.xaxis.set_major_formatter(mdates.DateFormatter(%m-%d)) ax.xaxis.set_minor_locator(mdates.HourLocator(byhour[6, 12, 18])) ax.grid(whichmajor, alpha0.35, linewidth0.6) ax.grid(whichminor, alpha0.15, linewidth0.4) ax.set_ylabel(value) ax.legend(locupper left, frameonFalse, fontsize9) fig.autofmt_xdate(rotation0, hacenter) fig.tight_layout() fig.savefig(timeline.png, bbox_inchestight)几个参数值得单独说。figsize(12, 4.5)的比例约 2.7:1是横向时间轴的舒适区间太方会显得拥挤太扁又会让波动幅度被压扁。dpi150是针对插入文档的场景如果要印刷得提到 300但文件体积会翻四倍日常没必要。fig.autofmt_xdate(rotation0)这个调用看起来矛盾——名字里有 rotate 却传 0——但它的副作用是把日期标签居中对齐避免默认的右对齐导致首尾标签被裁掉。这个细节在 Datetime 轴上很常见值得记住。异常点的标注我特意用了散点叠加而不是变色线段因为变色线段在一堆线段里很难被识别而散点有明确的位置和形状视觉上是独立的。zorder5保证它画在曲线之上不会被遮住。4.3 交互出图把探索权交给读者静态图把结论定死了交互图把发现的过程让给读者。适合交互的场景有个共同点读者需要自己确定关注哪一段。比如排障时你需要反复放大不同时间段看细节静态图做不到。import plotly.graph_objects as go from plotly.subplots import make_subplots fig make_subplots(rows2, cols1, shared_xaxesTrue, vertical_spacing0.06, subplot_titles(temp, flow)) fig.add_trace(go.Scatter(xs.index, ys.values, modelines, nametemp, linedict(width1)), row1, col1) fig.add_trace(go.Scatter(xs.index, ys.values, modelines, nametemp-upper, linedict(width0), showlegendFalse, opacity0), row2, col1) fig.add_trace(go.Scatter(xdf.index, ydf[flow], modelines, nameflow, linedict(width1)), row2, col1) fig.update_xaxes(rangeslider_visibleFalse) fig.update_layout(height560, hovermodex unified, margindict(l50, r20, t40, b30)) fig.write_html(timeline.html, include_plotlyjscdn)hovermodex unified是时序交互图的关键配置它让鼠标悬停时同时显示同一时刻所有序列的值而不是只显示最近的那条线。排障时这个功能省下大量来回移动鼠标的时间。include_plotlyjscdn让输出的 HTML 只引用外部脚本文件从几兆降到几十 KB。但要注意如果接收方在内网环境打开脚本加载不出来就是白屏。给内部同事发文件时我一般用include_plotlyjsTrue内联给外部就用 cdn宁可文件大一点也别打不开。还有一个常被忽略的点交互图一定要给初始视图设定合理的范围。默认全量展示时如果数据里有个别极端值Y 轴会被撑得很大正常波动被压成一条直线读者第一次打开看到的就是“什么都没发生”。可以在update_yaxes里设定range到分位区间让初始视图落在有信息量的范围内。4.4 几个必须反推的参数出图参数不是拍脑袋定的很多都能从最终展示条件反推。字号如果图最终会被缩放到 8 厘米宽插入文档那么原始画布 12 厘米宽、字号 10 磅缩放后实际只有 6.7 磅已经接近可读下限。所以要么把字号提到 12 到 13 磅要么把画布宽度降到 8 厘米。我一般选后者画布窄一点缩放比例接近 1所见即所得。线宽0.8 到 1.0 磅是安全区。低于 0.6 在高分屏上会因为抗锯齿变成浅灰色高于 1.5 在多序列叠加时会互相遮挡。颜色数量同一张图不要超过 6 种颜色。人的短期视觉记忆容量大概在这个量级超过之后读者需要反复对照图例阅读体验会断崖式下降。超出的部分用灰度或者不同线型区分。网格线alpha 值控制在 0.2 到 0.4 之间。太浅等于没有太深会盖过数据本身。主网格和次网格要区分开主网格对应日刻度次网格对应小时刻度层次感出来了图会显得很干净。5. 常见问题与排查技巧实录这一节都是真踩过的坑按现象归类方便你遇到问题时直接对照。5.1 时间轴类问题现象一横轴标签重叠成一团黑。根因是刻度密度超过了画布能容纳的数量。1200 像素宽的图日期标签至少需要 60 像素一个字横向最多放 20 个。如果跨了 30 天默认的每日刻度必然重叠。解法是换用DayLocator(interval3)或者直接按月定位。现象二曲线在时间轴上出现来回折返。九成是索引没有排序。绘图库按数组顺序连线索引乱序就会画出往返的线。sort_index()一行搞定但往往要排查很久才能想到。现象三整张图偏移了固定的小时数。典型的时区问题。数据存的是 UTC绘图时按本地时间解释就会出现整点偏移。解决方式是全程统一时区在最终展示前才做一次转换不要在中间环节反复转换。现象四首尾的标签被裁掉。日期标签默认右对齐最后一个标签会超出画布边界。用bbox_inchestight保存或者手动设置标签对齐方式。5.2 性能与体积类问题现象五打开交互图浏览器直接卡死。数据点太多。十万个点渲染成 SVG 路径浏览器基本扛不住。解法有三降采样、切换到基于 Canvas 的渲染模式、或者分页加载。降采样是最省事的按前面的公式反推粒度就行。现象六导出的图片文件有十几兆。多半是 dpi 设得太高又用了 PNG。如果图里没有大面积渐变转成 SVG 会小一个数量级而且放大不失真。但要注意 SVG 里的文字需要接收方有对应字体跨环境会变形这时候还是老老实实用 PNG。现象七批量出图时内存越跑越高。matplotlib 的 figure 对象不手动关闭就不会释放。循环里出图一定要在savefig之后调用plt.close(fig)否则跑几十张之后内存就爆了。这个坑我踩过报错信息完全不指向根因。5.3 显示与兼容类问题现象八中文变成一排方框。字体没配。除了设置rcParams[font.sans-serif]还要记得设axes.unicode_minusFalse否则负号也会变成方框。这一点在涉及负值的时间序列上特别明显。现象九同一个脚本在不同机器上出的图不一样。版本差异或者字体回退。要保证一致就得显式指定字体文件路径而不是依赖字体族名匹配。团队里共享出图脚本时这点很关键。现象十图在本地看好好的插进文档就糊了。缩放比例问题。本地看是原始尺寸插入文档被缩到 50%字号线宽全部对半砍。解法是按最终展示尺寸设计画布让缩放比例接近 1。5.4 问题速查表现象最可能的原因首选排查动作曲线中有一段平滑斜线缺失值被连起来了检查缺失处是否补了 NaN某条序列完全贴底量纲差异共用了一个轴改用子图或标准化横轴刻度挤在一起刻度密度过高调 Locator 的 interval曲线来回穿插索引未排序打印索引前后各五行对比中文显示为方框字体未配置设 sans-serif 与 unicode_minus交互图打开卡顿点数超过渲染上限降到 5000 点以内再试批量出图内存溢出figure 未关闭循环内加 plt.close标签首尾被裁边界未留白用 bbox_inches“tight”注意排查顺序应该是“先看数据、再看配置、最后怀疑工具”。我遇到过的时序图问题里工具本身的 bug 占比不到百分之五绝大多数都是数据整形没做对。6. 几个用过之后才明白的做法写完前面这些回头看有些经验是文档里不会写、但确实影响效率的。第一个是别在第一次出图时就追求好看。我的习惯是先出一张最丑的图默认参数、默认配色、什么都不调就看数据形状对不对。形状不对后面所有美化都是浪费时间形状对了再逐步加参数。很多人反过来先花半小时配色最后发现时间轴是错的全部推翻重来。第二个是降采样之前先想清楚要保留什么。均值、最大值、分位数选哪个取决于读者要回答什么问题。我曾经为了图好看全用均值结果把一次持续三分钟的过载完全抹平了事后被追问为什么图上没有。从那以后涉及排障场景的图我一律叠加最大值线。第三个是给每张图写一句图注。不是标题是说明“这张图是怎么算出来的”聚合粒度、是否插值、异常点如何判定。这句话在半年后自己回头看的时候价值最大因为你早就忘了当时的处理逻辑。第四个是交互图不要超过两层。所谓两层就是一个总览加一个下钻。做到三层以上读者就会在里面迷路。真要支持深度探索那是看板系统的活不该压在一张图上。第五个是静态图的配色要经得起黑白打印。总有那么一次图会被黑白打印出来贴在会议室的墙上。只靠颜色区分的序列在黑白下全部变成同一个灰完全无法辨认。所以关键序列一定要加线型区分实线、虚线、点划线这个习惯越早养成越好。这套流程我自己用了很久从单机脚本到团队共用的出图模板改动最多的部分永远是数据整形那一层绘图那一层反而越来越稳定。后面如果继续往下做还有两件事值得展开一是多序列的对齐与因果分析怎么在一张图上看清楚“A 变了之后 B 多久跟着变”二是大规模时序的渲染方案当数据量到千万级前面这些按像素反推的方法就不够用了得换成基于分块索引的渲染思路。这两块内容量都不小够单独写两篇。