工业设备故障诊断这件事我做了快六年从最早靠老师傅拿听棒贴着轴承箱听异响到后来用振动频谱仪一条条看边频带再到现在把特征工程和机器学习模型串成一条流水线。说实话轴承故障、过热、转子不平衡这三类问题占了旋转机械非计划停机的七成以上而且它们的振动特征在频域上经常互相重叠单靠一个模型很难同时压住漏报和误报。这篇东西就是把我自己搭的一套融合诊断方案完整拆开讲——用Python把随机森林、IsolationForest和TF-IDF三个看似不搭界的东西捏在一起分别处理有标签的故障分类、无标签的异常检测和文本工单的特征提取最后输出一个能直接落地的诊断结论。如果你手头有振动数据、有历史维修工单但不知道怎么把它们变成可用的模型输入或者你已经试过单一模型但效果总差一口气那这篇内容应该能帮你省掉不少试错时间。我会把每个模型为什么选、参数怎么定、特征怎么对齐、融合层怎么设计全部按实际代码和踩坑经验讲清楚不绕弯子。1. 三类故障的振动特征到底长什么样在动手写任何代码之前得先把物理对象搞清楚。轴承故障、过热和转子不平衡这三者在振动信号上的表现有本质区别如果连这个都分不清后面特征工程就是瞎猜。1.1 轴承故障的冲击性与高频共振轴承局部损伤比如外圈剥落、内圈点蚀、滚动体裂纹在运转时会产生周期性的冲击脉冲。这个冲击会激发轴承座和传感器安装位置的高频固有频率形成所谓的“高频共振带”。在时域上你会看到一串等间隔的冲击衰减波形在频域上高频段通常2kHz到10kHz会出现共振峰而低频段则出现故障特征频率及其谐波。轴承故障特征频率的计算依赖转速和轴承几何参数。外圈故障频率BPFO、内圈故障频率BPFI、滚动体故障频率BSF、保持架故障频率FTF这四个公式我在下面列一下实际项目中直接封装成函数调用def bearing_fault_frequencies(n, d, D, theta, fr): n: 滚动体数量 d: 滚动体直径 (mm) D: 节径 (mm) theta: 接触角 (度) fr: 轴转频 (Hz) import math theta_rad math.radians(theta) bpfo (n / 2) * fr * (1 - (d / D) * math.cos(theta_rad)) bpfi (n / 2) * fr * (1 (d / D) * math.cos(theta_rad)) bsf (D / (2 * d)) * fr * (1 - ((d / D) * math.cos(theta_rad)) ** 2) ftf (fr / 2) * (1 - (d / D) * math.cos(theta_rad)) return bpfo, bpfi, bsf, ftf实测中我发现轴承早期故障的冲击能量很弱直接做FFT经常被背景噪声淹没。这时候包络解调Hilbert变换取包络再做谱分析比直接频谱有效得多。包络谱上出现的BPFO或BPFI峰值才是判断轴承故障的硬证据。1.2 过热故障的宽频抬升与趋势性过热和轴承故障不一样它往往不是某个特定频率的突增而是整个高频段的能量缓慢抬升同时伴随润滑油粘度下降导致的阻尼变化。在时域上过热表现为振动有效值RMS的持续爬升但峰值因子Crest Factor反而可能下降因为冲击成分被高温软化后的材料吸收了。我处理过热数据时最看重的三个指标是高频段5kHz以上能量占比、RMS的滑动趋势斜率、以及温度传感器与振动RMS的相关系数。如果温度上升的同时高频能量占比也在涨基本可以锁定过热。这里有个坑环境温度变化会干扰判断所以必须做温度归一化或者直接用温升速率而不是绝对温度。1.3 转子不平衡的1倍频主导转子不平衡的特征非常明确1倍转频1X的振幅显著高于其他频率成分而且径向振动通常大于轴向振动。如果1X幅值占总振动能量的60%以上同时2X、3X幅值很低那基本就是不平衡。但要注意区分“不平衡”和“转子弯曲”或“联轴器不对中”后两者会在2X甚至3X上也有明显峰值。实际项目中我遇到过电机转子不平衡和轴承外圈故障同时存在的情况。这时候1X和BPFO会同时出现在频谱上如果只用一个二分类模型很容易把复合故障误判成单一故障。这也是我后来坚持做多模型融合的直接原因——不同模型对不同类型的特征敏感度不一样融合之后容错率会高很多。2. 为什么选随机森林、IsolationForest和TF-IDF做融合很多人一上来就想用深度学习LSTM、CNN往振动信号上怼。我不否认深度学习在特定场景下效果好但工业现场的数据量往往不够而且标注成本极高。一个轴承从正常到失效可能跑几个月但真正有价值的故障样本可能就几十条。这种数据条件下树模型和统计方法反而更稳。2.1 随机森林负责有标签的故障分类随机森林在我的方案里承担的是“监督分类器”的角色。输入是手工提取的时域和频域特征输出是故障类别正常、轴承故障、过热、转子不平衡。选它的理由很直接对缺失值不敏感、不需要特征归一化、能输出特征重要性、在小样本上不容易过拟合。我用的特征集大概有四十多维包括时域的均值、方差、峭度、峰值因子、裕度因子频域的1X幅值、2X幅值、BPFO/BPFI幅值、高频能量占比以及包络谱的峰值统计。随机森林的n_estimators我一般设200到500max_depth控制在8到12之间再深就会记住训练集的噪声。from sklearn.ensemble import RandomForestClassifier rf RandomForestClassifier( n_estimators300, max_depth10, min_samples_leaf3, max_featuressqrt, class_weightbalanced, random_state42, n_jobs-1 )class_weightbalanced这个参数很关键。工业数据里正常样本远多于故障样本不加权的话模型会倾向于把所有东西都判成正常。我试过不加权召回率直接掉到0.6以下加上之后能拉到0.85以上。2.2 IsolationForest负责无标签的异常检测IsolationForest的定位是“兜底”。现场经常出现一种情况设备出现了某种从未记录过的异常但随机森林因为没见过这类样本会把它硬分到某个已知类别里导致误判。IsolationForest不需要标签它通过随机切分特征空间来孤立异常点对未知类型的异常更敏感。我通常把IsolationForest的contamination参数设在0.05到0.1之间意思是假设5%到10%的数据是异常的。这个值不能拍脑袋定得看历史数据里异常比例大概是多少。如果设得太高正常波动会被误报设得太低真正的早期故障又抓不住。from sklearn.ensemble import IsolationForest iso IsolationForest( n_estimators200, contamination0.08, max_samplesauto, random_state42, n_jobs-1 )实际部署时我会把IsolationForest的输出作为一个“异常分数”而不是硬标签。分数越低表示越异常然后把这个分数和随机森林的预测概率一起送进融合层。2.3 TF-IDF处理维修工单和报警文本TF-IDF在这个方案里容易被忽略但它其实很重要。工业现场有大量的文本信息维修工单、报警日志、操作员备注。这些文本里藏着很多振动数据反映不出来的信息比如“上次加脂后异响消失”、“环境温度过高导致跳机”之类的上下文。我用TF-IDF把工单文本向量化然后训练一个简单的逻辑回归或者SVM做文本分类输出“文本侧故障概率”。这个概率和振动侧的概率做加权融合能显著提升复合故障的识别率。比如振动模型判断是轴承故障但工单文本里频繁出现“温度报警”融合层就会提高过热类别的权重。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression tfidf TfidfVectorizer( max_features5000, ngram_range(1, 2), min_df2, max_df0.9 ) text_clf LogisticRegression( C1.0, class_weightbalanced, max_iter1000 )ngram_range(1, 2)是为了捕捉“轴承 异响”这样的二元词组单用一元词会丢失很多语义信息。min_df2过滤掉只出现一次的词避免噪声特征。3. 从原始振动信号到模型输入的特征流水线特征工程是整个方案里最耗时间也最决定上限的部分。模型选得再好特征不对齐也是白搭。我这条流水线跑了三年多迭代了十几个版本下面讲的是目前最稳定的一版。3.1 时域特征的提取与窗口选择时域特征我固定提取12个均值、标准差、RMS、峰值、峰峰值、峭度、偏度、峰值因子、裕度因子、波形因子、脉冲因子、方差。这些特征的计算不复杂但窗口长度的选择很讲究。窗口太短统计量不稳定窗口太长故障的瞬态特征会被平均掉。我的经验是对于转速1500rpm左右的设备窗口长度取0.5秒到1秒比较合适对应大约12到25个转周期。如果转速更低窗口要相应加长。import numpy as np def time_domain_features(signal): abs_signal np.abs(signal) mean_val np.mean(signal) std_val np.std(signal) rms np.sqrt(np.mean(signal ** 2)) peak np.max(abs_signal) peak_to_peak np.max(signal) - np.min(signal) kurtosis np.mean((signal - mean_val) ** 4) / (std_val ** 4 1e-12) skewness np.mean((signal - mean_val) ** 3) / (std_val ** 3 1e-12) crest_factor peak / (rms 1e-12) clearance_factor peak / (np.mean(np.sqrt(abs_signal)) ** 2 1e-12) shape_factor rms / (np.mean(abs_signal) 1e-12) impulse_factor peak / (np.mean(abs_signal) 1e-12) return { mean: mean_val, std: std_val, rms: rms, peak: peak, peak_to_peak: peak_to_peak, kurtosis: kurtosis, skewness: skewness, crest_factor: crest_factor, clearance_factor: clearance_factor, shape_factor: shape_factor, impulse_factor: impulse_factor, variance: std_val ** 2 }峭度这个特征我要单独说一句。正常轴承的振动信号接近高斯分布峭度在3左右一旦出现冲击性故障峭度会迅速升高到5以上。但峭度对早期故障的敏感度有限通常要等到故障发展到中期才会明显抬升。所以我会同时看包络谱的峰值两者互补。3.2 频域与包络谱特征的构造频域特征我分三块直接FFT的幅值谱、Hilbert包络谱、以及高频段能量占比。直接FFT用来抓1X、2X、3X这些转频成分判断不平衡和对中问题包络谱用来抓BPFO、BPFI这些轴承故障特征频率高频能量占比用来判断过热和整体劣化趋势。from scipy.fft import fft, fftfreq from scipy.signal import hilbert def frequency_domain_features(signal, fs, fr): n len(signal) yf fft(signal) xf fftfreq(n, 1 / fs)[:n // 2] amplitude 2.0 / n * np.abs(yf[:n // 2]) def amp_at_freq(target_freq, tolerance2.0): idx np.argmin(np.abs(xf - target_freq)) mask np.abs(xf - target_freq) tolerance return np.max(amplitude[mask]) if np.any(mask) else 0.0 feat_1x amp_at_freq(fr) feat_2x amp_at_freq(2 * fr) feat_3x amp_at_freq(3 * fr) envelope np.abs(hilbert(signal)) env_yf fft(envelope - np.mean(envelope)) env_amp 2.0 / n * np.abs(env_yf[:n // 2]) high_freq_mask xf 2000 high_freq_ratio np.sum(amplitude[high_freq_mask] ** 2) / (np.sum(amplitude ** 2) 1e-12) return { 1x_amp: feat_1x, 2x_amp: feat_2x, 3x_amp: feat_3x, 1x_ratio: feat_1x / (np.sum(amplitude) 1e-12), high_freq_ratio: high_freq_ratio, env_peak: np.max(env_amp), env_mean: np.mean(env_amp) }包络谱的峰值位置要和轴承故障特征频率比对。我一般会计算BPFO、BPFI、BSF、FTF以及它们的2倍频、3倍频处的包络幅值作为独立特征送进模型。这样随机森林就能学到“BPFO处包络峰值高”对应轴承外圈故障。3.3 特征对齐与缺失值处理多源特征拼在一起的时候最容易出问题的是时间对齐。振动特征是按窗口算的文本特征是按工单时间戳算的温度特征是按传感器采样周期算的。如果不对齐融合层拿到的就是错位的信息。我的做法是以振动窗口的时间戳为基准向前向后各找最近的一条温度记录和一条工单记录用时间差作为权重做加权平均。如果时间差超过阈值比如温度超过5分钟、工单超过24小时就标记为缺失用该特征的中位数填充。注意缺失值填充一定要在训练集上计算中位数然后应用到验证集和测试集否则会造成数据泄漏。我见过有人直接在全体数据上算中位数交叉验证分数虚高上线后直接崩掉。4. 三个模型的融合策略与权重分配单模型跑通之后融合层才是真正拉开差距的地方。我试过投票法、 stacking、贝叶斯融合最后稳定下来的是“概率加权规则兜底”的方案。4.1 概率输出的校准与对齐随机森林输出的predict_proba是投票比例不是真正的概率分布往往偏向0和1两端。IsolationForest输出的是异常分数范围在-1到1之间需要先做归一化再转成概率。文本分类器的概率相对准一些但类别体系可能和振动侧不一致。我的处理方式是对随机森林做Platt缩放用逻辑回归拟合分数到概率的映射对IsolationForest做min-max归一化后取1 - normalized_score作为正常概率对文本分类器直接使用predict_proba。三者的类别顺序必须严格对齐我一般用固定的类别列表[normal, bearing, overheat, unbalance]所有模型输出都按这个顺序排列。from sklearn.calibration import CalibratedClassifierCV rf_calibrated CalibratedClassifierCV( rf, methodsigmoid, cv5 ) rf_calibrated.fit(X_train, y_train)校准之后随机森林的概率输出会平滑很多融合时不会因为某个模型过度自信而带偏整体判断。4.2 加权融合的权重确定权重不能拍脑袋定。我用的是验证集上的网格搜索以宏平均F1为目标三个权重的搜索范围是0.1到0.8步长0.05约束是三个权重之和为1。跑一遍大概几分钟找到最优组合后固定下来。实际跑下来我这边的最优权重通常是随机森林0.55、IsolationForest 0.25、TF-IDF文本0.20。随机森林权重最高是因为它直接监督训练对已知故障类型判别最准IsolationForest权重次之负责捕捉未知异常文本权重最低但关键时刻能提供上下文修正。模型权重范围典型最优值主要贡献随机森林0.40-0.700.55已知故障分类IsolationForest0.15-0.350.25未知异常检测TF-IDF文本0.10-0.300.20上下文修正4.3 规则兜底与冲突消解加权融合之后还会出现一些明显不合理的结论。比如振动侧三个模型都指向“正常”但温度特征显示温升速率异常高这时候纯靠概率融合可能会漏报过热。所以我加了一层规则兜底如果温升速率超过阈值且高频能量占比上升强制提升过热类别概率至少0.3。如果1X幅值占比超过0.6且2X、3X很低强制提升不平衡类别概率至少0.3。如果包络谱在BPFO或BPFI处峰值超过背景3倍强制提升轴承故障类别概率至少0.3。这些规则不是拍脑袋写的是从历史误报案例里总结出来的。每一条规则背后都有至少五个真实案例支撑。提示规则兜底的概率提升不要设得太高0.3左右比较合适。设太高会压制模型本身的判断设太低又起不到兜底作用。这个值需要在验证集上反复调。5. 实测中的误报漏报与排查链路模型上线之后最怕的不是效果差而是效果不稳定。我遇到过好几次“训练集F1 0.95上线一周后掉到0.7”的情况。下面把几个典型的排查过程完整还原方便你遇到类似问题时对照。5.1 传感器松动导致的全局误报有一次模型连续三天把一台正常运行的风机判成轴承故障。我第一反应是模型漂移了但检查训练数据分布没发现异常。后来去现场看发现加速度传感器的磁吸底座松了导致信号里混入了大量低频晃动成分峭度和峰值因子都异常升高模型自然判成故障。排查链路是这样的先看特征重要性发现峭度和峰值因子的贡献突然变大再回看原始时域波形发现低频大幅晃动最后现场确认传感器松动。紧固传感器后误报消失。这件事之后我在特征流水线里加了一个“传感器健康检查”步骤如果低频段10Hz能量占比超过总能量的30%就标记传感器可能松动暂时抑制故障报警。5.2 工况变化引起的特征漂移另一台设备模型在负载稳定时表现很好但一到变负载工况就频繁误报。原因是变负载时转速波动1X频率跟着漂移而我的频域特征是按固定频率点取的幅值频率一漂就取不准。解决办法是改用阶次分析Order Analysis把频率轴转换成转速的倍数这样1X永远在阶次1的位置不受转速波动影响。实现上需要同步采集转速信号或者从振动信号里提取转速用瞬时频率估计。改完之后变负载工况下的误报率从15%降到了3%以下。5.3 文本特征引入的噪声TF-IDF文本分类器上线后有一段时间把大量正常工单判成故障。查下来发现维修工单里经常出现“检查轴承”、“轴承正常”这样的描述TF-IDF把“轴承”这个词的权重拉得很高导致只要工单里出现“轴承”就被判成轴承故障。解决方法是引入否定词处理在文本预处理阶段检测“正常”、“无异常”、“未见异常”等否定词如果否定词出现在故障关键词附近就把该关键词的TF-IDF权重降权。同时扩充停用词表把“检查”、“查看”、“确认”这类操作动词过滤掉。def preprocess_text(text): negation_words [正常, 无异常, 未见异常, 良好] fault_keywords [轴承, 过热, 不平衡, 异响, 振动大] for neg in negation_words: for kw in fault_keywords: if neg in text and kw in text: text text.replace(kw, ) return text这个处理很粗糙但实测有效。更精细的做法是用依存句法分析判断否定词的作用范围但工业场景下工单文本通常很短粗糙处理已经够用。6. 把诊断模型封装成可调用的AI Agent模型跑通之后下一步是让它能被人用起来。我把它封装成了一个简单的AI Agent接收自然语言查询返回诊断结论和依据。这样现场工程师不用懂Python直接问“三号风机现在什么状态”就能拿到答案。6.1 Agent的输入输出设计Agent的输入是设备ID和查询时间范围输出是结构化的诊断报告包括当前状态正常/轴承故障/过热/不平衡、置信度、关键证据哪些特征触发了判断、以及建议措施。class DiagnosisAgent: def __init__(self, rf_model, iso_model, text_model, feature_pipeline): self.rf rf_model self.iso iso_model self.text_clf text_model self.pipeline feature_pipeline def diagnose(self, device_id, time_range): vib_features self.pipeline.extract_vibration(device_id, time_range) text_features self.pipeline.extract_text(device_id, time_range) rf_proba self.rf.predict_proba([vib_features])[0] iso_score self.iso.score_samples([vib_features])[0] iso_proba self._normalize_iso(iso_score) text_proba self.text_clf.predict_proba([text_features])[0] fused 0.55 * rf_proba 0.25 * iso_proba 0.20 * text_proba fused self._apply_rules(fused, vib_features) return self._format_report(fused, vib_features)6.2 与PLC和SCADA系统的对接思路Agent本身是Python服务要跟现场PLC和SCADA对接最稳妥的方式是通过OPC UA或者Modbus TCP读取实时数据通过REST API把诊断结果推送给上层系统。我不建议Agent直接写PLC诊断系统只读不写避免误动作。实际部署时Agent跑在边缘计算盒子上每5分钟拉一次最新数据跑完诊断后把结果写入本地数据库同时通过MQTT推送到监控大屏。这样即使网络断了本地诊断也不受影响。6.3 技能扩展与多设备管理一个Agent管一台设备太浪费我把它扩展成多设备管理每个设备维护独立的特征缓存和模型状态Agent根据设备ID路由到对应的处理逻辑。新增设备时只需要注册设备信息和对应的模型路径不用改代码。技能扩展方面我预留了插件接口。比如后面想加“齿轮箱故障诊断”技能只需要实现一个符合接口的诊断类注册到Agent的技能列表里就行。这样整个系统是可生长的不会因为加功能而推倒重来。7. 数据准备与代码结构的实操建议最后这部分讲点实在的。很多人模型调得很好但代码结构一团糟换台机器就跑不起来。我把自己的项目结构分享一下你可以直接抄。7.1 目录结构与依赖管理diagnosis_project/ ├── data/ │ ├── raw/ # 原始振动、温度、工单数据 │ ├── processed/ # 特征工程后的数据 │ └── models/ # 训练好的模型文件 ├── src/ │ ├── features/ # 特征提取模块 │ ├── models/ # 模型训练与融合 │ ├── agent/ # Agent服务 │ └── utils/ # 工具函数 ├── configs/ # 配置文件 ├── notebooks/ # 实验记录 ├── tests/ # 单元测试 └── requirements.txt依赖管理我用requirements.txt固定版本关键包包括scikit-learn、scipy、numpy、pandas、joblib。版本一定要锁死我吃过亏——scikit-learn从0.24升到1.0之后IsolationForest的contamination默认行为变了导致线上模型输出偏移。7.2 模型持久化与版本控制模型训练完用joblib保存文件名带上日期和版本号比如rf_model_20250115_v3.joblib。每次上线新模型旧模型不删保留至少三个版本方便回滚。import joblib from datetime import datetime version datetime.now().strftime(%Y%m%d) joblib.dump(rf_calibrated, fmodels/rf_model_{version}_v3.joblib) joblib.dump(iso, fmodels/iso_model_{version}_v3.joblib) joblib.dump(tfidf, fmodels/tfidf_{version}_v3.joblib)特征流水线的参数也要一起保存包括窗口长度、采样率、特征列表、归一化参数。这些参数和模型是绑定的换一个模型但用错流水线参数结果会完全不对。7.3 上线前的验证清单模型上线前我固定跑一遍验证清单缺一项都不发版在独立测试集上宏平均F1不低于0.85各类别召回率不低于0.75。在最近一个月的数据上误报率不超过5%漏报率不超过3%。特征分布与训练集对比PSI群体稳定性指标小于0.1。单次诊断耗时不超过2秒满足实时性要求。模型文件和流水线参数版本匹配能完整复现训练结果。这套清单帮我拦住了至少三次有问题的上线。有一次测试集F1达标但最近一个月数据上误报率到了12%查下来是设备换了新轴承振动特征分布变了重新训练后才通过。7.4 持续迭代的触发条件模型不是上线就完事了。我设了几个触发重新训练的条件误报率连续三天超过8%、漏报率连续一周超过5%、或者设备大修后振动特征发生显著变化。触发后自动拉取最近三个月数据重新训练人工审核后上线。这套机制跑了一年多模型效果一直维持在可用水平。工业场景的数据分布变化比互联网慢但一旦变化就是结构性的定期重训比频繁调参有效得多。最后分享一个小技巧如果你手头没有足够的故障样本可以用迁移学习。先在公开的轴承数据集比如凯斯西储大学数据集上预训练随机森林然后用自己设备的少量样本做微调。我试过在只有20条故障样本的情况下微调后的模型比从头训练的F1高了将近0.15。