科大讯飞工程机械核心部件寿命预测挑战赛冠军方案分享

狂风中的少年 提交于 2021-02-18 08:50:48

本次分享从以下几个方面展开,尽可能做到有理有据,希望对读者有所帮助:赛题简介、赛题难点、数据预处理、特征工程、数据增强、模型构建、其他、总结。

1.赛题简介

预测性维护是工业互联网应用“皇冠上的明珠”,实现预测性维护的关键是对设备系统或核心部件的寿命进行有效预测。对工程机械设备的核心耗损性部件的剩余寿命进行预测,可以据此对于相关部件的进行提前维护或者更换,从而减少整个设备非计划停机时间,避免因计划外停机而带来的经济损失,比如导致整个生产现场其他配套设备等待故障设备部件的修复。本赛题由中科云谷科技有限公司提供某类工程机械设备的核心耗损性部件的工作数据,包括部件工作时长、转速、温度、电压、电流等多类工况数据。希望参赛者利用大数据分析、机器学习、深度学习等方法,提取合适的特征、建立合适的寿命预测模型,预测核心耗损性部件的剩余寿命。


2.赛题难点

针对数据量以及划分构造训练集的问题采用以下方案解决:

训练集与测试集的构造:

a.一个训练样本按照寿命的一定比例进行构造多个小样本; 这里有两种方法,一是采用固定的比例列表,例如[0.45,0.55,0.63,0.75,0.85]。 二是采用多次选取随机比例构造。
b.测试集不变。 (队友周杰曾尝试过测试集也进行比例划分,有提升)

比如说一个样本的寿命为1000,我们截取450前的数据作为一个训练样本,其剩余寿命为550;然后截取550前的数据,标签为450,以此类推。这样构造之后产生了一个矛盾问矛盾。

样本间的含义是同一比例下的样本;
样本内是指同一个数据文件划分的多个比例样本,由于划分的方式导致时长必然与剩余寿命呈负相关。

3.数据预处理

数据中有大量的负值和突变数据存在,处理好这一部分可以有效提升成绩。针对温度负值,取绝对值。针对时长与累积量的负值数据,我们通过观察以及对工业数据的理解找到了一定规律,并基于此规律进行负值数据处理。具体如下图所示:

可以发现工作时长1后面变为了-149,产生了负值序列。猜测工业传感器可能的情况:

1.传感器由于工作环境以及数据存储问题可能会出现数据初始值重置的情况
2.无论初始值为多少,传感器始终为递增计数

也就是说由1变为-149是初始值重置的结果。但在负值内部依然为递增序列。同时观察数据也可以发现这一规律(-149,-148.25,-148,-147...)。

如何处理这种情况?

分为三步:

1.找到一整块负值序列

2.以该序列前一个正值作为初始值重置,按负值内部递增规律重新计算真实工作时长

3.后续正值加上负值内部极差

#负值处理
def nega_outlier_process(data,col):
#数据最大索引
index_max=data.shape[0]-1
#负值索引
negaindex=pd.DataFrame()
negaindex['index']=data[data[col]<0].index.tolist()

#查找多段负值分割点,用lix保存(‘负值索引’突变的索引)
negaindex['ptp']=pd.rolling_apply(negaindex['index'],2,np.ptp,min_periods=1)
lixmax=len(negaindex)-1
lix=[0]+negaindex[negaindex['ptp']>1].index.tolist()+[lixmax]
#print(negaindex,lix)
#循环读取每一个负值分段
for i in range(len(lix)-1):
#最后一个分段处理,negai代表分段负值索引dataframe
if lix[i+1]==lixmax:
negai=negaindex['index'].iloc[lix[i]:]
#前面分段处理
else:
negai=negaindex['index'].iloc[lix[i]:lix[i+1]]
#分段内负值的极差
nega_ptp=round(data[col][negai.tolist()].ptp(),2)
if negai.max()<index_max:
#分段两端正值的差(前提是有后分段)
posi_ptp=round(data[col][negai.min()-1]-data[col][negai.max()+1],2)
#负值内部加absnega_max,同时加上正值前端
data[col][negai.tolist()]=data[col][negai.tolist()]+\
abs(data[col][negai.tolist()]).max()+\
data[col][negai.min()-1]
#正值后端阶段加上nega_ptp,(st:posi_ptp大于0)再加posi_ptp
if posi_ptp>0:
if lix[i+1]==lixmax:
data[col][negai.max()+1:]=data[col][negai.max()+1:]+nega_ptp+posi_ptp
else:
data[col][negai.max()+1:negaindex['index'][lix[i+1]]]=data[col][negai.max()+1:negaindex['index'][lix[i+1]]]+nega_ptp+posi_ptp
else:
if lix[i+1]==lixmax:
data[col][negai.max()+1:]=data[col][negai.max()+1:]+nega_ptp
else:
data[col][negai.max()+1:negaindex['index'][lix[i+1]]]=data[col][negai.max()+1:negaindex['index'][lix[i+1]]]+nega_ptp
else:
#如果没有后分段,负值内部加上nega_ptp,同时加上正值前端
data[col][negai.tolist()]=data[col][negai.tolist()]+\
abs(data[col][negai.tolist()]).max()+\
data[col][negai.min()-1]
return data


#异常数据处理
def outer_data(data,e):
#负值处理
for col in [ '累积量参数1', '累积量参数2','部件工作时长']:
if data[col].min()<0:
data=nega_outlier_process(data,col)
data['温度信号']=abs(data['温度信号'])
if os.path.basename(e)=='de5d7628aced6b08c5bc.csv':
data['部件工作时长'][data['部件工作时长']>3000]=1611.5
if os.path.basename(e)=='21bade855e1f81d7e1c8.csv':
data['部件工作时长'][data['部件工作时长']==-4103.75]=-3.75
return data

4.特征工程

前面说过采用多比例分割法构建训练集会产生内部矛盾问题。这个矛盾问题有多严重,光凭文字可能感受不出来,请看下图:

(图中为训练集预测结果,life:预测剩余寿命,true:真实剩余寿命,max_life:样本最大工作时长)

对于单个数据文件04f4ee49de378a04bd51.csv构建的5个样本[0.45,0.55,0.63,0.75,0.85],模型预测的剩余寿命与最大工作时长呈正比,但实际剩余寿命是成反比的,似乎把预测结果反过来才比较准确。


初赛发现了这个问题后,一直没能解决,直到复赛组队,这里要特别感谢队友们,找到了几个核心特征,实在太🐮,提升将近3个百,打过这个比赛的知道有多难,提升一个千都很不容易。


本次分享的特征主要有以下几个方面:

1.基本统计特征:max,min,mean,count,std,skew,nunique,percentiles...

2.时频域特征:均方根,波形,脉冲等

3.核心特征:num_peaks,point...

def _roll(a, shift):
if not isinstance(a, np.ndarray):
a = np.asarray(a)
idx = shift % len(a)
return np.concatenate([a[-idx:], a[:-idx]])

def peaks(x, n):
x_reduced = x[n:-n]
res = None
for i in range(1, n + 1):
result_first = (x_reduced > _roll(x, i)[n:-n])
if res is None:
res = result_first
else:
res &= result_first
res &= (x_reduced > _roll(x, -i)[n:-n])
return np.sum(res)

def soreoccurring(x):
unique, counts = np.unique(x, return_counts=True)
counts[counts < 2] = 0
counts[counts > 1] = 1
return np.sum(counts * unique)

def pereoccurring_all(x):
if not isinstance(x, pd.Series):
x = pd.Series(x)
if x.size == 0:
return np.nan
value_counts = x.value_counts()
reoccuring_values = value_counts[value_counts > 1].sum()
if np.isnan(reoccuring_values):
return 0
return reoccuring_values / x.size

def datapoint(x):
if len(x) == 0:
return np.nan
unique, counts = np.unique(x, return_counts=True)
if counts.shape[0] == 0:
return 0
return np.sum(counts > 1) / float(counts.shape[0])

def margin(x):
ptp=x.ptp()
data=np.sqrt(abs(x))
df_margin=(ptp) / (pow(data.mean(),2)+1e-10)
return df_margin

#特征
def stat(data,c,name,t):
c[name + '_max'] = data.max()
c[name + '_min'] = data.min()
c[name + '_mean'] = data.mean()
c[name + '_ptp'] = data.ptp()
c[name+'_nq'] = data.nunique()
c[name + '_std'] = data.std()
c[name + '_skew'] = data.skew()
c[name + '_kurt'] = data.kurt()
c[name + '_var'] = data.var()
c[name + '_mode'] = data.mode()[0]
c[name + '_rms'] = np.sqrt(pow(c[name+'_mean'],2) + pow(c[name+'_std'],2)) #均方根
c[name + '_margin'] = margin(data)#裕度
c[name + '_boxing'] = c[name + '_rms'] / (abs(data).mean()+1e-10)#波形
c[name + 'fengzhi'] =(c[name + '_ptp']) / (c[name + '_rms']+1e-10)#峰值
c[name + 'maichong'] =(c[name + '_ptp']) / (abs(data).mean()+1e-10)#脉冲
c[name + '_25%'] = t['25%']#25分位数
c[name + '_50%'] = t['50%']
c[name + '_75%'] = t['75%']
c[name + '_25%'] = t['85%']
c[name + '_95%'] = t['95%']
c[name+'_peaks10']=peaks(data,10)
c[name+'_peaks50']=peaks(data,50)
c[name+'_soreoccurring']=soreoccurring(data)
c[name+'_pereoccurring_all']=pereoccurring_all(data)
c[name+'_datapoint']=datapoint(data)
return c

此外除了对整体样本进行统计,我们还分阶段进行了统计,该方法提升了2个百分点

核心代码如下:

#处理单个训练样本
def process_sample_single(e,split_p, stage,istest,train_p):
"""
e:单个样本地址
train_p:样本划分几次
split_p:由分时间段下标组成的列表[0,1,2,3..,-1]
stage:时间段名组成的列表['btg1_', 'btg2_', 'btg3_']
return:dateframe
"""

data = pd.read_csv(e)
data=outer_data(data,e)
lifemax=data['部件工作时长'].iloc[-1]
indexmax=len(data)-1
if istest:
train_p=1
else:
randoms=np.random.RandomState(int(indexmax/2)+train_p)
train_p=randoms.uniform(0.35,0.85)
data=data[data.index<=indexmax*train_p]
feature_na=list(filter(lambda x:x not in['开关1信号','开关2信号','告警信号1','设备类型'],data.columns))
split_p=separate_data(data,sept_n)
c = {'train_file_name': os.path.basename(e)+str(train_p),
'开关1_sum':data['开关1信号'].sum(),
'开关2_sum':data['开关2信号'].sum(),
'开关1_开关2':data['开关1信号'].sum()/(0.00000001+data['开关2信号'].sum()),
'开关总':data['开关1信号'].sum()+data['开关2信号'].sum(),
'告警1_sum':data['告警信号1'].sum(),
'告警1比例':data['告警信号1'].sum()/(0.00000001+data['开关1信号'].sum()+data['开关2信号'].sum()),
'设备':data['设备类型'][0],
'life':lifemax-data['部件工作时长'].iloc[-1]}

for i in feature_na:
#全数据
t = data[i].describe(percentiles=[0.25,0.5,0.75,0.85,0.95])
c=stat(data[i],c,i,t)

#前一半数据
data_behalf=data.iloc[0:int(len(data)/2),:]
bet = data_behalf[i].describe(percentiles=[0.25,0.5,0.75,0.85,0.95])
c=stat(data_behalf[i],c,'behalf'+i,bet)

#后一半数据
data_bahalf=data.iloc[int(len(data)/2):,:]
bat = data_bahalf[i].describe(percentiles=[0.25,0.5,0.75,0.85,0.95])
c=stat(data_bahalf[i],c,'bahalf'+i,bat)

# (sept_n)个时间分段统计
if split_time:
for j in range(sept_n):
stg = stage[j]
data_stg = data[split_p[j]:split_p[j + 1]]
p = data_stg[i].describe(percentiles=[0.25,0.5,0.75,0.85,0.95])
c=stat(data_stg[i],c,stg + i ,p)

this_tv_features = pd.DataFrame(c, index=[0])
return this_tv_features

5.数据增强

俗话说特征工程决定模型上限,那什么东西决定特征上限呢?答案是数据。


数据决定特征的上限可以从两个方面来理解,一是数据质量和特性直接决定了特征工程的意义,二是做出了合适的特征能否发挥作用(让关键特征发挥出作用)。我们通常做的都是第一类,比如进行数据清洗提升数据质量等,第二类似乎很少使用。


在这道题里体现的非常明显,由于工作时长特征(或者其他累积量特征)所占的影响力过大,以至于有些可以解决矛盾问题的特征都被掩盖了。


换个角度解决矛盾问题,从数据入手。矛盾的根源在于特征不能区分样本间与样本内,或者能区分的特征不能发挥作用。考虑到预测的剩余寿命是和时长正相关,可以加大负相关的影响力:取剩余寿命为0的样本加入训练集。


由于评价指标的特殊性,同样大小的误差值,剩余寿命越小,res变化越大,初赛有一个异常样本设为0提升的场景,大家多半有所记忆。


所以取了50个剩余寿命为0的样本加入训练集(其实还尝试过划分一个90%以上的比例,但效果非常差,这个大家应该也做过,推测可能的原因在于产生大量90%比例的样本导致整体预测偏低失衡)

train_single=get_together(n,train_list,False,func)
train_single.to_csv(outer+func.__name__+name+str(sept_n)+'_train.csv',index=False)
test_single =get_together(n,test_list2,True,func)
test_single.to_csv(outer+func.__name__+name+str(sept_n)+'_test2.csv',index=False)
train_single_life0=get_together(n,train_list[:50],True,func)
train_single_life0.to_csv(outer+func.__name__+name+str(sept_n)+'_train_life0.csv',index=False)


6.模型构建

这里依然没有采用多么复杂的融合,lightgbm单模:

#lgb
def lgb_cv(train,trainb,test,params,fit_params,feature_names,nfold):
train_pred = pd.DataFrame({
'true': train[ycol],
'pred': np.zeros(len(train))})
test_pred = pd.DataFrame({idx: test[idx], ycol: np.zeros(len(test))},columns=[idx,ycol])
cv_model=[]
num_model_seed=[2020]
for model_seed in num_model_seed:
kfolder = KFold(n_splits=nfold, shuffle=True, random_state=model_seed)
for fold_id, (trn_idx, val_idx) in enumerate(kfolder.split(train)):
print(f'\nFold_{fold_id} Training ================================\n')
lgb_trn = lgb.Dataset(
data=train.iloc[trn_idx][feature_names].append(trainb[feature_names]).reset_index(drop=True),
label=train.iloc[trn_idx][ycol].append(trainb[ycol]).reset_index(drop=True),
feature_name=feature_names
)
lgb_val = lgb.Dataset(
data=train[(train['is0']==0)&(train.index.isin(val_idx))][feature_names].reset_index(drop=True),
label=train[(train['is0']==0)&(train.index.isin(val_idx))][ycol].reset_index(drop=True),
feature_name=feature_names)
lgb_reg = lgb.train(params=params, train_set=lgb_trn, **fit_params,
valid_sets=[lgb_trn, lgb_val])
cv_model.append(lgb_reg)
val_pred = lgb_reg.predict(
train.iloc[val_idx][feature_names],
num_iteration=lgb_reg.best_iteration)
train_pred.loc[val_idx, 'pred']+= val_pred/len(num_model_seed)
test_pred[ycol] += lgb_reg.predict(test[feature_names]) / (nfold*len(num_model_seed))
test_pred=target_exp(test_pred)
print(train_pred)
# test_pred['life'][test_pred['life']>4000]=5000
# test_pred['life'][test_pred['life']<50]=0
score = compute_loss_log(train_pred['true'], train_pred['pred'])
return score,cv_model,test_pred,train_pred


7、其他


初赛的时候还有一个骚操作,当时一位同学问为什么初赛分享的划分比例有个0.63,其实是有依据的,EDA训练集测试集的分布时,计算了一下训练集测试集相同分位数的比例,大致为0.63。然后我直接取(最大时长/0.63-最大时长)作为剩余寿命,提交的结果,排名前十。

指标优化,对y进行log化,将评价函数转变为MSE。这个大致有3个百提升

def target_log(data):
data[ycol]=np.log(data[ycol]+1)
return data

def target_exp(data):
data[ycol]=np.exp(data[ycol])-1
return data

#评价指标
def compute_loss_log(target, predict):
temp = target - predict
res = np.dot(temp, temp) / len(temp)
return res

8、总结

良好的队伍沟通与分享。我们将自己的想法和思路都写在同一个云端文档里,上分与降分的操作,确保不重复操作踩坑,扩展思路。


对业务情景、数据、指标的充分理解,在理解基础上进行数据清洗和指标优化,每一步操作有可靠的依据,而不是一把梭,哪个有用用哪个。


找准关键核心问题。针对核心问题做特征,针对核心问题做数据。


运气也得好(手动狗头)。很多时候都是尝试一次就失败一次,运气好的话可能一次就成功了。



往期精彩


列夫·托尔斯泰


与人交谈一次,往往比多年闭门劳作更能启发心智,

思想必定是在与人交往中产生,而在孤独中进行加工和表达。


您可以长按下方的二维码添加我

备注:加群交流

稍后会拉你进学习交流群

你也「在看」吗?👇

本文分享自微信公众号 - Python学习与数据挖掘(Python_CaiNiao)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!