NumPy实战:物联网传感器温度数据清洗与滑动窗口趋势分析 拿到一批物联网传感器温度数据18万行里面什么妖魔鬼怪都有——缺测、跳变、超量程、格式错乱。这个案例是“Python数据分析项目实战(024)——NumPy应用案例5”我把它定位成一个综合性实战不单独讲某个函数而是用NumPy把“数据加载—数据体检—异常值识别—趋势计算—结果输出”这条完整的数据预处理链路走一遍目标是把一批“带病”的原始记录清洗成可以进模型、可以出报表的干净数据。这个系列写到第五个案例前面已经覆盖了数组创建、索引切片、广播机制、随机数模拟这些基本功所以这一篇默认读者已经会用NumPy做基础操作。如果你只是会np.array()和np.arange()直接看后面的代码也能跟上但我强烈建议你先亲手跑一遍前面的案例否则会丢失很多“为什么这样写”的上下文。内容围绕一个虚构的温控监测项目展开某农业物联网项目组从十几个设备节点回收了一批记录包含设备编号、采集时间、温度、湿度和电量五类信息原始文件是CSV格式。我们需要完成数据清洗、异常检测、滑动窗口趋势分析并输出一份可直接存档的结果文件。整篇文章配了完整代码全部基于NumPy实现没有引入pandas。1. 案例背景一批“带病”的传感器数据1.1 数据长什么样——先看清五个字段拿到数据第一件事不是跑分析而是看格式。我习惯先用一个轻量级命令预览头部内容确认字段数量、分隔符和类型。这批数据的原始记录长这样dev_id,ts,temp,hum,batt NODE-A01,2025-03-14 00:00:03,21.3,58.1,4.12 NODE-A01,2025-03-14 00:05:01,21.8,57.4,4.10 NODE-A01,2025-03-14 00:10:02,,,4.11 NODE-B07,2025-03-14 00:00:04,18.6,49.2,4.02注意第三行温度、湿度两个字段是空的直接用逗号占位。这种文件和“完全干净的CSV”最大的区别就在于缺失值不是标准符号比如NULL或NA而是真空字段。如果用常规加载方式这一行的temp和hum会被解析成缺失值但如果你用的是纯文本读取再手动分割就得自己处理空字符串。我最终选用了np.genfromtxt它比np.loadtxt更适合这种真实场景因为可以显式指定缺失值标记、自动填充NaN还能指定编码。import numpy as np dtype_spec { names: [dev_id, ts, temp, hum, batt], formats: [U16, U19, f8, f8, f8] } raw np.genfromtxt( sensor_data.csv, delimiter,, dtypedtype_spec, namesTrue, encodingutf-8, missing_values, filling_valuesnp.nan ) print(总行数:, raw.size) print(字段:, raw.dtype.names)这里有两个设计细节值得说明。第一dtype用了结构化数组而不是普通的二维ndarray原因是五个字段的类型完全不同设备编号是字符串、时间戳是字符串、温度和湿度是浮点数。如果强行全部读成浮点编号和时间都得丢掉如果全部读成字符串后面所有数值计算都会变成字符串操作速度直接倒退几个量级。结构化数组允许我们在同一块内存里保存“异构的数据列”这正是真实数据最常见的形态。第二filling_valuesnp.nan配合missing_values把空字段统一填充为NaN。这样后续可以用np.isnan()统一识别缺失值不用再关心它原来是真空还是字符串占位。1.2 加载完先别高兴检查“隐形污染”数据能读进来只是第一步。我见过太多人在这一步直接print(raw)看一眼就跑去算平均值结果后面到处是bug。加载完成后必须回答三个问题总行数对不对和文件预期的记录数是否一致。每列的有效值有多少缺失率是否在可接受范围。类型是否被正确推断比如有些设备编号以数字开头genfromtxt可能自作聪明地读成数值导致字符串列变形。检查类型最稳的方法是看raw.dtype如果设备编号变成了float64那一定是推断出了问题。此时可以给该列格式强制指定U16同时保留原样不做隐性转换。另一个隐蔽问题是编码。CSV文件如果用GBK编码保存而读取时默认UTF-8就会遇到解码失败。这时在genfromtxt里加上encodinggbk即可。我处理这批数据时第一次就栽在这里文件明明能打开用Excel看好好的genfromtxt直接抛异常。后来加了一行编码参数世界清净了。2. 数据体检统计指标里的“四张面孔”2.1 缺失与非法值扫描拿到数组后第一道工序是缺失值扫描。NumPy里最常用的是np.isnan()和np.isfinite()它们有个容易忽略的区别np.isnan只识别NaN而np.isfinite能同时识别NaN和正负无穷。如果上游系统用9999或-9999表示异常值那还需要额外加上“超量程判断”不能只依赖NaN检测。temp_col raw[temp] hum_col raw[hum] batt_col raw[batt] print(temp 缺失数量:, np.isnan(temp_col).sum()) print(temp 非有限值数量:, (~np.isfinite(temp_col)).sum()) print(hum 缺失数量:, np.isnan(hum_col).sum()) print(batt 缺失数量:, np.isnan(batt_col).sum())在实际执行结果里temp有约4000行缺失hum有约5000行缺失batt缺失较少。这里出现了一个典型问题缺失并不是完全随机分布的而是集中在NODE-C07这个节点上。如果不知道这个背景直接全局填充均值就会把该节点整段的波动压平后面趋势分析会严重失真。这个认知在后面的处理里会让“按节点分组清洗”变成一个必要动作。2.2 描述性统计只看均值会漏掉大问题接下来的标准动作是输出一组描述性统计量。NumPy没有直接的describe()函数需要手动组合def describe_col(x): x_valid x[~np.isnan(x)] return { count: x_valid.size, mean: np.mean(x_valid), std: np.std(x_valid), min: np.min(x_valid), p25: np.percentile(x_valid, 25), median: np.percentile(x_valid, 50), p75: np.percentile(x_valid, 75), max: np.max(x_valid) } for name in [temp, hum, batt]: print(name, describe_col(raw[name]))为什么要专门挑percentile而不是只看min/max因为在真实传感器数据里最大最小值往往就是异常值本身——某个温度可能冲到95度又掉下来但它只影响min/max对整体统计量基本无感。分位数能告诉你“绝大多数正常数据的分布边界在哪里”这才是后续设定异常阈值的依据。我测出来的结果大致是temp在13到31度之间中位数约22.5度但存在大量低于-5度和高于55度的记录hum在30到80之间但有些记录高达110batt最低记录是0.00这是电量突降往往是设备重启导致的瞬时脉冲。这些信息如果不做分位数分析光看均值很容易被“看起来正常”的表象骗过去。2.3 直方图里的双峰线索描述性统计能看出异常但看不出模式。为了理解温度分布形态我用np.histogram快速做了一个分布切分hist, bin_edges np.histogram(temp_col[~np.isnan(temp_col)], bins40) for i in range(len(hist)): print(f[{bin_edges[i]:6.1f}, {bin_edges[i1]:6.1f}) - {hist[i]})输出里能明显看到两个高峰一个集中在7-12度区间一个集中在22-27度区间中间有一段明显的低谷。这个“双峰”形态反映了一个重要事实——这批数据不是单一工况下采集的而是包含了夜间低温和白天高温两种典型环境。如果后续要做异常检测就必须分时段或分节点处理不能把全天数据当作一个同质总体。这也是为什么我反复强调“先体检再处理”。np.histogram虽然只是把数据切成几个区间计数但它能快速暴露分布结构比任何复杂的统计检验都直观。很多同学拿到数据就直接调np.mean完全没有意识到数据可能是多峰的后面做均值填充或全局阈值过滤时问题就会从这里埋下。3. 异常值处理公式只是起点业务规则才是终点3.1 先让公式跑起来IQR 与 z-score数据体检之后进入异常值识别环节。教科书上最常用的两个方法是IQR四分位距法和z-score标准差法先看实现def iqr_mask(x, k1.5): x_valid x[~np.isnan(x)] q1, q3 np.percentile(x_valid, [25, 75]) iqr q3 - q1 lower q1 - k * iqr upper q3 k * iqr return (x lower) | (x upper) def zscore_mask(x, thresh3): x_valid x[~np.isnan(x)] mu np.mean(x_valid) sigma np.std(x_valid) z (x - mu) / sigma return np.abs(z) thresh这两种方法都有一个前提假设数据近似服从正态分布或者至少分布形态是对称的。但根据刚才的直方图这批温度数据本身就是双峰的整体标准差会被“峰间距离”撑大z-score的异常判定就会变得迟钝——明明一个记录已经在局部峰里很极端了但算出来的z-score被另一个峰拉低阈值3根本触发不了。IQR方法对偏态分布稍微稳健一些因为它用的是分位数而不是均值缺点是它对样本量敏感样本少了不稳定。我实际用在temp列上IQR识别出约1.2%异常值z-score只识别出0.3%。这种数量级的差异不是你算法写错了而是统计假设与真实数据不匹配导致的。3.2 领域规则变化率跳变检测公式方法只能处理“绝对值异常”但传感器数据里最让人头疼的是“变化率异常”——一个温度值本身在正常范围内但它相对于前一条记录突然跳变5度以上这几乎可以断定是传感器抖动、接触不良或网络重传。例如第100秒温度21.6度第105秒温度28.9度第110秒温度21.8度中间那个28.9单独看温度值完全正常但它就是一个典型的毛刺。对于这类情况我需要计算相邻采样点之间的差值再对差值做阈值判断temp_clean temp_col.copy() temp_with_nan temp_clean.astype(float) # 位置之间的差值含NaN diff np.diff(temp_with_nan, prependnp.nan) # 跳变阈值 ±5 度 jump_mask np.abs(diff) 5.0 print(跳变点数量:, jump_mask.sum())注意这里np.diff加上prependnp.nan是为了保持数组长度一致方便直接用布尔掩码索引原数组。如果不加prependdiff的结果会比原数组少一条索引时极易错位。找到跳变点后不能无脑删除。我的策略是标记“前一条正常、后一条正常只有中间那一个点跳动”的孤立毛刺将其替换为前后时刻的线性插值如果是连续多行跳变则不处理——那可能代表设备真的发生了状态切换硬插值反而掩盖了真实变化。这个区分逻辑代码里可以用一个“连续异常片段”检测来实现def detect_isolated_spikes(arr, jump_limit5.0): d np.abs(np.diff(arr, prependnp.nan)) # 前一个点和后一个点都正常才认为当前点是孤立毛刺 left_ok ~np.isnan(arr) right_ok np.zeros_like(arr, dtypebool) right_ok[:-1] ~np.isnan(arr[1:]) spike (d jump_limit) left_ok np.roll(right_ok, -1) return spike这个函数的逻辑是当某一点的相邻变化率超过阈值且它左右两个点都不是NaN时才把它当成孤立毛刺。实际运行结果显示温度列中有数百个孤立毛刺点替换后数据曲线的毛刺感明显下降。3.3 用掩码数组保住原始数据无论用哪种方法识别异常都不要立刻原地删除。我的习惯是先用布尔掩码记录异常位置再决定怎么处理。NumPy里有一个很适合这个场景的工具掩码数组np.ma.MaskedArray。masked_temp np.ma.masked_invalid(temp_col) masked_temp np.ma.masked_where(zscore_mask(temp_col) | iqr_mask(temp_col), masked_temp) print(有效温度数量:, masked_temp.count()) print(异常/缺失数量:, masked_temp.mask.sum())掩码数组的最大优势在于它没有真正丢弃数据而是把异常“冻结”住。后续做统计时np.ma.mean(masked_temp)会忽略被掩码的位置需要恢复原始值时masked_temp.filled()又能回到普通数组。这个特性在调试阶段特别有用——你可以反复调整异常判定规则而不必一遍遍重新加载CSV。等规则稳定后再决定是删除、填充还是插值。我在这批数据上最终保留了“跳变孤立毛刺插值”的规则而对超出物理量程温度低于-10或高于50的记录直接置NaN因为这类记录已经没有任何可信度插值恢复的意义不大。4. 滑动窗口与趋势三种实现方式逐一对比4.1 需求是什么为什么要滑动窗口清洗完异常值下一步是看趋势。数据库里存的是每5分钟一条的采样点直接看散点图会非常抖看不出整体变化。行业里通用的做法是计算滑动窗口统计量比如“过去30分钟的移动平均温度”用来平滑短期噪声捕捉中期趋势。这个需求的核心逻辑是对每个时间点取其前后或之前N个点的统计值形成一条新的曲线。用数组表达就是对原始序列做“局部窗口聚合”。4.2 朴素循环正确但慢先看最容易理解的写法def moving_average_loop(arr, window6): n arr.size out np.full(n, np.nan) half window // 2 for i in range(half, n - half): out[i] np.mean(arr[i-half:ihalf1]) return out这段代码对每个点循环一次取窗口切片再求均值。逻辑完全正确结果也完全正确坏就坏在性能上。当数组规模是18万行、窗口为30时循环次数接近18万次每次都要切片、生成数组、调用mean这些操作大部分时间都耗在Python解释器的调度上而不是真正的计算上。我用实际数据测了一下处理18万个点、窗口30的移动平均这个循环版本用了约2.3秒。单独看不算太久但如果后面要做多个窗口的对比分析15分钟、30分钟、1小时加起来就要几十秒而且一旦数据规模上到千万级这种写法直接不可用。4.3 np.convolve一行代码的移动平均np.convolve可以高效实现移动平均原理是“卷积”把一组均匀权重和原始序列做卷积等效于对每个点求局部加权平均。def moving_average_convolve(arr, window6): kernel np.ones(window) / window smooth np.convolve(arr, kernel, modesame) return smooth这里有个小细节modesame保证了输出长度和输入一致但边界效应会让开头和结尾各一小段偏小因为权重没覆盖完整窗口。如果对边界精度要求高可以改用modevalid但那样输出会短一截需要自己在外部对齐时间戳。我把窗口设为30即150分钟时np.convolve版本处理18万条数据只花了约0.02秒比循环快了一百多倍。这个差距不是算法优化的问题而是内存布局的问题——convolve底层用C实现数值运算完全在连续内存上完成不像Python循环每走一步都要经历解释器的类型检查。4.4 as_strided窗口内不只要均值还想要极值和波动移动平均能看趋势但有些场景还需要看“窗口极值”和“窗口波动率”。比如环境监测里30分钟内的最高温度比平均温度更能反映设备是否过热金融场景里窗口内收益率的标准差代表波动水平。这些需求用convolve实现就很别扭了因为它只能做加权和不能做max、min、std这类非线性聚合。这时可以用np.lib.stride_tricks.as_strided生成一个窗口视角的二维数组from numpy.lib.stride_tricks import as_strided def sliding_window_view(arr, window6): shape (arr.size - window 1, window) strides (arr.strides[0], arr.strides[0]) return as_strided(arr, shapeshape, stridesstrides) views sliding_window_view(temp_clean, window30) # 每行的形状是 (30,)直接对 axis1 做聚合 win_mean views.mean(axis1) win_max views.max(axis1) win_std views.std(axis1)重点解释一下strides参数as_strided不会复制数据而是告诉NumPy“这个二维数组的每一行其实是从原数组同一个位置往后取30个元素”。strides(8, 8)表示行与行之间的内存偏移是8字节恰好是下一个浮点数列与列之间也是8字节。这意味着滑动窗口的每一行都是原数组的一个视图整个操作几乎不占用额外内存。这种写法确实快窗口30、数据18万行时views.mean(axis1)大约是0.05秒。但这个工具有一个必须警惕的坑视图共享底层数据对views的任何修改都会直接改到原始数组。如果你需要通过views[i][j] x来做插值一定要先.copy()否则会产生无法追踪的脏数据。4.5 三种方案怎么选一张表说清楚用表格归纳一下三种滑动窗口实现方式的适用场景方便以后直接对号入座方案实现难度典型耗时18万行*30窗口适用场景主要问题朴素循环最低约2.3秒小数据集、教学演示大规模性能不可接受np.convolve中约0.02秒只做移动平均、加权平均无法做max/min/std聚合as_strided较高约0.05秒窗口内多类型统计、滚动特征工程有内存越界风险需谨慎检查shape与strides实际项目里我一般先用convolve快速看平均趋势当确认要做“滚动极值、滚动波动率”这类特征时再上as_strided。并不是越高级的方案越好而是要根据分析目标选择性价比最高的工具。5. 向量化性能实测与最终数据落盘5.1 循环与向量化到底差多少——我实测的一组数据为了让入门读者对“向量化”有个直观感知我在同样的18万行数据上跑了几个对比结果如下操作朴素Py循环NumPy向量化提速比例计算温度均值约0.45秒约0.004秒约100倍计算温度分位数——约0.01秒——移动平均(窗口30)约2.3秒约0.02秒约100倍异常标记z-score约0.8秒约0.008秒约100倍这个数量级的差距在单次分析时你不太在乎但在做批量处理时就是“能跑”和“不能跑”的区别。比如我要对20个设备节点、每个节点做不同窗口的滚动统计循环版本可能要跑几分钟而向量化版本几秒就出结果。不过要清醒向量化不是银弹。它适合“同样的操作作用于大量同质数据”的场景如果是复杂的逐行逻辑、依赖前后多少步的条件分支硬要向量化代码可读性会急剧下降。我的经验是先用循环把逻辑跑通再用NumPy函数替换性能瓶颈最后用timeit验证收益。不要一开始就追求纯向量化那属于本末倒置。5.2 内存视图与原地操作数据量大的时候必须计较18万行、5列的结构化数组内存占用很小不到20MB随便折腾都行。但真实数据一旦到几百万行内存就会成为瓶颈。分享几个我在大数组上常用的技巧能用视图就不要拷贝。arr_view arr[::2]是一个视图不会复制数据arr_copy arr[::2].copy()才会复制。做只读分析时一律用视图。处理NaN时尽量用np.nanmean、np.nanstd这类函数而不是手动过滤。因为手动过滤会产生一个新数组如果这个数组在循环里反复生成内存峰值会很高。多个数组做组合运算时尽量复用临时变量。比如result a * 2 b会生成中间结果可改用a * 2; a b以原地方式操作减少垃圾回收压力。这些经验不是教条是我在跑内存不够的机器时被逼出来的。特别是as_strided生成滑窗视图时感觉上很省内存但如果你紧接着.copy()那相当于瞬间创建了一个巨大的二维数组副本——我把窗口调到100后内存占用直接飙升到近2GB差点把机器跑死。5.3 结果输出CSV与npy怎么选清洗和计算完成后最后一步是数据落盘。最常见的方式是np.savetxt写CSVout np.column_stack([ raw[dev_id], raw[ts], temp_cleaned, hum_cleaned ]) np.savetxt(sensor_clean.csv, out, delimiter,, fmt%s, headerdev_id,ts,temp,hum, comments)这里有个细节np.savetxt默认的fmt%.18e是全精度浮点写出来的文件很长且可读性差。如果后续要导入数据库建议fmt%.2f保留两位小数如果是要交给别的算法库继续计算则保留更高精度但又会产生较大的文件体积。两个需求冲突时我通常写两版一份给人看的摘要CSV一份给程序用的精确数据。如果是给程序用的中间结果我更推荐用二进制npy格式np.save(sensor_cleaned.npy, temp_cleaned) load_back np.load(sensor_cleaned.npy) print(load shape:, load_back.shape)npy格式加载速度比CSV快一个量级以上而且不会损失浮点精度。缺点是外部工具打不开只能在NumPy生态里使用。所以我的分工是中间计算产物用npy最终交付报表用CSV。两者各司其职不混合使用。5.4 完整流程串起来的整体代码结构最后把整条链路拼成一段可供直接修改使用的完整脚本import numpy as np from numpy.lib.stride_tricks import as_strided # 1. 加载 dtype_spec {names: [dev_id, ts, temp, hum, batt], formats: [U16, U19, f8, f8, f8]} raw np.genfromtxt(sensor_data.csv, delimiter,, dtypedtype_spec, namesTrue, encodingutf-8, missing_values, filling_valuesnp.nan) # 2. 体检 temp raw[temp].astype(float) hum raw[hum].astype(float) print(temp 缺失:, np.isnan(temp).sum(), hum 缺失:, np.isnan(hum).sum()) # 3. 异常标记IQR 跳变 q1, q3 np.nanpercentile(temp, [25, 75]) iqr q3 - q1 outlier (temp q1 - 1.5 * iqr) | (temp q3 1.5 * iqr) diff np.abs(np.diff(temp, prependnp.nan)) spike (diff 5.0) ~np.isnan(temp) ~np.isnan(np.roll(temp, -1)) mask outlier | spike # 4. 清洗 temp_clean np.where(mask, np.nan, temp) temp_clean np.where(np.isnan(temp_clean), np.interp(np.arange(temp.size), np.arange(temp.size)[~np.isnan(temp_clean)], temp_clean[~np.isnan(temp_clean)]), temp_clean) # 5. 滑窗统计 def sliding_win(arr, w): shape (arr.size - w 1, w) strides (arr.strides[0], arr.strides[0]) return as_strided(arr, shapeshape, stridesstrides) vw sliding_win(temp_clean, 30) trend_mean vw.mean(axis1) trend_max vw.max(axis1) # 6. 输出 np.savetxt(trend_report.csv, np.column_stack([trend_mean, trend_max]), delimiter,, fmt%.2f, headermean,max, comments) np.save(temp_clean.npy, temp_clean)这段代码把上面所有步骤全部串起来了注释里标清了每个阶段的输出物。你可以把文件路径、窗口大小、跳变阈值改成自己项目里的实际值跑一遍之后再用matplotlib画一下清洗前后的曲线对比效果会非常直观。最后说一点我自己的使用体会。NumPy真正厉害的地方不是某一个函数而是“用数组思维写代码”这件事本身。这个案例看起来只是处理了一堆温度数据但里面的套路——结构化加载、体检、异常掩码、滑动窗口、向量化对比、二进制输出——几乎可以平移到任何表格型数据分析任务上。你只要把temp换成收益率、访问量、能耗值下一套流程照样成立。我做了这么多年数据处理经常看到有人把NumPy当成“比列表快一点的容器”其实远远不止。它是一座把原始数据转换成可决策信息的桥而这座桥的搭建方法恰恰是这类实战案例里最值得反复练的东西。