简介:面向负荷监测、智能家居与能源管理研究的非侵入式负荷分解(NILM)工程包,基于因子隐马尔可夫模型(FHMM)从家庭总用电数据中识别并分离各独立电器的运行状态,有效解决多电器叠加状态下难以区分的难题,适用于机器学习、模式识别方向的开发者与科研人员。压缩包共26个文件,以Python脚本和Jupyter Notebook为主干,包含主程序、数据预处理、模型训练与评估等模块,辅以配置文件、样例数据等,整体仅628KB,结构清晰、轻量易用。已有580人学习浏览,对NILM入门与进阶均具参考价值,可直接用于学术研究或工程实践。包内覆盖数据预处理、特征提取、FHMM模型构建、训练调参、负荷分解及结果评估的完整流程,并提供可运行的示例Notebook,帮助快速理解算法原理并复现实验,也为后续拓展多电器识别算法奠定坚实基础。
1. 非侵入式负荷分解为什么值得从FHMM做起:不给每台电器装表,也能拆出各自的用电量
你家里通常只有一块总电表,但你想知道冰箱、空调、微波炉各用了多少电。装一圈子表不现实,于是就有了非侵入式负荷分解(NILM,即标题里的“非倾入式”):只分析总表入口的功率波形,把叠加在一起的设备负荷拆回单台设备。早期做法是看功率跳变和事件匹配,遇到多设备同时开关就乱套。因子隐马尔可夫模型(FHMM)换了个思路——把每台电器当作一条独立的隐马尔可夫链,多台电器共同生成总功率观测,再通过联合解码还原每台电器的状态序列。这个模型的好处是天然支持多设备、多状态,而且有成熟的概率推断工具可用。适合刚接触NILM、手里只有公共数据集或自家总表数据的工程师,先拿它跑通第一个可解释的基线,再决定要不要往深度学习方向走。下面按“建模→数据准备→训练评估→排错→进阶”的顺序,把整个落地路径拆给你。
2. 因子隐马尔可夫模型建模一本账:联合状态、转移矩阵与发射概率的工程化拆解
2.1 从HMM到FHMM:把“一台电器一个马尔可夫链”变成可计算的状态机
先回顾常规HMM。一台设备被建模成一条隐马尔可夫链,隐状态就是设备的运行档位,比如冰箱的“待机/制冷”,空调的“待机/低风/高风”。每条链有一个状态转移矩阵A、初始分布π和发射概率。发射概率描述“当设备处于某个状态时,观测功率服从什么分布”。单独用一个HMM去拟合一台设备很容易,但NILM面对的是总功率,是所有设备状态共同作用的结果。
如果只是训练多个独立HMM、然后各自解码再相加,会出问题:因为任一台设备状态的改变都会影响总观测,独立解码时彼此没有约束,可能解出“冰箱开着、空调也开着”,但总功率却对不上的荒唐组合。FHMM把K台设备的HMM并成一个联合模型,联合状态是各设备状态的笛卡尔积。设备之间假设独立,所以联合转移矩阵是各个转移矩阵的Kronecker积,联合发射概率则是“所有设备状态均值相加后加噪声”的总功率模型。这样一来,分解问题变成了对联合隐状态序列的推断,设备间的约束自然被带上。
工程上的直接收益是:训练阶段可以各设备独立训练(或者半监督初始化),推断阶段再组合成联合状态空间。这意味着我们能复用hmmlearn这类成熟库去训单设备模型,而不需要自己从零写Baum-Welch。
2.2 落地前必须敲定的五个工程参数
FHMM最怕不是模型推不出来,而是参数给得不符合物理事实。下面这五项在你写代码前就该定下来。
| 参数 | 建议取值 | 影响 |
|---|---|---|
| 设备状态数K_i | 冰箱2,空调3,微波炉2,电脑2 | 状态数少了分不出档位,多了会过度拟合噪声 |
| 采样间隔 | 8秒~1分钟 | 小于8秒数据量太大,大于1分钟会漏掉短时运行设备 |
| 观测特征 | 有功功率P,必要时加无功功率Q | P对电阻类设备区分度好,Q能补电机类设备差异 |
| 设备清单 | 先选3~5台大功率设备 | 过多设备联合状态爆炸,过少又没意义 |
| 发射噪声标准差 | 10W~20W | 太小模型过拟合尖峰,太大掩盖小功率设备 |
状态数的选择是最容易拍脑袋的地方。常见做法是先看设备铭牌和额定功率档位,例如空调一般有“待机、低风、高风”三档,那就给3个状态。数据驱动一点的话,可以用BIC或肘部法则在2~5个状态里选,但那是后期优化,基线阶段直接按物理常识设置就行。
采样间隔要和设备类型配套。冰箱压缩机一轮工作几十分钟,1分钟采样也能捕捉到。但微波炉只运行一两分钟,用1分钟采样就可能只看到半个脉冲,这种设备要么不放进清单,要么提高采样率。观测噪声的标准差可以先设15W左右,后面用训练集的残差再去校准它。
2.3 手写一个最小FHMM核心:转移矩阵组合、联合发射概率与维特比解码
hmmlearn这类库没有直接提供FHMM实现,需要自己拼装。下面这个最小骨架是所有后续工作的地基:它不负责训练,只负责把多台设备的HMM参数组合成联合模型,并对一段总功率序列做维特比解码。
import numpy as np from itertools import product class TinyFHMM: def __init__(self, models, mean_power, noise_std=15.0): """ models: 每个设备一个 dict,含 'A'(转移矩阵)和 'pi'(初始分布) mean_power: mean_power[设备下标][状态下标] = 该状态的功率均值(W) noise_std: 总功率高斯噪声标准差 """ self.models = models self.mean_power = mean_power self.noise_std = noise_std self._build_joint_params() def _build_joint_params(self): state_space = [range(m['A'].shape[0]) for m in self.models] self.joint_states = list(product(*state_space)) # 所有联合状态 self.S = len(self.joint_states) # 联合转移矩阵:独立设备 => Kronecker 积 A = self.models[0]['A'] for m in self.models[1:]: A = np.kron(A, m['A']) self.A = A pi = self.models[0]['pi'] for m in self.models[1:]: pi = np.kron(pi, m['pi']) self.pi = pi # 联合状态下总功率均值 = 各设备状态均值之和 self.means = np.array([ sum(self.mean_power[d][s] for d, s in enumerate(st)) for st in self.joint_states ]) def decode(self, P): """ P: 一维 numpy 数组,单位 W,已重采样到固定间隔 返回 (各设备状态序列, 联合状态序列) """ T = len(P) V = np.full((T, self.S), -np.inf) back = np.zeros((T, self.S), dtype=int) def log_emit(t, s): diff = P[t] - self.means[s] return -0.5 * ((diff / self.noise_std) ** 2) V[0] = np.log(self.pi + 1e-12) + [log_emit(0, s) for s in range(self.S)] for t in range(1, T): for s in range(self.S): trans = np.log(self.A[:, s] + 1e-12) + V[t - 1] best = np.argmax(trans) V[t, s] = trans[best] + log_emit(t, s) back[t, s] = best state_seq = np.empty(T, dtype=int) state_seq[-1] = np.argmax(V[-1]) for t in range(T - 1, 0, -1): state_seq[t - 1] = back[t, state_seq[t]] return self._map_to_devices(state_seq), state_seq def _map_to_devices(self, seq): return np.array([self.joint_states[s] for s in seq]).T逻辑说明:联合转移矩阵用np.kron逐台设备乘起来,顺序要与itertools.product的枚举顺序一致——product按第一个设备变化最慢、最后一个设备变化最快遍历,np.kron也是先展开右侧再左侧,两者对齐。维特比里加1e-12是为了防止转移概率为0时取对数报错。发射概率简化为高斯分布的对数,没算归一化常数,因为它对同一时刻所有状态是常数,不影响argmax结果。
参数说明:noise_std这个值取大了会让解码倾向于停留在概率上“安全”的状态,导致状态切换不灵敏;取小了又会让解码去追每个功率尖峰。先给15W跑一版,然后看残差的方差再回来调整。这个骨架只支持暴力维特比,联合状态数超过两三百个时速度会肉眼可见地变慢,第5章再讲怎么用束搜索替代。
3. 数据准备与特征工程:从总表功率一路做到能喂给FHMM的训练样本
3.1 公共数据集选型:优先AMPds还是UK-DALE、REDD
NILM领域常用的三个公共数据集各有脾气。AMPds是加拿大一户家庭,1分钟采样,有21个回路,记录时长超过一年,适合验证季节变化对模型的影响。UK-DALE采样率较高(6秒~1秒),多户家庭,带电器事件标注,适合需要精确开关时刻的场景。REDD出现得早,数据质量参差,但胜在设备类型丰富。
我的建议是别贪多:先选一个数据集、三到五台设备、两三周数据,把整条链路跑通。例如AMPds里挑冰箱、空调、微波炉和洗碗机,全都是状态边界清晰的设备。注意取“总表”数据时要取数据集的mains或whole_house字段,不要自己把各个回路相加——真实总表还包含线路损耗和少量未计量设备,自己加出来的数字过干净,反而会掩盖模型的真实表现。
3.2 重采样与对齐:1分钟数据做不了的事,别上模型硬扛
数据集里不同回路的采样时间戳往往错位,有些设备数据还缺一段。第一步是把所有序列重采样到统一间隔,并用中位数滤波处理异常尖峰。
import pandas as pd def load_and_clean(mains_csv, circuit_csvs, freq='1min'): # 总表数据 mains = pd.read_csv(mains_csv, parse_dates=['timestamp'], index_col='timestamp') P_main = mains['power'].resample(freq).mean() # 各设备回路 devices = {} for name, path in circuit_csvs.items(): df = pd.read_csv(path, parse_dates=['timestamp'], index_col='timestamp') devices[name] = df['power'].resample(freq).mean() # 按总表有效时间对齐 frames = [P_main] + list(devices.values()) df = pd.concat(frames, axis=1, join='inner') df.columns = ['mains'] + list(devices.keys()) # 用61点滑动中位数处理异常尖峰和负值 for col in df.columns: x = df[col] med = x.rolling(61, center=True, min_periods=1).median() df[col] = x.where(x.between(-20, x.quantile(0.999) * 3), med) return df参数说明:freq='1min'把高采样率数据降到1分钟,能平滑瞬时尖峰,代价是丢短时设备事件。rolling(61)在1分钟采样下约等于1小时的滑动窗口,对功率曲线的长周期漂移(比如气温升高导致空调功率波动)不敏感。中位数滤波对“瞬时毛刺”很有效,但会磨掉真实的高频开关动作,所以这个清洗步骤只用于训练集,测试集不建议做重滤波,否则评估结果会虚高。
缺失段的处理要格外小心:中间缺了超过5分钟的连续数据,直接切片断开,不要用插值硬接。插值会让模型以为设备在缺失段内保持某个状态,污染状态转移矩阵的估计。
3.3 要不要用无功功率和谐波:特征选择在FHMM里的真实作用
FHMM的发射模型可以是一维高斯,也可以是二维高斯。一维时只使用有功功率P,这是最稳妥的起点。P对电阻类设备(电暖器、电水壶)区分度好,对电机类设备(空调压缩机、冰箱)和纯电阻类设备的区分其实有限。
无功功率Q能补这个短板。空调、冰箱这类带电机和变频器的设备,其Q/P比例与电阻类设备明显不同,在二维发射模型下更容易区分。代价是每个联合状态要维护一个二维高斯,协方差矩阵参数变多,训练数据不足时容易过拟合。谐波特征更复杂,NI LM的学术论文里常常作为加分项,但工程上第一版不要碰它。谐波的采集频率通常要几kHz,和功率数据对齐本身就费劲,收益却不一定明显。
如果数据集里没有Q,不用强求。先用P跑通,等基线稳了再考虑扩展特征维度。
3.4 训练-验证-测试窗口切分:防止把同一周既训练又测试
时间序列最忌讳随机切分。冰箱的运转周期、家庭用电作息都有周期性,如果把同一周的数据既拿来训练又拿来测试,模型会“记住”这周的作息,换成新的一周立刻打回原形。
train_end = df.index[int(len(df) * 0.7)] train = df.loc[:train_end] test = df.loc[train_end:] # 监督/半监督训练用 train 里各设备单独功率序列 fridge_train = train['fridge'].dropna().values hvac_train = train['hvac'].dropna().values # ...更稳的做法是跨季节抽样:例如在1月和7月各取两周作测试,训练覆盖这两个月前后的数据。FHMM的设备参数被假设是静态的,但空调夏天和冬天的平均功率差别很大,单靠一段数据训练出来的发射模型会随着季节偏移。所以你训练时看到的F1再高,也别高兴太早,先看一眼测试段落在哪个季节。
4. 训练、评估与可视化:让因子隐马尔可夫模型真的能分解出每一台设备
4.1 EM训练:Baum-Welch在FHMM里怎么落地
TinyFHMM只负责解码,参数从哪来?常见做法是用hmmlearn对每台设备的单独功率序列做监督/半监督训练,然后把训练好的转移矩阵和发射均值填入TinyFHMM。为什么可以分开训练?因为FHMM假设设备独立,联合模型的先验部分(转移矩阵、初始分布)就是各设备参数的乘积,观测共享只影响解码阶段。
from hmmlearn import hmm def fit_device_hmm(device_power, n_states, n_iter=50): """ device_power: 该设备有功功率一维序列 n_states: 状态数,按设备档位设置 返回模型和状态功率均值 """ X = device_power.reshape(-1, 1) model = hmm.GaussianHMM( n_components=n_states, covariance_type="diag", n_iter=n_iter, tol=1e-4, random_state=42, ) model.fit(X) means = model.means_.ravel() return model, means参数说明:n_components必须对应设备真实档位,空调给3、冰箱给2,别图省事统一设成2。n_iter=50是一般收敛范围,如果日志显示没收敛就加大到100。covariance_type="diag"在单特征下就是普通方差,扩展到二维时才体现区别。random_state=42是为了复现,不然模型每次随机初始化结果差别很大。
有一个值得注意的点:hmmlearn训练时按最大似然原则搜索,但一次训练可能陷入局部最优。我一般会固定随机种子多跑几次,选择log-likelihood最高的结果,但不会根据测试集指标来回挑模型——那等于在测试集上调参,会导致后续评估失真。
4.2 把训练结果组装成FHMM并完成总表解码
三台设备训练完后,直接组装成TinyFHMM并跑解码。
models = [] means_per_device = [] for name in ['fridge', 'hvac', 'microwave']: power = train[name].dropna().values m, means = fit_device_hmm(power, n_states=2) models.append({'A': m.transmat_, 'pi': m.startprob_}) means_per_device.append(means) fhmm = TinyFHMM(models, means_per_device, noise_std=15.0) device_state_seq, joint_seq = fhmm.decode(test['mains'].values)解码结果里device_state_seq是一个二维数组,第一行是冰箱状态序列,第二行是空调,第三行是微波炉。把每个状态映射到训练得到的功率均值,就得到各设备的分解功率曲线。
这里有个工程细节:如果某台设备在训练数据里某个状态出现次数极少,转移矩阵里那一行会接近零,解码时代码加上1e-12还能跑,但状态基本不会出现。这通常是状态数给多了,把n_states降回来。
4.3 评估指标:F1、MAE怎么算,各自的适用边界
NILM评估不能只看一个指标。状态判定类指标用F1,功率还原类指标用MAE,两个都要看。
from sklearn.metrics import f1_score def evaluate_state_and_power(state_true, state_pred, power_true, power_pred): # 二值设备只看开/关 f1 = f1_score(state_true, state_pred, average="binary") # 多状态设备:按档位分别算F1再平均,避免高功率档位淹没低功率档位 # 功率误差:分设备算,再跨设备取平均 mae = np.mean(np.abs(power_true - power_pred)) return {"F1": f1, "MAE_W": mae}F1的坑在于多状态设备。假设空调三档:待机、低风、高风,大多数时间在待机,一个“永远预测待机”的模型F1会很高,但它完全没抓到制冷时段。所以对多状态设备,要按状态分别算precision/recall再平均,或者直接看功率曲线的MAE。MAE的坑则在于大功率设备误差会淹没小功率设备。空调偏差100W和路由器偏差5W,平均MAE时前者占主导。按设备算MAE,再向用户汇报时按设备逐一列出来。
4.4 可视化验证:把分解功率和真实功率画在一起,看状态切换时间点
指标只能告诉你“差多少”,不能告诉你“错在哪”。自己采集数据做验证时,最有效的动作是画图。
import matplotlib.pyplot as plt # 取测试集前240个点(4小时)展示 t = test.index[:240] ax = plt.subplot() ax.plot(t, decompose_power(device_state_seq[0], means_per_device[0])[:240], label='fridge_pred') ax.plot(t, test['fridge'].values[:240], label='fridge_true', alpha=0.8) plt.legend() plt.savefig('fridge_decoding.png', dpi=150)看什么?第一看状态切换时刻是否对齐,冰箱压缩机的启动和停机时间点能不能对上;第二看稳态功率值是否一致。如果稳态一致但切换时刻乱跳,多半是转移矩阵建模有问题;如果切换时刻还行但稳态功率不对,则是发射均值估计偏了。
4.5 训练时最常被忽略的:数据泄漏与测试分布偏移
做NILM基线最容易犯的错就是把训练和测试混在一起。除了第3章说的按时序切分,还要注意从设备单独序列里训练HMM时,不要用测试时段的数据去补缺失值。另一个隐蔽问题是季节分布偏移:冰箱在夏季的制冷次数明显增加,但每次制冷的功率均值变化不大;空调则完全不同,夏季平均功率可能是春秋的两倍。当你想把模型从一个月推广到全年时,最好训练数据覆盖多个温度区间,否则测试效果会随季节越来越差。
5. 避坑/常见问题/排查:FHMM做非侵入式负荷分解最容易翻车的五个场景
5.1 分解结果里所有设备永远同一个状态
现象:解码出来的设备状态序列几乎不变,冰箱永远“待机”,空调永远“待机”,总功率一波动模型没有任何反应。
原因:转移矩阵训练时自转移概率过高,模型宁可维持原状态也不愿付出状态切换的代价。另一种常见原因是联合状态枚举顺序与Kronecker积顺序不一致,导致转移矩阵行列错位,解码器只能找到一个“安全”的局部最优。
解决:检查_build_joint_params里np.kron和itertools.product的顺序是否一致;然后给转移矩阵加自转移下限,例如把对角线概率最小值限制在0.8附近,阻止模型“永不变换”。也可以训练完直接看model.transmat_的对角线值,如果都超过0.98,说明设备状态过于稳定,适当减少状态数。
5.2 小功率设备完全分不出来,大功率设备也不稳
现象:清单里有台50W的路由器,解码结果里它的状态从来没变过。
原因:观测信噪比不足。总功率噪声标准差15W,路由器开关只有50W差距,理论上还能分,但当它和大功率设备同时工作时,总功率的变化主要由大设备主导。FHMM是高斯发射模型,小设备的信号被当成噪声吸收了。
解决:先锚定大功率设备,把它们的状态解准,再回头加小设备。另一种做法是给发射模型增加每个设备各自的方差,而不是全局统一噪声。如果小设备已被吸收干净,就没必要硬塞进FHMM。NILM的可分解功率下限一般就在30W左右,低于这个量级建议直接放弃。
5.3 模型训练收敛但解码效果还不如简单的功率阈值
现象:模型log-likelihood收敛得很漂亮,但分解出的冰箱状态切换抖动,连“功率>100W判定为开”的阈值法都不如。
原因:GaussianHMM假设每个状态的功率呈单高斯分布,但冰箱的“制冷”状态功率可能在不同工况下相差很大,单高斯无法覆盖这种多峰分布。另一个原因是没有建模状态驻留时长,模型允许设备在相邻时刻疯狂切换状态。
解决:把发射模型换成GMMHMM,每个状态内部用2~3个高斯分量覆盖多工况。
from hmmlearn import hmm model = hmm.GMMHMM( n_components=2, # 设备状态数 n_mix=3, # 每个状态用3个高斯分量 covariance_type="diag", n_iter=50, random_state=42, ) model.fit(X)5.4 换了自己的表计数据就翻车
现象:公共数据集上跑得好好的,换成自己采集的智能电表数据后完全乱套。
原因:自采数据最常见的坑是采样间隔不稳,有时5秒一个点,有时20秒一个点;其次是单位问题,有些电表上报的正向有功电能单位是0.001kWh,换算成功率要做差分,容易搞错量级;还有时间戳时区混乱,导致重采样后数据错位。
解决:先画原始波形确认量级,再对时间戳做差分检查间隔分布。
# 用 pandas 检查时间戳间隔 df.index.to_series().diff().describe()5.5 联合状态空间爆炸
现象:6台设备平均每台3个状态,联合状态空间3^6=729,解码一个7天的序列耗时几分钟。
原因:暴力维特比每个时间步要遍历S^2个转移组合,S指数增长,计算量增长是平方级别。
解决:先控制设备数量,5台以上就不要用暴力维特比。改用束搜索:每个时间步只保留分数最高的K个候选路径,跳过低概率分支。工程上的第一优先不是优化算法,而是精简设备清单。
6. 进阶:把FHMM从基线变成可上线的分解工具——束搜索、约束注入与在线校准
当你跑通了5台以内的FHMM基线,接着需要面对三个问题:解码速度、状态持久性、参数漂移。
束搜索替代暴力维特比是最直接的提速手段。每个时间步只保留Top-K候选状态,K取50~100,计算复杂度降到O(TKS),设备数到8台也能跑。代价是可能丢掉全局最优路径,但NILM里绝大多数状态转移概率很低,截断后几乎不影响结果。另一个加速技巧是利用转移矩阵的Kronecker结构,按设备维度做动态规划,这需要重写解码器,收益比束搜索更明显,但改动量也更大。
状态持久性约束值得认真做。真实设备的状态切换有物理限制,冰箱压缩机最短运行时间通常在5分钟以上,不可能隔几秒就切换。你可以在维特比解码时不改转移矩阵,而是在后处理里过滤掉短于阈值的状态片段;更优雅的写法是给“保持当前状态”的转移概率加权重,具体做法是把转移矩阵改造成“平均状态驻留时间=至少10个采样点”的形式,即对角元素设为1-exp(-1/10)。这个参数在采样率为1分钟时约等于10分钟,能大幅减少状态抖动。
在线校准是落地时绕不开的一环。设备功率会漂移:冰箱结霜后平均功率上升,空调夏季功率高于春秋。典型做法是每隔一定时间窗口,用当前解码出的状态片段重新估计发射均值,冻结转移矩阵,只更新均值。这个操作要防止重估漂移——如果某段时间解码错了,用它更新均值只会让错上加错。保险的做法是先计算窗口内分解曲线与总表残差的方差,残差方差比训练阶段明显增大时,才触发重估。
验证技巧:把分解出的冰箱功率和真实回路功率做24小时滑窗相关性,相关系数低于0.6说明状态切换的时刻不对,不是功率幅值问题,此时调均值没用,要回头调转移矩阵或状态数。我自己的习惯是每换一个数据集,先画一周真实功率曲线再动手建模。第一次做NILM时,我在公共数据集上跑通后直接换上自采数据,结果因为采样间隔不一致连续翻车三天;后来老老实实先画原始波形、确认量级和单位、再走清洗管线,才把基线稳住。如果你打算把FHMM当作NILM的起点,建议冰箱、空调加微波炉三台设备起步,跑通后再扩大设备范围。希望帮到你。
本文还有配套的精品资源,点击获取