價(jià)格預(yù)測(cè)EDA實(shí)戰(zhàn):從數(shù)據(jù)診斷到特征工程準(zhǔn)備)
1. 從零讀懂Task2為什么EDA是二手車(chē)價(jià)格預(yù)測(cè)的第一道分水嶺很多人拿到一個(gè)回歸預(yù)測(cè)任務(wù)第一反應(yīng)是趕緊上模型、調(diào)參數(shù)、刷榜單。我見(jiàn)過(guò)太多這樣的操作數(shù)據(jù)讀進(jìn)來(lái)簡(jiǎn)單fillna一下直接X(jué)GBoost跑起來(lái)然后盯著線(xiàn)上分?jǐn)?shù)反復(fù)調(diào)參調(diào)了三天發(fā)現(xiàn)還不如別人一個(gè)簡(jiǎn)單的線(xiàn)性回歸。問(wèn)題出在哪出在壓根沒(méi)搞明白數(shù)據(jù)長(zhǎng)什么樣。DataWhale的Task2定位非常明確在Task1已經(jīng)完成數(shù)據(jù)加載和初步觀(guān)察的基礎(chǔ)上系統(tǒng)性地做探索性數(shù)據(jù)分析EDA。這個(gè)階段的核心目標(biāo)不是建模而是回答幾個(gè)關(guān)鍵問(wèn)題——目標(biāo)變量長(zhǎng)什么樣特征和目標(biāo)之間是什么關(guān)系特征之間有沒(méi)有冗余缺失值和異常值該怎么處理這些問(wèn)題的答案直接決定了后續(xù)特征工程的方向和模型選型的天花板。二手車(chē)交易價(jià)格預(yù)測(cè)這個(gè)場(chǎng)景尤其特殊。它的目標(biāo)變量是價(jià)格而價(jià)格數(shù)據(jù)天然具有幾個(gè)特點(diǎn)右偏分布、存在極端值、受多因素非線(xiàn)性影響。如果你不做EDA直接建模模型很可能被少數(shù)高價(jià)車(chē)帶偏或者被大量缺失值搞得性能驟降。我個(gè)人的經(jīng)驗(yàn)是在一個(gè)中等規(guī)模的結(jié)構(gòu)化數(shù)據(jù)競(jìng)賽中EDA做得好不好直接決定了你最終成績(jī)是前30%還是前10%。這篇文章面向的是已經(jīng)完成Task1、正準(zhǔn)備進(jìn)入Task2的參賽者也適合任何想系統(tǒng)學(xué)習(xí)結(jié)構(gòu)化數(shù)據(jù)EDA方法論的人。我會(huì)把Task2涉及的每個(gè)環(huán)節(jié)拆開(kāi)講透包括為什么要這么做、具體怎么操作、實(shí)測(cè)中會(huì)遇到什么問(wèn)題、以及我踩過(guò)的那些坑。代碼以Python為主工具鏈?zhǔn)莗andas matplotlib seaborn這些都是數(shù)據(jù)競(jìng)賽的標(biāo)配不需要額外折騰環(huán)境。2. 數(shù)據(jù)加載后的第一件事把數(shù)據(jù)摸清楚2.1 數(shù)據(jù)規(guī)模與字段類(lèi)型的快速體檢拿到數(shù)據(jù)后別急著畫(huà)圖。先做一次基礎(chǔ)體檢搞清楚三件事數(shù)據(jù)有多少行多少列、每個(gè)字段是什么類(lèi)型、內(nèi)存占用多大。這聽(tīng)起來(lái)很簡(jiǎn)單但我見(jiàn)過(guò)有人跑了半天代碼才發(fā)現(xiàn)數(shù)據(jù)讀進(jìn)來(lái)時(shí)類(lèi)型全錯(cuò)了數(shù)值型被當(dāng)成了字符串。import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns # 讀取數(shù)據(jù) train pd.read_csv(used_car_train.csv) test pd.read_csv(used_car_test.csv) # 基礎(chǔ)體檢 print(f訓(xùn)練集形狀: {train.shape}) print(f測(cè)試集形狀: {test.shape}) print(f內(nèi)存占用: {train.memory_usage(deepTrue).sum() / 1024**2:.2f} MB) print(\n字段類(lèi)型分布:) print(train.dtypes.value_counts())這里有個(gè)細(xì)節(jié)值得注意二手車(chē)數(shù)據(jù)里經(jīng)常有power、kilometer這類(lèi)數(shù)值字段但有時(shí)候因?yàn)槿笔е档拇嬖趐andas會(huì)把它們讀成object類(lèi)型。如果你不檢查后面做數(shù)值計(jì)算時(shí)就會(huì)報(bào)錯(cuò)。我的習(xí)慣是讀完數(shù)據(jù)后立刻用train.info()看一眼確認(rèn)沒(méi)有類(lèi)型異常。另外訓(xùn)練集和測(cè)試集的字段差異也要檢查。測(cè)試集通常會(huì)少一個(gè)目標(biāo)列price但其他字段應(yīng)該完全一致。如果發(fā)現(xiàn)字段不一致要么是數(shù)據(jù)版本有問(wèn)題要么是讀取時(shí)出了錯(cuò)。2.2 目標(biāo)變量price的分布診斷目標(biāo)變量的分布是EDA的重中之重。對(duì)于價(jià)格預(yù)測(cè)任務(wù)你需要搞清楚它是正態(tài)分布、右偏分布還是雙峰分布。這直接決定了后續(xù)要不要做對(duì)數(shù)變換。# 目標(biāo)變量分布 fig, axes plt.subplots(1, 3, figsize(18, 5)) # 原始分布 sns.histplot(train[price], bins50, axaxes[0], kdeTrue) axes[0].set_title(Price原始分布) # 對(duì)數(shù)變換后 sns.histplot(np.log1p(train[price]), bins50, axaxes[1], kdeTrue) axes[1].set_title(Log(Price1)分布) # 箱線(xiàn)圖看異常值 sns.boxplot(xtrain[price], axaxes[2]) axes[2].set_title(Price箱線(xiàn)圖) plt.tight_layout() plt.show() # 統(tǒng)計(jì)量 print(train[price].describe()) print(f偏度: {train[price].skew():.4f}) print(f峰度: {train[price].kurtosis():.4f})實(shí)測(cè)下來(lái)二手車(chē)價(jià)格數(shù)據(jù)幾乎必然是右偏的偏度通常在2以上。這意味著大部分車(chē)價(jià)格集中在較低區(qū)間少數(shù)豪車(chē)?yán)吡宋膊俊H绻阒苯佑迷紁rice做回歸模型會(huì)過(guò)度關(guān)注那些高價(jià)樣本導(dǎo)致整體預(yù)測(cè)偏差。我的處理建議是先看對(duì)數(shù)變換后的分布是否接近正態(tài)。如果接近后續(xù)建模時(shí)對(duì)目標(biāo)變量做log1p變換預(yù)測(cè)后再用expm1還原。這個(gè)操作在Kaggle的很多回歸競(jìng)賽中都是標(biāo)配效果立竿見(jiàn)影。但要注意如果評(píng)價(jià)指標(biāo)是RMSE對(duì)數(shù)變換后還原會(huì)引入偏差這時(shí)候需要對(duì)預(yù)測(cè)結(jié)果做無(wú)偏修正或者直接用MAE作為評(píng)價(jià)指標(biāo)。2.3 缺失值的全局掃描與分類(lèi)缺失值處理是EDA中最容易被輕視的環(huán)節(jié)。很多人看到缺失值就無(wú)腦填均值或中位數(shù)這是大忌。正確的做法是先搞清楚缺失的模式是隨機(jī)缺失還是系統(tǒng)性缺失缺失比例是多少缺失本身是否攜帶信息# 缺失值統(tǒng)計(jì) missing train.isnull().sum() missing_pct (missing / len(train)) * 100 missing_df pd.DataFrame({ 缺失數(shù)量: missing, 缺失比例(%): missing_pct }).sort_values(缺失比例(%), ascendingFalse) print(missing_df[missing_df[缺失數(shù)量] 0])在二手車(chē)數(shù)據(jù)中bodyType、fuelType、gearbox這幾個(gè)類(lèi)別字段經(jīng)常有缺失而model、brand等字段缺失較少。這里有個(gè)關(guān)鍵判斷如果某個(gè)字段缺失比例超過(guò)40%填充的風(fēng)險(xiǎn)很大可以考慮直接構(gòu)造一個(gè)缺失類(lèi)別讓模型自己學(xué)習(xí)缺失是否意味著某種特殊車(chē)況。我踩過(guò)的一個(gè)坑是對(duì)notRepairedDamage字段做填充時(shí)這個(gè)字段的值是-和0.0直接astype(float)會(huì)報(bào)錯(cuò)。必須先replace(-, np.nan)再處理。這種臟數(shù)據(jù)在真實(shí)業(yè)務(wù)數(shù)據(jù)里非常常見(jiàn)EDA階段不發(fā)現(xiàn)建模時(shí)就會(huì)卡住。3. 單變量與多變量分析從每個(gè)字段里榨出信息3.1 數(shù)值型特征的分布與業(yè)務(wù)含義解讀數(shù)值型特征不能只看統(tǒng)計(jì)量要結(jié)合業(yè)務(wù)含義去理解。以kilometer為例它代表車(chē)輛行駛里程。如果你只看describe()可能覺(jué)得就是個(gè)普通數(shù)值。但結(jié)合業(yè)務(wù)想一想里程越高車(chē)況越差價(jià)格應(yīng)該越低。如果EDA發(fā)現(xiàn)里程和價(jià)格沒(méi)有明顯負(fù)相關(guān)那要么是數(shù)據(jù)有問(wèn)題要么是這個(gè)字段被噪聲污染了。# 數(shù)值特征分布 num_cols [power, kilometer, brand, model] fig, axes plt.subplots(2, 2, figsize(14, 10)) for idx, col in enumerate(num_cols): ax axes[idx // 2][idx % 2] sns.histplot(train[col].dropna(), bins50, axax, kdeTrue) ax.set_title(f{col}分布) plt.tight_layout() plt.show()power字段馬力通常也是右偏的大部分車(chē)在100-200馬力之間少數(shù)高性能車(chē)超過(guò)500。這個(gè)字段和價(jià)格的相關(guān)性往往很強(qiáng)但要注意非線(xiàn)性關(guān)系——馬力從100到200對(duì)價(jià)格的影響可能遠(yuǎn)大于從400到500。brand和model雖然是數(shù)值編碼但本質(zhì)上是類(lèi)別特征。直接當(dāng)數(shù)值用會(huì)引入錯(cuò)誤的序關(guān)系。比如brand1和brand2之間未必有大小關(guān)系。這類(lèi)字段在EDA階段就要標(biāo)記出來(lái)后續(xù)做類(lèi)別編碼。3.2 類(lèi)別型特征的頻次分析與目標(biāo)編碼預(yù)判類(lèi)別特征的分析重點(diǎn)是頻次分布和目標(biāo)均值分布。頻次太低的小類(lèi)別模型很難學(xué)到有效模式可以考慮合并。而目標(biāo)均值分布則能告訴你這個(gè)特征是否有區(qū)分度。# 類(lèi)別特征分析 cat_cols [brand, bodyType, fuelType, gearbox, notRepairedDamage] for col in cat_cols: print(f\n {col} ) print(f唯一值數(shù)量: {train[col].nunique()}) print(train[col].value_counts().head(10)) print(f目標(biāo)均值:\n{train.groupby(col)[price].mean().head(10)})這里有個(gè)經(jīng)驗(yàn)如果某個(gè)類(lèi)別特征的唯一值超過(guò)50個(gè)比如model字段可能有上千個(gè)取值直接用One-Hot會(huì)導(dǎo)致維度爆炸。這時(shí)候目標(biāo)編碼Target Encoding或者頻率編碼是更好的選擇。但目標(biāo)編碼容易過(guò)擬合必須配合交叉驗(yàn)證或者平滑處理。notRepairedDamage這個(gè)字段特別有意思。它表示車(chē)輛是否有未修復(fù)的損壞。從業(yè)務(wù)邏輯看有未修復(fù)損壞的車(chē)價(jià)格應(yīng)該更低。如果EDA發(fā)現(xiàn)這個(gè)字段的缺失本身就和低價(jià)相關(guān)那缺失值就不能隨便填而要當(dāng)作一個(gè)獨(dú)立類(lèi)別。3.3 特征與目標(biāo)的相關(guān)性矩陣與非線(xiàn)性關(guān)系識(shí)別相關(guān)性分析是EDA的常規(guī)操作但很多人只看皮爾遜相關(guān)系數(shù)這只能捕捉線(xiàn)性關(guān)系。對(duì)于二手車(chē)價(jià)格這種復(fù)雜場(chǎng)景非線(xiàn)性關(guān)系才是主流。# 相關(guān)性矩陣 corr_matrix train.corr() plt.figure(figsize(16, 12)) sns.heatmap(corr_matrix, cmapcoolwarm, center0, annotFalse) plt.title(特征相關(guān)性熱力圖) plt.show() # 與目標(biāo)的相關(guān)性排序 target_corr corr_matrix[price].sort_values(ascendingFalse) print(target_corr)實(shí)測(cè)中和price相關(guān)性最高的往往是power、kilometer、brand這幾個(gè)。但相關(guān)系數(shù)低不代表特征沒(méi)用可能是非線(xiàn)性關(guān)系。比如kilometer和price可能是分段關(guān)系低里程區(qū)間價(jià)格下降快高里程區(qū)間價(jià)格下降慢。我的做法是對(duì)每個(gè)重要數(shù)值特征畫(huà)散點(diǎn)圖或者用分箱后的箱線(xiàn)圖看趨勢(shì)。如果發(fā)現(xiàn)明顯的非線(xiàn)性模式后續(xù)可以考慮多項(xiàng)式特征或者分箱離散化。4. 異常值與重復(fù)值的處理策略別讓臟數(shù)據(jù)毀掉模型4.1 異常值檢測(cè)IQR、Z-Score與業(yè)務(wù)規(guī)則三管齊下異常值處理沒(méi)有萬(wàn)能公式必須結(jié)合統(tǒng)計(jì)方法和業(yè)務(wù)規(guī)則。統(tǒng)計(jì)方法能發(fā)現(xiàn)數(shù)值上的離群點(diǎn)業(yè)務(wù)規(guī)則能判斷這些離群點(diǎn)是否合理。# IQR方法檢測(cè)異常值 def detect_outliers_iqr(df, col): Q1 df[col].quantile(0.25) Q3 df[col].quantile(0.75) IQR Q3 - Q1 lower Q1 - 1.5 * IQR upper Q3 1.5 * IQR outliers df[(df[col] lower) | (df[col] upper)] return outliers, lower, upper # 對(duì)price檢測(cè) outliers, lower, upper detect_outliers_iqr(train, price) print(fPrice異常值數(shù)量: {len(outliers)}) print(f正常范圍: [{lower:.2f}, {upper:.2f}])但要注意價(jià)格數(shù)據(jù)里的異常值未必是錯(cuò)誤數(shù)據(jù)。一輛限量版豪車(chē)價(jià)格遠(yuǎn)超普通車(chē)這是合理的業(yè)務(wù)現(xiàn)象不是數(shù)據(jù)錯(cuò)誤。如果直接刪除模型就學(xué)不到高價(jià)車(chē)的模式。我的建議是對(duì)于價(jià)格預(yù)測(cè)任務(wù)不要輕易刪除高價(jià)樣本而是考慮用對(duì)數(shù)變換壓縮尺度或者用分位數(shù)回歸、Huber損失等對(duì)異常值魯棒的建模方法。power字段有時(shí)候會(huì)出現(xiàn)0或者異常大的值。0馬力顯然不合理可能是缺失值被填成了0。這種就要當(dāng)作缺失值重新處理。4.2 重復(fù)值排查完全重復(fù)與業(yè)務(wù)重復(fù)的區(qū)別重復(fù)值分兩種完全重復(fù)所有字段都一樣和業(yè)務(wù)重復(fù)關(guān)鍵字段一樣但其他字段有差異。完全重復(fù)直接drop_duplicates即可但業(yè)務(wù)重復(fù)要小心。# 完全重復(fù) print(f完全重復(fù)行數(shù): {train.duplicated().sum()}) # 業(yè)務(wù)重復(fù)同一輛車(chē)多次出現(xiàn) key_cols [brand, model, kilometer, power] biz_dup train.duplicated(subsetkey_cols, keepFalse) print(f業(yè)務(wù)重復(fù)行數(shù): {biz_dup.sum()})如果發(fā)現(xiàn)同一組關(guān)鍵字段對(duì)應(yīng)多個(gè)不同的price可能是數(shù)據(jù)采集時(shí)的重復(fù)記錄也可能是不同交易時(shí)間的價(jià)格差異。這時(shí)候不能簡(jiǎn)單去重要結(jié)合業(yè)務(wù)判斷。我一般會(huì)保留第一條或者取價(jià)格均值。4.3 異常值處理后的驗(yàn)證分布對(duì)比與模型反饋處理完異常值后一定要做前后對(duì)比驗(yàn)證。最直接的方法是畫(huà)處理前后的分布對(duì)比圖看分布形態(tài)是否更合理。# 處理前后對(duì)比 fig, axes plt.subplots(1, 2, figsize(14, 5)) sns.histplot(train[price], bins50, axaxes[0]) axes[0].set_title(處理前) sns.histplot(train_clean[price], bins50, axaxes[1]) axes[1].set_title(處理后) plt.show()更嚴(yán)謹(jǐn)?shù)淖龇ㄊ怯锰幚砗蟮臄?shù)據(jù)跑一個(gè)基線(xiàn)模型看交叉驗(yàn)證分?jǐn)?shù)是否提升。如果處理后分?jǐn)?shù)反而下降說(shuō)明你可能刪掉了有用信息。這個(gè)反饋循環(huán)是EDA和建模之間的橋梁很多人忽略了這一步導(dǎo)致EDA和建模脫節(jié)。5. 特征工程前的EDA準(zhǔn)備為后續(xù)Task鋪路5.1 時(shí)間特征的拆解與業(yè)務(wù)周期識(shí)別二手車(chē)數(shù)據(jù)里通常有regDate注冊(cè)日期和creatDate數(shù)據(jù)創(chuàng)建日期。這兩個(gè)字段能衍生出車(chē)齡、使用年限等強(qiáng)特征。# 時(shí)間特征處理 train[regDate] pd.to_datetime(train[regDate], format%Y%m%d, errorscoerce) train[creatDate] pd.to_datetime(train[creatDate], format%Y%m%d, errorscoerce) train[car_age] (train[creatDate] - train[regDate]).dt.days / 365 # 車(chē)齡分布 sns.histplot(train[car_age].dropna(), bins50) plt.title(車(chē)齡分布) plt.show()車(chē)齡和價(jià)格的關(guān)系通常是非線(xiàn)性的3年內(nèi)的車(chē)貶值最快之后趨于平緩。EDA階段要確認(rèn)這個(gè)模式是否存在如果存在后續(xù)可以對(duì)車(chē)齡做分箱或者樣條變換。另外creatDate本身可能攜帶時(shí)間信息。如果數(shù)據(jù)采集跨越了較長(zhǎng)時(shí)間不同時(shí)間段的市場(chǎng)行情不同這個(gè)字段可能和價(jià)格有隱含關(guān)聯(lián)。但要注意如果測(cè)試集的creatDate分布和訓(xùn)練集差異很大直接用這個(gè)字段可能導(dǎo)致過(guò)擬合。5.2 高基數(shù)類(lèi)別特征的編碼預(yù)研model字段可能有上千個(gè)取值brand也有幾十個(gè)。這類(lèi)高基數(shù)類(lèi)別特征怎么編碼是EDA階段就要想清楚的問(wèn)題。# 高基數(shù)特征統(tǒng)計(jì) for col in [brand, model]: freq train[col].value_counts() print(f{col}: 總類(lèi)別數(shù){len(freq)}, 出現(xiàn)次數(shù)10的類(lèi)別數(shù){(freq10).sum()})如果大量類(lèi)別出現(xiàn)次數(shù)極少可以考慮把低頻類(lèi)別合并成其他。或者用目標(biāo)編碼但必須做平滑encoding (count * mean prior * smoothing) / (count smoothing)。這個(gè)平滑參數(shù)需要在交叉驗(yàn)證中調(diào)。我的經(jīng)驗(yàn)是對(duì)于二手車(chē)這種數(shù)據(jù)brand用目標(biāo)編碼效果通常不錯(cuò)但model因?yàn)榛鶖?shù)太高目標(biāo)編碼容易過(guò)擬合可以考慮用頻率編碼或者嵌入Embedding的方式。5.3 特征交互的初步探索哪些組合值得深挖EDA的最后一步是探索特征交互。有些特征單獨(dú)看區(qū)分度一般但組合起來(lái)很強(qiáng)。比如brand和car_age的組合同樣是3年車(chē)齡豪華品牌和普通品牌的貶值曲線(xiàn)完全不同。# 特征交互探索 pivot train.pivot_table(valuesprice, indexbrand, columnscar_age_bin, aggfuncmean) sns.heatmap(pivot, cmapYlOrRd) plt.title(品牌與車(chē)齡的價(jià)格交互) plt.show()這種交互分析能幫你發(fā)現(xiàn)哪些組合特征值得構(gòu)造。但要注意EDA階段只是探索不要在這里花太多時(shí)間構(gòu)造特征那是Task3的事。EDA的目標(biāo)是找到方向不是完成所有工作。6. 我在Task2實(shí)操中踩過(guò)的坑與總結(jié)的技巧第一個(gè)坑是數(shù)據(jù)類(lèi)型陷阱。二手車(chē)數(shù)據(jù)里很多字段看起來(lái)是數(shù)值實(shí)際是字符串比如notRepairedDamage的-。如果不處理后面所有數(shù)值操作都會(huì)報(bào)錯(cuò)。我的建議是讀完數(shù)據(jù)后立刻做一次df.apply(lambda x: x.dtype)檢查對(duì)object類(lèi)型的字段逐個(gè)確認(rèn)。第二個(gè)坑是缺失值填充的連鎖反應(yīng)。我曾經(jīng)對(duì)bodyType用眾數(shù)填充結(jié)果發(fā)現(xiàn)填充后這個(gè)字段和目標(biāo)變量的相關(guān)性反而下降了。后來(lái)才明白缺失本身可能意味著某種特殊車(chē)型填成眾數(shù)反而抹掉了這個(gè)信息。正確的做法是先構(gòu)造一個(gè)bodyType_isnull的指示特征再用模型預(yù)測(cè)填充或者保留缺失。第三個(gè)坑是異常值刪除過(guò)度。有一次我按IQR刪掉了price的異常值結(jié)果交叉驗(yàn)證分?jǐn)?shù)掉了0.02。原因是那些高價(jià)樣本雖然少但包含了重要的模式信息。后來(lái)我改用對(duì)數(shù)變換分?jǐn)?shù)就回來(lái)了。第四個(gè)坑是忽略了訓(xùn)練集和測(cè)試集的分布一致性。EDA不能只看訓(xùn)練集測(cè)試集的分布也要對(duì)比。如果某個(gè)特征在測(cè)試集里的分布和訓(xùn)練集差異很大說(shuō)明可能存在數(shù)據(jù)漂移直接用這個(gè)特征風(fēng)險(xiǎn)很高。最后分享一個(gè)實(shí)用技巧EDA階段的所有發(fā)現(xiàn)都要記錄下來(lái)形成一個(gè)數(shù)據(jù)洞察清單。比如price右偏需要log變換、kilometer和price非線(xiàn)性、bodyType缺失可能攜帶信息。這個(gè)清單就是Task3特征工程的路線(xiàn)圖。沒(méi)有這個(gè)清單你做到Task3時(shí)會(huì)忘記EDA階段的發(fā)現(xiàn)又得重新看一遍數(shù)據(jù)。另外可視化不要追求花哨能說(shuō)明問(wèn)題就行。我見(jiàn)過(guò)有人花大量時(shí)間做交互式圖表結(jié)果對(duì)建模沒(méi)有任何幫助。EDA的可視化目標(biāo)是快速驗(yàn)證假設(shè)不是做數(shù)據(jù)報(bào)告。一張清晰的箱線(xiàn)圖比十張炫酷的3D圖更有價(jià)值。