2019CCF-BDCI-乘用车细分市场销量预测方案(Top1%)

2021-12-24 10:09:30 浏览数 (2)

写在前面

本文将带来最近一场比赛的方案分享,这是一场有关时间序列的问题,虽然没有进决赛,不过很多点还是非常值得学习的。希望能给大家带来帮助,也欢迎与我进行更多讨论。

这里也将从代码出发,来分享我的解题思路。

正文

1.数据说明

赛题给出了历史销量数据包含60个车型在22个省份,从2016年1月至2017年12月的销量。参赛队伍需要预测接下来4个月(2018年1月至2018年4月),这60个车型在22个省份的销量;参赛参赛队伍需自行划分训练集数据进行建模。

数据文件包括:

[训练集]历史销量数据:train_sales_data_v1.csv

[训练集]车型搜索数据:train_search_data_v1.csv

[训练集]汽车垂直媒体新闻评论数据和车型评论数据:train_user_reply_data_v1.csv

[评测集]2018年1月至4月的各车型各省份销量预测:evaluation_public.csv

初赛和复赛并无太多差异,主要是车型增多而已。

2.评测指标

在我看来对评测指标的理解,也是能够帮助上分的。

初赛复赛阶段的在线评分采用NRMSE(归一化均方根误差)的均值作为评估指标。首先单独计算每个车型在每个细分市场(省份)的NRMSE,再计算所有NRMSE的均值,计算方式为:

其中,

车型第

个样本的真实值,

为第

个样本的预测值,

为k车型的预测样本数量(n=4),

为真实值的平均值,

为需要预测的车型数量。

为最终评价指标,值为0-1之间,越接近1模型越准确。

不难理解这里需要按车型在不同省份的得分,然后整合起来得到最终的分数。

关键来了,这样的评价指标有一个特点,销量小的车型对评分带来更大的影响,依据这个特征,可以考虑添加样本权重,以及最终融合方式。

下面给出了计算权重的方式,当然也是出于经验的方式构造的,可以有更多优化。

代码语言:javascript复制
# 样本权重信息
data['n_salesVolume'] = np.log(data['salesVolume'] 1)
df_wei = data.groupby(['province','model'])['n_salesVolume'].agg({'mean'}).reset_index().sort_values('mean')
df_wei.columns = ['province','model','wei']
df_wei['wei'] = 10 - df_wei['wei'].values
data = data.merge(df_wei, on=['province','model'], how='left')

只需在模型训练的时候加上权重信息即可,初赛有千分位的提升,复赛没有具体测。

代码语言:javascript复制
dtrain = lgb.Dataset(df[all_idx][features], label=df[all_idx]['n_label'], weight=df[all_idx]['wei'].values)

下面给出评价指标的代码:

代码语言:javascript复制
def score(data, pred='pred_label', label='label', group='model'):
    data['pred_label'] = data['pred_label'].apply(lambda x: 0 if x < 0 else x).round().astype(int)
    data_agg = data.groupby('model').agg({
        pred:  list,
        label: [list, 'mean']
    }).reset_index()
    data_agg.columns = ['_'.join(col).strip() for col in data_agg.columns]
    nrmse_score = []
    for raw in data_agg[['{0}_list'.format(pred), '{0}_list'.format(label), '{0}_mean'.format(label)]].values:
        nrmse_score.append(
            mse(raw[0], raw[1]) ** 0.5 / raw[2]
        )
    print(1 - np.mean(nrmse_score))
    return 1 - np.mean(nrmse_score)

3.特征工程

方案主要构造了传统的时序特征,很多时候也都是从这几个方面进行扩展,或者更好的描绘这几种特性。遇到这类问题,从这四点考虑,准没错的。此次比赛我对异常的方面做的很少,也没有找到有效办法。不过我始终坚信,异常点方面还是能提不好分的。

比赛预测省份下车型的每月汽车销量,我们可以从不同维度来提取特征,可以从微观,也可以从宏观角度。我主要从两点考虑构造,同时也构造了popularity的相关特征

代码语言:javascript复制
1. 省份/车型/月份:汽车销量,popularity
2. 车型/月份:汽车销量

具体区间怎么选择,统计方式怎么选择?下面给出我的方案

代码语言:javascript复制
def getStatFeature(df_, month, flag=None):
    
    df = df_.copy()
    
    stat_feat = []
    
    # 确定起始位置
    if (month == 26) & (flag):
        n = 1
    elif (month == 27) & (flag):
        n = 2
    elif (month == 28) & (flag):
        n = 3
    else:
        n = 0
    print('进行统计的起始位置:',n,' month:',month)

    ######################    
    # 省份/车型/月份 粒度 #
    #####################
    df['adcode_model'] = df['adcode']   df['model']
    df['adcode_model_mt'] = df['adcode_model'] * 100   df['mt']
    for col in tqdm(['label']):
        
        # 平移
        start, end = 1 n, 9
        df, add_feat = get_shift_feature(df, start, end, col, 'adcode_model_mt')
        stat_feat = stat_feat   add_feat
        
        # 相邻 
        start, end = 1 n, 8
        df, add_feat = get_adjoin_feature(df, start, end, col, 'adcode_model_mt', space=1)
        stat_feat = stat_feat   add_feat
        start, end = 1 n, 7
        df, add_feat = get_adjoin_feature(df, start, end, col, 'adcode_model_mt', space=2)
        stat_feat = stat_feat   add_feat
        start, end = 1 n, 6
        df, add_feat = get_adjoin_feature(df, start, end, col, 'adcode_model_mt', space=3)
        stat_feat = stat_feat   add_feat
          
        # 连续
        start, end = 1 n, 3 n
        df, add_feat = get_series_feature(df, start, end, col, 'adcode_model_mt', ['sum','mean','min','max','std','ptp'])
        stat_feat = stat_feat   add_feat
        start, end = 1 n, 5 n
        df, add_feat = get_series_feature(df, start, end, col, 'adcode_model_mt', ['sum','mean','min','max','std','ptp'])
        stat_feat = stat_feat   add_feat
        start, end = 1 n, 7 n
        df, add_feat = get_series_feature(df, start, end, col, 'adcode_model_mt', ['sum','mean','min','max','std','ptp'])
        stat_feat = stat_feat   add_feat
    
    for col in tqdm(['popularity']):
        
        # 平移
        start, end = 4, 9
        df, add_feat = get_shift_feature(df, start, end, col, 'adcode_model_mt')
        stat_feat = stat_feat   add_feat
        
        # 相邻
        start, end = 4, 8
        df, add_feat = get_adjoin_feature(df, start, end, col, 'adcode_model_mt', space=1)
        stat_feat = stat_feat   add_feat
        start, end = 4, 7
        df, add_feat = get_adjoin_feature(df, start, end, col, 'adcode_model_mt', space=2)
        stat_feat = stat_feat   add_feat
        start, end = 4, 6
        df, add_feat = get_adjoin_feature(df, start, end, col, 'adcode_model_mt', space=3)
        stat_feat = stat_feat   add_feat
          
        # 连续
        start, end = 4, 7
        df, add_feat = get_series_feature(df, start, end, col, 'adcode_model_mt', ['sum','mean','min','max','std','ptp'])
        stat_feat = stat_feat   add_feat
        start, end = 4, 9
        df, add_feat = get_series_feature(df, start, end, col, 'adcode_model_mt', ['sum','mean','min','max','std','ptp'])
        stat_feat = stat_feat   add_feat
    
    ##################    
    # 车型/月份 粒度 #
    ##################
    df['model_mt'] = df['model'] * 100   df['mt']
    for col in tqdm(['label']):
        colname = 'model_mt_{}'.format(col)
        tmp = df.groupby(['model_mt'])[col].agg({'mean'}).reset_index()
        tmp.columns = ['model_mt',colname]
        df = df.merge(tmp, on=['model_mt'], how='left')
        # 平移
        start, end = 1 n, 9
        df, add_feat = get_shift_feature(df, start, end, colname, 'adcode_model_mt')
        stat_feat = stat_feat   add_feat
        
        # 相邻
        start, end = 1 n, 8
        df, add_feat = get_adjoin_feature(df, start, end, colname, 'model_mt', space=1)
        stat_feat = stat_feat   add_feat
        start, end = 1 n, 7
        df, add_feat = get_adjoin_feature(df, start, end, colname, 'model_mt', space=2)
        stat_feat = stat_feat   add_feat
        start, end = 1 n, 6
        df, add_feat = get_adjoin_feature(df, start, end, colname, 'model_mt', space=3)
        stat_feat = stat_feat   add_feat
        
        # 连续
        start, end = 1 n, 3 n
        df, add_feat = get_series_feature(df, start, end, colname, 'model_mt', ['sum','mean'])
        stat_feat = stat_feat   add_feat
        start, end = 1 n, 5 n
        df, add_feat = get_series_feature(df, start, end, colname, 'model_mt', ['sum','mean'])
        stat_feat = stat_feat   add_feat
        start, end = 1 n, 7 n
        df, add_feat = get_series_feature(df, start, end, colname, 'model_mt', ['sum','mean'])
        stat_feat = stat_feat   add_feat
    
    return df,stat_feat

这里面涉及到三个函数:

代码语言:javascript复制
def get_shift_feature(df_, start, end, col, group):
    '''
    历史平移特征
    col  : label,popularity
    group: adcode_model_mt, model_mt
    '''
    df = df_.copy()
    add_feat = []
    for i in range(start, end 1):
        add_feat.append('shift_{}_{}_{}'.format(col,group,i))
        df['{}_{}'.format(col,i)] = df[group]   i
        df_last = df[~df[col].isnull()].set_index('{}_{}'.format(col,i))
        df['shift_{}_{}_{}'.format(col,group,i)] = df[group].map(df_last[col])
        del df['{}_{}'.format(col,i)]
    
    return df, add_feat

def get_adjoin_feature(df_, start, end, col, group, space):
    '''
    相邻N月的首尾统计
    space: 间隔
    Notes: shift统一为adcode_model_mt
    '''
    df = df_.copy()
    add_feat = []
    for i in range(start, end 1):   
        add_feat.append('adjoin_{}_{}_{}_{}_{}_sum'.format(col,group,i,i space,space)) # 求和
        add_feat.append('adjoin_{}_{}_{}_{}_{}_mean'.format(col,group,i,i space,space)) # 均值
        add_feat.append('adjoin_{}_{}_{}_{}_{}_diff'.format(col,group,i,i space,space)) # 首尾差值
        add_feat.append('adjoin_{}_{}_{}_{}_{}_ratio'.format(col,group,i,i space,space)) # 首尾比例
        df['adjoin_{}_{}_{}_{}_{}_sum'.format(col,group,i,i space,space)] = 0
        for j in range(0, space 1):
            df['adjoin_{}_{}_{}_{}_{}_sum'.format(col,group,i,i space,space)]   = df['adjoin_{}_{}_{}_{}_{}_sum'.format(col,group,i,i space,space)]  
                                                                                  df['shift_{}_{}_{}'.format(col,'adcode_model_mt',i j)]
        df['adjoin_{}_{}_{}_{}_{}_mean'.format(col,group,i,i space,space)]  = df['adjoin_{}_{}_{}_{}_{}_sum'.format(col,group,i,i space,space)].values/(space 1)
        df['adjoin_{}_{}_{}_{}_{}_diff'.format(col,group,i,i space,space)]  = df['shift_{}_{}_{}'.format(col,'adcode_model_mt',i)].values -
                                                                              df['shift_{}_{}_{}'.format(col,'adcode_model_mt',i space)]
        df['adjoin_{}_{}_{}_{}_{}_ratio'.format(col,group,i,i space,space)] = df['shift_{}_{}_{}'.format(col,'adcode_model_mt',i)].values /
                                                                              df['shift_{}_{}_{}'.format(col,'adcode_model_mt',i space)]
    return df, add_feat

def get_series_feature(df_, start, end, col, group, types):
    '''
    连续N月的统计值
    Notes: shift统一为adcode_model_mt
    '''
    df = df_.copy()
    add_feat = []
    li = []
    df['series_{}_{}_{}_{}_sum'.format(col,group,start,end)] = 0
    for i in range(start,end 1):
        li.append('shift_{}_{}_{}'.format(col,'adcode_model_mt',i))
    df['series_{}_{}_{}_{}_sum'.format( col,group,start,end)] = df[li].apply(get_sum, axis=1)
    df['series_{}_{}_{}_{}_mean'.format(col,group,start,end)] = df[li].apply(get_mean, axis=1)
    df['series_{}_{}_{}_{}_min'.format( col,group,start,end)] = df[li].apply(get_min, axis=1)
    df['series_{}_{}_{}_{}_max'.format( col,group,start,end)] = df[li].apply(get_max, axis=1)
    df['series_{}_{}_{}_{}_std'.format( col,group,start,end)] = df[li].apply(get_std, axis=1)
    df['series_{}_{}_{}_{}_ptp'.format( col,group,start,end)] = df[li].apply(get_ptp, axis=1)
    for typ in types:
        add_feat.append('series_{}_{}_{}_{}_{}'.format(col,group,start,end,typ))
    
    return df, add_feat

看到这里,我的特征基本上就做完了,没有复杂的构造,都比较基础。

上面代码还有一部分没有交代清楚

代码语言:javascript复制
    # 确定起始位置
    if (month == 26) & (flag):
        n = 1
    elif (month == 27) & (flag):
        n = 2
    elif (month == 28) & (flag):
        n = 3
    else:
        n = 0

这部分主要用来确定提取特征的起始位置,主要在于建模方式的选择。

4.建模策略

测试集需要预测四个月的汽车销量,可选建模策略还是蛮多的,个人主要方案是一步一步预测,首先得到1月份的结果,然后将1月份合并到训练集,预测2月份结果,然后是3月,4月。

这样可能会累计误差,所有也可以跳跃式提取。

代码语言:javascript复制
for month in [25,26,27,28]:
    
    m_type = 'xgb'
    flag = False # False:连续提取  True:跳跃提取
    st = 4 # 保留训练集起始位置
    
    # 提取特征
    data_df, stat_feat = get_stat_feature(data, month, flag)
    
    # 特征分类
    num_feat = ['regYear']   stat_feat
    cate_feat = ['adcode','bodyType','model','regMonth']
    
    # 类别特征处理
    if m_type == 'lgb':
        for i in cate_feat:
            data_df[i] = data_df[i].astype('category')
    elif m_type == 'xgb':
        lbl = LabelEncoder()  
        for i in tqdm(cate_feat):
            data_df[i] = lbl.fit_transform(data_df[i].astype(str))
            
    # 最终特征集        
    features = num_feat   cate_feat
    print(len(features), len(set(features)))
    
    # 模型训练
    sub, model = get_train_model(data_df, month, m_type, st)    
    
    data.loc[(data.regMonth==(month-24))&(data.regYear==2018), 'salesVolume'] = sub['forecastVolum'].values
    data.loc[(data.regMonth==(month-24))&(data.regYear==2018), 'label'      ] = sub['forecastVolum'].values

这里m_type确定选择的模型,flag确定是否采样跨越式提取特征,st保留训练集起始位置。基本上最终方案只是在这几个参数上进行修改,然后融合得到。

5.融合方式

融合方式也可以有很多,stacking和blending,这里只选择了blending,并尝试了两者加权方式,算术平均和几何平均。

算术平均:

几何平均:

由于评分规则,算术平均会使融合的结果偏大,如:

显然不符合本赛题评价指标的直觉,越小的值对评分影响越大,算术平均会导致更大的误差。所以选择几何平均,能够使结果偏向小值,如下:

这个操作也是最终分数有近一个千的提升。在之前的比赛也使用过这种方法,非常值得借鉴。在最近的“全国高校新能源创新大赛”中的也依然适用。

0 人点赞