2019-CCF乘用車細(xì)分市場(chǎng)銷量預(yù)測(cè)-Rank19

1. Abstract

在市場(chǎng)整體趨勢(shì)逐步改變的環(huán)境下,比賽方希望能在銷量數(shù)據(jù)自身趨勢(shì)規(guī)律的基礎(chǔ)上,找到消費(fèi)者在互聯(lián)網(wǎng)上的行為數(shù)據(jù)與銷量之間的相關(guān)性,更準(zhǔn)確有效地預(yù)測(cè)銷量趨勢(shì)。這個(gè)題目也就是時(shí)序預(yù)測(cè)銷量,國慶期間無意看到就solo參賽,最后復(fù)賽B榜排名19,一共2999比賽隊(duì)伍。這里必須要感謝兩位大神的幫助@魚遇雨欲語與余 @叫我月月鳥開源了重要的特征,使得小弟能夠玩得盡興。 該比賽當(dāng)中鄙人主要使用了lightgbm模型融合xgboost模型,并未使用特殊的后處理方式或者其他的規(guī)則進(jìn)行處理。看到各位的不同騷操作,鄙人絞盡腦汁也想不到,心里暗暗佩服。當(dāng)中自認(rèn)為比賽當(dāng)中最有效的是挖掘時(shí)序特征,而如何挖掘更有效的時(shí)序特征變得十分重要。通過這次比賽,我認(rèn)為時(shí)序特征主要有以下幾種值得大家考慮:1. 平移時(shí)序,2. 滑窗時(shí)序, 3. 累計(jì)時(shí)序,4. 趨勢(shì)時(shí)序,5. 占比時(shí)序。這幾個(gè)種特征可謂是眾多時(shí)序題目提分的關(guān)鍵所在。而模型方面鄙人真的經(jīng)驗(yàn)尚淺,只能通過調(diào)參來提一下分,深度學(xué)習(xí)在這里貌似派不上什么用場(chǎng)。

2. Feature Introduction and analysis

說到時(shí)序特征那么我們必然先看我們的salesVolume的走勢(shì)是怎么樣吧。下圖為同一車型在不同地區(qū)的平均銷量趨勢(shì),由于文章篇幅問題,這里僅截取了部分出來

salesVolume16,17年的走向變化

從圖中我們基本可以看出很多重要的信息

  1. 二月份基本上是全年的銷量最差的一個(gè)月(這個(gè)也是測(cè)試集當(dāng)中其中一個(gè)要預(yù)測(cè)的月份)
  2. 16年的銷量趨勢(shì)與17年的銷量趨勢(shì)走向基本一致(周期性與趨勢(shì)性)

2.1 滑窗時(shí)序特征 vs 平移時(shí)序特征

于是我打算從滑窗時(shí)序特征和平移時(shí)序特征下手(平移時(shí)序特征主要靠漁佬的開源代碼)。而滑窗特征如下:

def get_rolling_feat(df_, range_list, target_col="label"):
    df = df_.copy()
    df['model_adcode'] = df['adcode'] + df['model']
    rolling_feat = []
    for i in range_list:
        df["rolling_mean_{}_{}".format(i, target_col)] = df.groupby("model_adcode").apply(lambda x: x[target_col].rolling(i).mean().shift(1)).reset_index()[target_col]
        rolling_feat.append("rolling_mean_{}_{}".format(i, target_col))
        df["rolling_median_{}_{}".format(i, target_col)] = df.groupby("model_adcode").apply(lambda x: x[target_col].rolling(i).median().shift(1)).reset_index()[target_col]
        rolling_feat.append("rolling_median_{}_{}".format(i, target_col))
        df["rolling_std_{}_{}".format(i, target_col)] = df.groupby("model_adcode").apply(lambda x: x[target_col].rolling(i).std().shift(1)).reset_index()[target_col]
        rolling_feat.append("rolling_std_{}_{}".format(i, target_col))
        df["rolling_min_{}_{}".format(i, target_col)] = df.groupby("model_adcode").apply(lambda x: x[target_col].rolling(i).min().shift(1)).reset_index()[target_col]
        rolling_feat.append("rolling_min_{}_{}".format(i, target_col))
        df["rolling_max_{}_{}".format(i, target_col)] = df.groupby("model_adcode").apply(lambda x: x[target_col].rolling(i).max().shift(1)).reset_index()[target_col]
        rolling_feat.append("rolling_max_{}_{}".format(i, target_col))
    return df, rolling_feat

這兩種特征進(jìn)行線上線下對(duì)比發(fā)現(xiàn)滑窗時(shí)序特征效果弱于平移特征。于是我對(duì)比了兩者預(yù)測(cè)后的結(jié)果,發(fā)現(xiàn)加入滑窗特征的結(jié)果均值偏大。因?yàn)榛瑒?dòng)窗口更多是取前幾個(gè)月的均值,中值,這些值相對(duì)于平移特征更加平滑,反而獲取不到每一次銷量的變化趨勢(shì)。如測(cè)試集若要拿到去年同期銷量滑窗特征,那么其窗口大小為12,那么使用平均銷量則會(huì)將同期值平滑掉,而且兩種特征的相似度比較高,特征冗余性比較強(qiáng)。到最后最后我并沒有加入滑窗特征,也有可能是我不太會(huì)用的原因。

2.2 趨勢(shì)增長(zhǎng)特征

另外一種特征就是月月鳥提供的趨勢(shì)增長(zhǎng)特征,這個(gè)特征更加顯式地將增長(zhǎng)特征加入到模型當(dāng)中。具體代碼可以參考月月鳥大大的blog。該特征為上個(gè)月的環(huán)比日平均銷量增長(zhǎng)率或者較前兩個(gè)月的日平均銷量增長(zhǎng)率。但這個(gè)特征有一個(gè)問題,假如上個(gè)月(30天)的銷量為5,而這個(gè)月(31天)銷量為100。那么環(huán)比日均銷量增長(zhǎng)率為18.3548。其實(shí)這種值假如不小心真的會(huì)把它當(dāng)成異常值處理掉,畢竟銷量變化十分劇烈。面對(duì)這種劇烈的變化,模型或多或少會(huì)有影響,那么大家又是如何處理數(shù)據(jù)偏差大的情況呢?這里想給大家留一個(gè)問號(hào)。

unstack_data = {}
def getHistoryIncrease(df_, increase_feat, step=1, wind=1, col='salesVolume'):
    res = []
    feature_name = '{}_last{}_{}_increase'.format(col, step, wind)
    print("generate :", feature_name)
    if col not in unstack_data.keys():
        for i in df_['model_adcode'].unique():
            msk = (df_['model_adcode'] == i)
            df = df_[msk].copy().reset_index(drop=True)
            df = df[['mt', col]].set_index('mt').T
            df['model_adcode'] = i
            res.append(df)
        res = pd.concat(res).reset_index(drop=True)
        unstack_data[col] = res.copy()

    res = unstack_data[col].copy()
    res_ = res.copy()
    for i in range(step + wind + 1, 29):
        res_[i] = (res[i - step] - res[i - (step + wind)]) / res[i - (step + wind)]

    for i in range(1, step + wind + 1):
        res_[i] = np.NaN
    res = res_.set_index(["model_adcode"]).stack().reset_index()
    increase_feat.append(feature_name)
    res.rename(columns={0: feature_name}, inplace=True)
    df_ = pd.merge(df_, res, on=['model_adcode', 'mt'], how='left')

    return df_

def getHistoryIncrease_(df_, increase_feat, step=1, wind=1, col='salesVolume'):
    feature_name = '{}_last{}_{}_increase'.format(col, step, wind)
    increase_feat.append(feature_name)
    print("generate :", feature_name)
    tmp_df = df_.copy()
    tmp_df["shift_model_adcode_{}_{}".format(col, step)] = tmp_df.sort_values("mt").groupby("model_adcode")[col].shift(step)
    tmp_df["shift_model_adcode_{}_{}".format(col, step + wind)] = tmp_df.sort_values("mt").groupby("model_adcode")[col].shift(step + wind)
    tmp_df[feature_name] = (tmp_df["shift_model_adcode_{}_{}".format(col, step)] - tmp_df["shift_model_adcode_{}_{}".format(col, step + wind)]) / tmp_df["shift_model_adcode_{}_{}".format(col, step + wind)]
    df_ = pd.merge(df_, tmp_df[["model_adcode", "mt", feature_name]], on=['model_adcode', 'mt'], how='left')
    return df_

def get_history_increase_feature(df_, month):
    increase_feat = []
    month -= 24
    base_step = month - 1 if month - 1 > 0 else 1

    df_ = getHistoryIncrease(df_, increase_feat, step=base_step, col="per_salesVolume_day")
    df_ = getHistoryIncrease(df_, increase_feat, step=base_step + 1, col="per_salesVolume_day")
    df_ = getHistoryIncrease(df_, increase_feat, step=base_step + 2, col="per_salesVolume_day")
    df_ = getHistoryIncrease(df_, increase_feat, step=base_step, wind=2, col="per_salesVolume_day")
    df_ = getHistoryIncrease(df_, increase_feat, step=base_step + 1, wind=2, col="per_salesVolume_day")
    df_ = getHistoryIncrease(df_, increase_feat, step=base_step + 2, wind=2, col="per_salesVolume_day")
    df_ = getHistoryIncrease(df_, increase_feat, step=base_step, wind=12, col="per_salesVolume_day")

    df_ = getHistoryIncrease(df_, increase_feat, step=month, col='per_popularity_day')
    df_ = getHistoryIncrease(df_, increase_feat, step=month + 1, col='per_popularity_day')
    df_ = getHistoryIncrease(df_, increase_feat, step=month + 2, col='per_popularity_day')
    df_ = getHistoryIncrease(df_, increase_feat, step=month, wind=2, col='per_popularity_day')
    df_ = getHistoryIncrease(df_, increase_feat, step=month + 1, wind=2, col='per_popularity_day')
    df_ = getHistoryIncrease(df_, increase_feat, step=month + 2, wind=2, col='per_popularity_day')

    return df_, increase_feat

2.3 組合交叉特征

假如僅僅使用魚佬和月月鳥大大提供的特征還是不夠的,因?yàn)轸~佬給的特征僅僅focus在model_adcode上的時(shí)序特征。那么我拍腦袋想到交叉屬性的salesVolume,如bodyType_adcode也是一樣可以做到同樣的效果吧。

df["bodyType_adcode"] = df["adcode"] + df["bodyType"]
groupby_df = df.groupby(["bodyType_adcode", "mt"]).agg({"salesVolume": "mean"})
groupby_df = groupby_df.reset_index().rename(columns={"salesVolume": "mean_salesVolume"})
# TODO: bodyType_adcode是否設(shè)置start_shift_i
for i in range(1, 13):
    column_name = "shift_bodyType_adcode_mean_salesVolume_{}".format(i)
    groupby_df[column_name] = groupby_df.groupby("bodyType_adcode").mean_salesVolume.shift(i)
    stat_feat.append(column_name)
df = pd.merge(df, groupby_df, on=["bodyType_adcode", "mt"], how="left")

除此之外,我為了提高時(shí)序特征的多樣性對(duì)不同的屬性進(jìn)行交叉計(jì)算出該屬性下不同取值的平均月銷量占比

# 計(jì)算不同地區(qū)不同車型的銷售占比和搜索量占比
data["month_sum_salesVolume"] = data.groupby("mt").salesVolume.transform("sum")
data["month_prop_salesVolume"] = data.salesVolume / data.month_sum_salesVolume
data["month_sum_popularity"] = data.groupby("mt").popularity.transform("sum")
data["month_prop_popularity"] = data.popularity / data.month_sum_popularity
# 計(jì)算同一地區(qū)不同車型的銷售占比和搜索量占比
data["month_adcode_sum_salesVolume"] = data.groupby(["mt", "adcode"]).salesVolume.transform("sum")
data["month_adcode_prop_salesVolume"] = data.salesVolume / data.month_adcode_sum_salesVolume
data["month_adcode_sum_popularity"] = data.groupby(["mt", "adcode"]).popularity.transform("sum")
data["month_adcode_prop_popularity"] = data.popularity / data.month_adcode_sum_popularity
# 計(jì)算同一地區(qū)同一車身不同車型的銷售占比和搜索量占比
data["month_adcode_bodyType_sum_salesVolume"] = data.groupby(["mt", "adcode", "bodyType"]).salesVolume.transform("sum")
data["month_adcode_bodyType_prop_salesVolume"] = data.salesVolume / data.month_adcode_bodyType_sum_salesVolume
data["month_adcode_bodyType_sum_popularity"] = data.groupby(["mt", "adcode", "bodyType"]).popularity.transform("sum")
data["month_adcode_bodyType_prop_popularity"] = data.popularity / data.month_adcode_bodyType_sum_popularity
# 同一車型在不同地區(qū)銷售占比
data["month_model_sum_salesVolume"] = data.groupby(["mt", "model"]).salesVolume.transform("sum")
data["month_model_prop_salesVolume"] = data.salesVolume / data.month_model_sum_salesVolume
data["month_model_sum_popularity"] = data.groupby(["mt", "model"]).popularity.transform("sum")
data["month_model_prop_popularity"] = data.popularity / data.month_model_sum_popularity

這些占比特征同樣可以像model_adcode的月銷量一樣做平移特征和趨勢(shì)增量特征。它可以看作是對(duì)銷量特征的另一種表達(dá)方式,增加特征的多樣性。

我們利用上個(gè)月的銷量*上年去年的銷量增長(zhǎng)率+上個(gè)月的銷量作為預(yù)估值,這個(gè)可以作為特征訓(xùn)練。

def expect_values(df_, target_col="salesVolume", fill_12mt=False):
    df = df_.copy()
    df["shift_model_adcode_mt_{}_-1".format(target_col)] = df.groupby("model_adcode")[target_col].shift(-1)
    df["shift_model_adcode_mt_{}_increase_rate".format(target_col)] = (df["shift_model_adcode_mt_{}_-1".format(target_col)] - df[target_col]) / df[target_col]
    df["increase_rate_period"] = df.groupby("model_adcode")["shift_model_adcode_mt_{}_increase_rate".format(target_col)].shift(12)
    # TODO: 暫時(shí)使用24個(gè)月后的增長(zhǎng)率填充空值
    if fill_12mt:
        df.loc[(df.mt == 12), "increase_rate_period"] = df["model_adcode"].map(df[df.mt == 24].set_index("model_adcode")["increase_rate_period"])
    df["expected_{}".format(target_col)] = df[target_col] + df[target_col] * df.increase_rate_period
    df["expected_{}".format(target_col)] = df.groupby("model_adcode")["expected_{}".format(target_col)].shift(1)
    return df

上述的占比特征等都可以運(yùn)用求算預(yù)估值expect_values方法去做一個(gè)簡(jiǎn)單的特征工程。

2.4 用戶行為數(shù)據(jù)

"carCommentVolum", "newsReplyVolum"這兩個(gè)屬性其實(shí)是比賽方比較希望我們?nèi)ネ诰虻奶卣?,著眼一看貌似是特別好的特征,后來發(fā)現(xiàn)這些特征都并沒有特別趨勢(shì)性而且也跟汽車月銷量無太多線性相關(guān)性。

carCommentVolum 16年,17年變化
newsReplyVolum 16年,17年變化

carCommentVolum和newsReplyVolum的趨勢(shì)和波動(dòng)與model_adcode的salesVolume兩者不同,而且carCommentVolum和newsReplyVolum僅僅針對(duì)車型,目標(biāo)salesVolume是具體到地區(qū)的車型銷量,粒度相對(duì)比較粗。因此對(duì)于同一車型不同地區(qū)的銷量沒有太大的區(qū)分度。我也是在這里之后沒有太多嘗試,也想不到好辦法。這里留個(gè)坑,看前排大佬還有什么好法子去充分利用好這個(gè)特征。

累積特征:

def cumsum_SalesVolume(df_):
    df = df_.copy()
    df["model_adcode_salesVolumn_cumsum"] = df.groupby("model_adcode").salesVolume.transform("cumsum")
    df["bodyType_adcode_salesVolume_cumsum"] = df.groupby(["bodyType", "adcode"]).salesVolume.transform("cumsum")
    df["adcode_salesVolumn_cumsum"] = df.groupby("adcode").salesVolume.transform("cumsum")
    df["model_salesVolumn_cumsum"] = df.groupby("model").salesVolume.transform("cumsum")
    df["model_adcode_salesVolumn_cumsum_mean"] = df["model_adcode_salesVolumn_cumsum"] / df["mt"]
    df["model_adcode_salesVolumn_cumsum"] = df.groupby("model_adcode").model_adcode_salesVolumn_cumsum.shift(1)
    df["bodyType_adcode_salesVolume_cumsum"] = df.groupby(["bodyType", "adcode"]).bodyType_adcode_salesVolume_cumsum.shift(1)
    df["adcode_salesVolumn_cumsum"] = df.groupby("adcode").adcode_salesVolumn_cumsum.shift(1)
    df["model_salesVolumn_cumsum"] = df.groupby("model").model_salesVolumn_cumsum.shift(1)
    return df, ["model_adcode_salesVolumn_cumsum", "adcode_salesVolumn_cumsum", "model_salesVolumn_cumsum"]

2.5 預(yù)測(cè)結(jié)果的分布情況

對(duì)于回歸問題,我們一定要留意一下預(yù)測(cè)測(cè)試集的目標(biāo)分布區(qū)間范圍情況。除了留意valid data的評(píng)估值之外,還要留意預(yù)估結(jié)果它的均值和方差等等。其實(shí)很多線下線上不一致的原因都在預(yù)測(cè)結(jié)果的數(shù)值分布出現(xiàn)了嚴(yán)重的偏差。就想之前說的加入滑動(dòng)窗口特征導(dǎo)致整體均值都變大,這有可能是因?yàn)榛瑒?dòng)特征將就近的月份銷量變重要了,而1-4月的銷量偏小的特性沒有捕抓到。

3. Conclusion

總得來說特征主要圍繞一下幾個(gè)方面進(jìn)行構(gòu)造:

ccf乘用車細(xì)分市場(chǎng)銷量預(yù)測(cè)-特征工程

其中數(shù)據(jù)target離群值多或者說波動(dòng)性大也是問題所在,有些銷量忽高忽低,也沒有什么特別周期性。我們可以通過折線圖找出一些波動(dòng)性較強(qiáng)的值,或者通過散點(diǎn)圖可以找到一些離群點(diǎn)。

相對(duì)于16年,17年的汽車銷量變化趨勢(shì)存在忽高忽低的情況.png
不同車型的銷量散點(diǎn)圖

因此認(rèn)為這些都是數(shù)據(jù)出錯(cuò)的問題,當(dāng)然漁佬也有提及到。但是自己嘗試將銷量平滑化的辦法,但是效果不佳。之后期待漁佬以及其他大佬的之后發(fā)布的文章講述一下怎么處理這些異常值。

另外一個(gè)難題就是線下線上效果不一致問題,導(dǎo)致不能有效找出的強(qiáng)特征。這也是我這場(chǎng)比賽的問題所在。但有一點(diǎn)需要注意的就是local cv的飆高有可能是因?yàn)樘卣鞔嬖跁r(shí)間穿越或者過度擬合valid data,因?yàn)榫€上的預(yù)測(cè)數(shù)據(jù)是1-4月,而本地是9-12月。

多從不同交叉組合挖掘強(qiáng)特,特征才具有多樣性。綜上所述,小弟的特征工程相對(duì)比較簡(jiǎn)單,也沒有什么特別的規(guī)則和模型,期待后續(xù)大佬結(jié)束決賽后,一起圍觀開源代碼及其內(nèi)容。

上面的特征比較簡(jiǎn)單,也沒有什么特別的規(guī)則和模型,最后使用lgbm和xgb平均融合提交。經(jīng)過這次比賽我進(jìn)一步加深對(duì)時(shí)序題的理解,同時(shí)也深知自己的特征工程能力還是太弱了,平時(shí)需要多搞eda來提高數(shù)據(jù)嗅覺。除此之外,自己以后也要廣泛交友,尋找志同道合的朋友一起打比賽,畢竟自己一個(gè)人打壓力大,局限性也大。

以上為鄙人的拙見,最后留下幾個(gè)我的問題跟大家探討一下,歡迎大家拍磚:
1. 對(duì)于離群或者波動(dòng)性較大的數(shù)據(jù),除了處理丟棄、均值填充、時(shí)序平滑化之外還有什么好辦法呢?這場(chǎng)比賽大家又是怎么做呢?
2. 面對(duì)時(shí)序題目,大家一般的校驗(yàn)方法是怎么做呢?是截取最后若干時(shí)間段作為valid set還是n-fold去做呢?
3. 對(duì)于"carCommentVolum", "newsReplyVolum"這兩個(gè)屬性,大家能通過它來挖掘什么有意思的特征呢?


感謝各位大佬的閱讀,點(diǎn)贊和評(píng)論。晚安,早唞。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容