格預(yù)測(cè)實(shí)戰(zhàn):從RMSLE評(píng)估到LightGBM特征工程與模型融合)
簡(jiǎn)介面向大學(xué)生競(jìng)賽與課程設(shè)計(jì)的二手車價(jià)格預(yù)測(cè)優(yōu)勝方案源碼資料包源自阿里天池與Datawhale聯(lián)合舉辦的賽事包含獲獎(jiǎng)方案的完整項(xiàng)目說明。壓縮包共十五個(gè)文件以Python腳本為主體輔以md說明文檔、txt依賴清單與少量備份文件整體僅約610KB體量輕巧當(dāng)前已有42人瀏覽學(xué)習(xí)。資料完整覆蓋數(shù)據(jù)預(yù)處理、特征工程、模型訓(xùn)練與集成融合等競(jìng)賽核心環(huán)節(jié)包含基于梯度提升樹、神經(jīng)網(wǎng)絡(luò)及多模型加權(quán)融合等不同思路的建模代碼并附預(yù)測(cè)結(jié)果數(shù)據(jù)表能夠幫助使用者系統(tǒng)理解獲獎(jiǎng)方案的構(gòu)建脈絡(luò)并快速復(fù)現(xiàn)。適合計(jì)算機(jī)科學(xué)、應(yīng)用數(shù)學(xué)、電子信息工程等相關(guān)專業(yè)的學(xué)生或科研人員作為課程設(shè)計(jì)、畢業(yè)設(shè)計(jì)或?qū)W術(shù)競(jìng)賽的參考范本其思路亦可遷移至房?jī)r(jià)、銷量等同類回歸預(yù)測(cè)問題。1. 二手車價(jià)格預(yù)測(cè)競(jìng)賽不是比模型是比誰更懂二手車的定價(jià)邏輯同一臺(tái)車有人估 9 萬有人報(bào) 11 萬差價(jià)背后不是玄學(xué)而是對(duì)車齡、里程和品牌殘值率的理解深度。某競(jìng)賽平臺(tái)與某開源學(xué)習(xí)社區(qū)聯(lián)合舉辦的二手車價(jià)格預(yù)測(cè)賽題要求根據(jù)車輛的多維信息預(yù)測(cè)成交價(jià)評(píng)估指標(biāo)直接對(duì)標(biāo)行業(yè)里的定價(jià)偏差。這類賽題的優(yōu)勝方案通常不是最深的神經(jīng)網(wǎng)絡(luò)而是把特征工程、交叉驗(yàn)證和多模型融合做到極致的一套樹模型方案。這篇筆記會(huì)從數(shù)據(jù)認(rèn)知、特征構(gòu)造、模型訓(xùn)練講到源碼組織完整拆解一套可復(fù)現(xiàn)的建模流程適合想入門結(jié)構(gòu)化數(shù)據(jù)競(jìng)賽的開發(fā)者也適合正在做二手車估值系統(tǒng)的工程師。我們先從賽題數(shù)據(jù)本身開始。2. 賽題拆解與數(shù)據(jù)認(rèn)知價(jià)格為什么是長尾分布以及我們要預(yù)測(cè)什么不少參賽者拿到數(shù)據(jù)后第一件事就是跑一個(gè) LightGBM但如果不先理解價(jià)格分布和評(píng)估指標(biāo)后續(xù)所有調(diào)參都是在黑匣子里瞎試。二手車價(jià)格預(yù)測(cè)本質(zhì)上是一個(gè)回歸問題但它的特殊之處在于目標(biāo)變量跨越一萬元到上百萬元誤差度量方式直接決定了你該優(yōu)化什么。2.1 任務(wù)定義與評(píng)估指標(biāo)你優(yōu)化的不是誤差而是對(duì)數(shù)誤差官方在賽題說明里通常會(huì)把評(píng)估指標(biāo)定義成 RMSLERoot Mean Squared Logarithmic Error也就是對(duì)真實(shí)價(jià)格和預(yù)測(cè)價(jià)格分別取對(duì)數(shù)后計(jì)算 RMSE。為什么要這樣設(shè)計(jì)因?yàn)橐惠v 3 萬的車少估 3 千和一輛 30 萬的車少估 3 千在絕對(duì)誤差上是相同的但在買賣雙方的心理感知上完全不同。取對(duì)數(shù)后3 萬車少估 3 千對(duì)應(yīng) log 誤差約 0.130 萬車少估 3 千對(duì)應(yīng) log 誤差約 0.01這樣就把“相對(duì)誤差”作為懲罰核心更貼近二手車定價(jià)業(yè)務(wù)。所以在訓(xùn)練之前我一般會(huì)先寫一個(gè) RMSLE 計(jì)算函數(shù)用作所有模型驗(yàn)證的統(tǒng)一標(biāo)尺。import numpy as np def rmsle(y_true, y_pred): # 將真實(shí)值和預(yù)測(cè)值都限制在非負(fù)范圍避免對(duì)負(fù)數(shù)取對(duì)數(shù) y_true np.maximum(y_true, 0) y_pred np.maximum(y_pred, 0) # 用 log1p 計(jì)算對(duì)數(shù)誤差的均方根等價(jià)于對(duì)價(jià)格取 log 后的 RMSE return np.sqrt(np.mean((np.log1p(y_true) - np.log1p(y_pred)) ** 2))這段代碼里有三個(gè)關(guān)鍵點(diǎn)np.maximum 是為了防止極端預(yù)測(cè)值出現(xiàn)負(fù)價(jià)格而做兜底np.log1p 是 log(x1)在價(jià)格接近 0 時(shí)比 log(x) 更穩(wěn)定整個(gè)函數(shù)返回的是一個(gè)標(biāo)量分?jǐn)?shù)分?jǐn)?shù)越低代表相對(duì)誤差越小。后續(xù)每一次交叉驗(yàn)證、特征篩選、融合權(quán)重調(diào)整我都用這個(gè)函數(shù)來打分不額外看別的指標(biāo)。明確了優(yōu)化目標(biāo)之后還需要知道訓(xùn)練數(shù)據(jù)長什么樣。這里要提醒一下RMSLE 和 RMSE 的數(shù)值有很大差異不要拿 RMSE 的標(biāo)準(zhǔn)去評(píng)判 RMSLE。2.2 探索性數(shù)據(jù)分析先看價(jià)格分布、缺失值和異常值再談建模EDA 看起來是體力活但往往決定了后續(xù)特征工程的走向。我用 pandas 加載訓(xùn)練集后第一件事是看 shape、字段類型和描述統(tǒng)計(jì)重點(diǎn)關(guān)注 price、mileage、reg_date 這三個(gè)字段的分布和缺失比例。常見的數(shù)據(jù)集里品牌、車系、上牌日期、行駛里程、排量、變速箱、燃料類型、過戶次數(shù)、是否事故車等字段都會(huì)出現(xiàn)其中上牌日期和過戶次數(shù)的缺失率往往不低這部分要先記錄下來。價(jià)格分布是典型的右偏分布少量豪華品牌的高價(jià)車占據(jù)長尾如果直接用原始價(jià)格做回歸模型會(huì)被那些幾十萬的車主導(dǎo)忽略了幾萬元區(qū)間的主流樣本。所以我會(huì)同時(shí)畫原始價(jià)格和 log 價(jià)格兩個(gè)直方圖確認(rèn)取對(duì)數(shù)后的分布是否接近正態(tài)。import pandas as pd import matplotlib.pyplot as plt train pd.read_csv(train.csv) print(train.shape) print(train.describe(percentiles[0.01, 0.25, 0.5, 0.75, 0.99])) plt.hist(train[price], bins100) plt.yscale(log) plt.show() train[log_price] np.log1p(train[price]) plt.hist(train[log_price], bins100) plt.show() missing train.isnull().sum() print(missing[missing 0])上面這段代碼里train.describe 的 percentiles 參數(shù)會(huì)額外給出 1% 和 99% 分位數(shù)方便快速發(fā)現(xiàn)極端價(jià)格plt.yscale(log) 讓長尾更加直觀最后打印的 missing 列表是后續(xù)填充策略的依據(jù)。如果看到 price 的最小值是 0 或者接近 0大概率是樣本異常或者數(shù)據(jù)采集錯(cuò)誤需要在特征工程階段處理。一個(gè)常見的數(shù)據(jù)集字段說明可以整理成下面這樣方便后續(xù)寫代碼時(shí)對(duì)照。字段名類型業(yè)務(wù)含義預(yù)處理關(guān)注點(diǎn)brand_idint品牌編碼是否需要做目標(biāo)編碼reg_datedatetime上牌日期計(jì)算車齡mileagefloat行駛里程萬公里缺失值、異常值powerint最大功率馬力缺失填充gearboxstr變速箱類型轉(zhuǎn)類別編碼fuel_typestr燃料類型轉(zhuǎn)類別編碼transfer_countfloat過戶次數(shù)缺失用 unknownpricefloat成交價(jià)萬元僅訓(xùn)練集有需取對(duì)數(shù)從這張表可以直觀看出真正需要手工處理的字段并沒有多少但這不代表建模簡(jiǎn)單。price 和 mileage 之間是明顯的非線性關(guān)系和 reg_date 之間存在車齡的單調(diào)衰減這些關(guān)系都靠特征工程去表達(dá)。2.3 從原始字段到有效特征車齡、里程、品牌殘值率的構(gòu)造邏輯原始字段很少能直接進(jìn)模型。日期字段必須轉(zhuǎn)成車齡里程數(shù)要考慮單位是否統(tǒng)一品牌這種高基數(shù)類別特征需要找到一種既能保留信息又不過擬合的編碼方式。車齡的構(gòu)造很簡(jiǎn)單用數(shù)據(jù)采集年份或者銷售年份減去上牌年份。為什么不用上牌日期本身因?yàn)闃淠P蜔o法理解“2017 年”這個(gè)數(shù)字的大小與價(jià)格衰減的關(guān)系但車齡“5 年”是一個(gè)連續(xù)變量模型可以學(xué)出“前 3 年貶值最快、5 年后貶值放緩”這種分段關(guān)系。里程也是一樣單純用“里程×車齡”不如“年均里程”更能反映車輛的使用強(qiáng)度。品牌殘值率是這類賽題最重要的特征。不同品牌的三年保值率可能從 45% 到 75% 不等這種差異比單一品牌 ID 更有解釋力。常見做法是統(tǒng)計(jì)每個(gè)品牌在訓(xùn)練集中的平均成交價(jià)作為該品牌的價(jià)格基準(zhǔn)。import pandas as pd import numpy as np train[reg_year] pd.to_datetime(train[reg_date]).dt.year train[reg_month] pd.to_datetime(train[reg_date]).dt.month train[car_age] train[sale_year] - train[reg_year] train[mileage] train[mileage].astype(float) train[mileage_per_year] train[mileage] / (train[car_age] 1) brand_mean_price train.groupby(brand_id)[price].transform(mean) train[brand_price_mean] brand_mean_pricecar_age 單位是年sale_year 一般來自數(shù)據(jù)集提供的銷售年份字段如果沒有可以考慮用數(shù)據(jù)發(fā)布時(shí)間。mileage_per_year 的計(jì)算里加了一個(gè) 1是為了避免新車車齡為 0 時(shí)出現(xiàn)除零錯(cuò)誤同時(shí)也平滑了分母。brand_price_mean 用 transform 一次性映射到每一行方便后續(xù)直接作為特征。這里要注意brand_price_mean 是用全量訓(xùn)練集統(tǒng)計(jì)出來的如果直接用于交叉驗(yàn)證會(huì)出現(xiàn)標(biāo)簽泄漏某條樣本的預(yù)測(cè)值里包含了它自己所在品牌的平均價(jià)格信息。正確做法是在每一折內(nèi)重新統(tǒng)計(jì)或者使用后面會(huì)講到的目標(biāo)編碼。我在這里先展示特征構(gòu)造的思路后面章節(jié)會(huì)給出防泄漏的實(shí)現(xiàn)。數(shù)據(jù)認(rèn)知到此為止接下來進(jìn)入特征工程和清洗環(huán)節(jié)。這個(gè)環(huán)節(jié)決定了你最終是站在排行榜中游還是上游。3. 特征工程與數(shù)據(jù)清洗決定名次的是特征不是調(diào)參特征工程是最能拉開排名差距的部分而它也是最容易被新手忽略的部分。很多參賽者拿著同一個(gè) LightGBM 參數(shù)跑完就去看排名卻不知道別人在特征上多花了兩倍時(shí)間。3.1 缺失值填充與異常價(jià)格處理避免把事故車當(dāng)正常車缺失值處理常常被一句 fillna(0) 帶過但二手車數(shù)據(jù)里的缺失往往帶有業(yè)務(wù)含義。比如過戶次數(shù)缺失可能是車商沒有錄入也可能是數(shù)據(jù)源本身沒有這項(xiàng)記錄如果直接用 0 填充模型會(huì)學(xué)到“缺失從未過戶”這種錯(cuò)誤信號(hào)。我習(xí)慣對(duì)類別型缺失單獨(dú)標(biāo)記一個(gè) unknown 類別讓樹模型自己去判斷缺失與目標(biāo)的關(guān)系。數(shù)值型字段的缺失要分情況排量、功率這類連續(xù)值往往和車型定位相關(guān)用中位數(shù)填充比均值更穩(wěn)因?yàn)橹形粩?shù)不受極端值影響。如果字段缺失率超過 40%我傾向于直接剔除而不是強(qiáng)行填充因?yàn)樘畛湟粋€(gè)半數(shù)以上都是缺失的字段等于是在制造噪聲。價(jià)格異常值是另一個(gè)坑。有些樣本成交價(jià)低到幾百元對(duì)應(yīng)的卻是正常描述的車況這大概率是數(shù)據(jù)采集錯(cuò)誤或者特殊交易情形。如果不處理模型會(huì)為了擬合這些離群點(diǎn)而扭曲整個(gè)分裂邏輯。同時(shí)還要留意價(jià)格和里程的聯(lián)合異常比如里程只有 1 萬公里但價(jià)格只有同款車的 20%可能是事故車如果數(shù)據(jù)集里帶有事故車標(biāo)記可以直接利用。train[transfer_count] train[transfer_count].fillna(unknown) train[displacement] train[displacement].fillna(train[displacement].median()) train train[(train[price] 0.5) (train[price] 100)] train train[~((train[mileage] 2) (train[price] train[price].quantile(0.1)))]第一行把過戶次數(shù)缺失變成獨(dú)立的 unknown 類別第二行用中位數(shù)填充排量避免均值被大排量車型拉高。后面的過濾條件我是按價(jià)格在 0.5 萬到 100 萬之間來篩具體閾值根據(jù)賽題的價(jià)格分位數(shù)調(diào)整最后一行過濾掉“低里程但極低價(jià)”的矛盾樣本這類樣本十有八九是異常數(shù)據(jù)。過濾比例如果超過訓(xùn)練集的 5%就要回頭檢查是否誤殺了真實(shí)的特價(jià)車。3.2 類別特征編碼與組合特征品牌、車系、變速箱如何進(jìn)入樹模型類別特征的編碼方式直接影響樹模型的表達(dá)能力。簡(jiǎn)單高基數(shù)類別比如車系如果直接用 LabelEncoder等于給車系賦予了一組任意整數(shù)編號(hào)樹模型會(huì)誤以為編號(hào)之間有大小關(guān)系。LightGBM 支持直讀類別特征但需要在 Dataset 中指定 feature_name 和 categorical_feature 參數(shù)否則也會(huì)自動(dòng)按數(shù)值處理。對(duì)于高基數(shù)類別我更推薦做目標(biāo)編碼用該類別下的價(jià)格均值作為特征。這樣既編碼了類別本身又編碼了該類別的價(jià)格水平。但目標(biāo)編碼必須防泄漏不能直接在全體訓(xùn)練集上計(jì)算均值否則驗(yàn)證分?jǐn)?shù)會(huì)虛高。from sklearn.model_selection import KFold def target_encoding(df, col, target, n_splits5, seed42): kf KFold(n_splitsn_splits, shuffleTrue, random_stateseed) df[te_ col] np.nan for tr_idx, va_idx in kf.split(df): tr df.iloc[tr_idx] va df.iloc[va_idx] mean_map tr.groupby(col)[target].mean() df.loc[df.index[va_idx], te_ col] va[col].map(mean_map) # 測(cè)試集中出現(xiàn)但訓(xùn)練集中沒出現(xiàn)過的類別用全局均值兜底 df[te_ col] df[te_ col].fillna(df[target].mean()) return df這個(gè)函數(shù)的邏輯是把數(shù)據(jù)分成 5 折對(duì)每一折都用其余 4 折的類別均值去填當(dāng)前折的對(duì)應(yīng)取值。這樣每一行的目標(biāo)編碼都只依賴不在這行本身的歷史信息從機(jī)制上避免了泄漏。n_splits 用 5 還是 10 取決于數(shù)據(jù)量數(shù)據(jù)量在幾萬條以上時(shí) 5 折足夠折數(shù)越多偏差越小但計(jì)算越慢。seed 固定后每次運(yùn)行結(jié)果可復(fù)現(xiàn)。除了編碼組合特征也有很大作用。我一般會(huì)構(gòu)造品牌和車齡的交互、品牌和里程的交互以及排量和變速箱的組合。品牌殘值率明顯不同而同一品牌下5 年 10 萬公里的車和 5 年 5 萬公里的車價(jià)格差可能超過 15%這種差異只有通過交互特征才能被樹模型捕捉到。train[brand_age] train[brand_id].astype(str) _ train[car_age].astype(str) train[brand_mileage_clip] train[brand_id].astype(str) _ pd.cut(train[mileage], 10).astype(str)brand_age 是一個(gè)字符串拼接得到的組合類別用來表達(dá)“某品牌在某車齡區(qū)間”的交叉效應(yīng)brand_mileage_clip 把里程分成 10 檔后再和品牌拼接相當(dāng)于把里程分箱和品牌做了交互。這種手工組合特征在樹模型里往往有奇效因?yàn)樗鼈儽葮淠P妥约赫业?split 更加貼近業(yè)務(wù)邏輯。3.3 價(jià)格對(duì)數(shù)變換與標(biāo)簽編碼讓回歸目標(biāo)更接近正態(tài)分布標(biāo)簽變換是最后一個(gè)關(guān)鍵動(dòng)作。前面看到價(jià)格是長尾分布直接把原始價(jià)格作為目標(biāo)模型在迭代中的梯度會(huì)被大價(jià)格樣本主宰導(dǎo)致小價(jià)格樣本擬合不足。取對(duì)數(shù)后目標(biāo)分布變得對(duì)稱梯度更均衡。在實(shí)現(xiàn)上我會(huì)把標(biāo)簽轉(zhuǎn)換和特征編碼放在同一個(gè)流水線里from sklearn.preprocessing import LabelEncoder le LabelEncoder() for col in [gearbox, fuel_type, color]: train[col] le.fit_transform(train[col].astype(str)) train[log_price] np.log1p(train[price]) feature_cols [car_age, mileage, power, displacement, gearbox, fuel_type, transfer_count, brand_price_mean, mileage_per_year, te_brand_id, te_model_id, brand_age] X train[feature_cols] y train[log_price]LabelEncoder 會(huì)把字符串類別轉(zhuǎn)成從 0 開始的整數(shù)對(duì)樹模型足夠用。注意要保證訓(xùn)練集和測(cè)試集使用同一個(gè) LabelEncoder 對(duì)象最好在訓(xùn)練完成后把訓(xùn)練數(shù)據(jù)中的類別到整數(shù)的映射保存下來推理時(shí)復(fù)用否則類別集合不同會(huì)導(dǎo)致編碼錯(cuò)位。log_price 是后續(xù)所有模型訓(xùn)練的標(biāo)簽?zāi)P皖A(yù)測(cè)出來的是 log 空間的數(shù)值最終提交前要執(zhí)行 np.expm1 還原成價(jià)格。特征工程做完后不要急著訓(xùn)模型先檢查一下特征之間是否有多重共線性或泄漏。比如 brand_price_mean 和 te_brand_id 都表達(dá)了品牌價(jià)格水平兩者高度相關(guān)樹模型雖然不怕共線性但冗余特征會(huì)讓重要性評(píng)估變得混亂。我的習(xí)慣是先跑一個(gè) LightGBM 看特征重要性把重要性很低且確實(shí)業(yè)務(wù)重復(fù)的特征刪掉這樣后續(xù)模型收斂更快。現(xiàn)在進(jìn)入模型訓(xùn)練和驗(yàn)證階段。這一章會(huì)告訴你為什么選 LightGBM、交叉驗(yàn)證怎么做才不失真、融合到底該加權(quán)還是 Stacking。4. 模型訓(xùn)練與交叉驗(yàn)證從單模型到融合優(yōu)勝方案是這樣煉成的特征做到位之后模型選擇變得相對(duì)簡(jiǎn)單。二手車價(jià)格預(yù)測(cè)這類表格任務(wù)樹模型是當(dāng)之無愧的主力而樹模型里我優(yōu)先選 LightGBM。4.1 基線模型選擇為什么優(yōu)先選 LightGBM 而不是線性回歸線性回歸的問題在于它假設(shè)特征和目標(biāo)之間是線性關(guān)系。二手車價(jià)格和里程的關(guān)系顯然不是線性的前 5 萬公里折舊快過了這個(gè)區(qū)間折舊放緩。要讓線性模型 work你需要手工構(gòu)造分箱、交互、多項(xiàng)式特征這個(gè)成本非常高。LightGBM 通過梯度提升和直方圖分裂自動(dòng)學(xué)習(xí)分段常數(shù)函數(shù)正好匹配這種非線性關(guān)系。另一個(gè)理由是工程效率。LightGBM 的內(nèi)存占用低訓(xùn)練速度快自帶處理缺失值的邏輯還支持類別特征直讀。在同樣的特征集上LightGBM 的基線 RMSLE 通常比線性回歸低 20% 到 40%這個(gè)差距不是靠調(diào)參能追回來的。我一般會(huì)先把下面這組參數(shù)作為起點(diǎn)而不是一上來就去搜索超參數(shù)。params { objective: regression, metric: rmse, learning_rate: 0.05, num_leaves: 31, max_depth: -1, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, lambda_l2: 1.0, verbose: -1, seed: 42, }learning_rate 設(shè) 0.05 是比較穩(wěn)的起點(diǎn)如果想追求精度可以降到 0.02但需要增大迭代次數(shù)。num_leaves 是主要復(fù)雜度控制參數(shù)31 對(duì)應(yīng) 2^5-1適合中低維特征如果特征很多可以適當(dāng)加大到 63 或者 127但要注意過擬合。feature_fraction 和 bagging_fraction 都設(shè) 0.8相當(dāng)于每棵樹只用 80% 的特征和 80% 的樣本是兩種并行正則化。lambda_l2 加一點(diǎn) L2 正則讓權(quán)重不要走極端。這些參數(shù)不是固定的但作為基線已經(jīng)能跑出不錯(cuò)的分?jǐn)?shù)。4.2 K 折交叉驗(yàn)證與早停讓驗(yàn)證分?jǐn)?shù)真實(shí)反映線上表現(xiàn)交叉驗(yàn)證是評(píng)估模型的唯一可信標(biāo)尺。單次留出驗(yàn)證的波動(dòng)太大隨機(jī)選一批數(shù)據(jù)當(dāng)驗(yàn)證集運(yùn)氣好就高運(yùn)氣差就低。K 折交叉驗(yàn)證把訓(xùn)練集切成 K 份每份輪流當(dāng)驗(yàn)證最后取 K 次驗(yàn)證分?jǐn)?shù)的平均這樣能減少評(píng)估方差。在二手車價(jià)格賽題里數(shù)據(jù)通常不是嚴(yán)格的時(shí)間序列隨機(jī) K 折是常見做法。但如果數(shù)據(jù)里有明顯的采集日期字段而且價(jià)格隨時(shí)間有趨勢(shì)性變化就要改成按時(shí)間切分用較早的數(shù)據(jù)訓(xùn)練較晚的數(shù)據(jù)驗(yàn)證否則模型會(huì)偷看未來信息。import lightgbm as lgb from sklearn.model_selection import KFold from sklearn.metrics import mean_squared_error kf KFold(n_splits5, shuffleTrue, random_state42) oof np.zeros(len(X)) for fold, (tr_idx, va_idx) in enumerate(kf.split(X)): tr_X, tr_y X.iloc[tr_idx], y.iloc[tr_idx] va_X, va_y X.iloc[va_idx], y.iloc[va_idx] dtr lgb.Dataset(tr_X, labeltr_y) dva lgb.Dataset(va_X, labelva_y) model lgb.train( params, dtr, num_boost_round5000, valid_sets[dva], callbacks[lgb.early_stopping(100), lgb.log_evaluation(0)] ) oof[va_idx] model.predict(va_X, num_iterationmodel.best_iteration) score np.sqrt(mean_squared_error(y, oof)) print(fCV RMSLE: {score:.5f})這段代碼有幾個(gè)細(xì)節(jié)值得注意。dtr 和 dva 分屬訓(xùn)練部分和驗(yàn)證部分early_stopping 在驗(yàn)證集上連續(xù) 100 輪沒有提升時(shí)停止訓(xùn)練避免過擬合的同時(shí)不需要手動(dòng)指定迭代輪數(shù)。predict 時(shí)加上 num_iterationmodel.best_iteration確保使用的是早停時(shí)的最佳模型而不是最后一次迭代的模型。每折訓(xùn)練完都把對(duì)驗(yàn)證集的預(yù)測(cè)存進(jìn) oof最后統(tǒng)一計(jì)算 RMSLE這個(gè)分?jǐn)?shù)就是模型在隨機(jī) K 折下的真實(shí)估計(jì)。如果 CV 分?jǐn)?shù)和線上分?jǐn)?shù)差距很大優(yōu)先檢查是不是交叉驗(yàn)證切分方式與賽題數(shù)據(jù)的生成方式不一致。比如賽題數(shù)據(jù)本身是分多個(gè)批次的或者價(jià)格標(biāo)簽有時(shí)間聚集效應(yīng)這時(shí)候隨機(jī) K 折的 CV 分?jǐn)?shù)會(huì)虛高。4.3 多模型融合策略加權(quán)平均與 Stacking 的取舍單模型跑出合適的 CV 分?jǐn)?shù)后融合是往上走的常見手段。融合的基礎(chǔ)是多樣性不同模型看到的數(shù)據(jù)視角不同它們的錯(cuò)誤模式不同加權(quán)平均后誤差會(huì)互相抵消。常見的組合是 LightGBM、XGBoost、CatBoost也可以加入嶺回歸模型專門擬合線性部分。簡(jiǎn)單加權(quán)平均是最穩(wěn)的融合方式實(shí)現(xiàn)成本低不容易過擬合。權(quán)重不一定要精確搜索我會(huì)先看每個(gè)模型單獨(dú)的 CV 分?jǐn)?shù)分?jǐn)?shù)越好的給越大權(quán)重。一般權(quán)重控制在 0.5、0.3、0.2 這種粗糙的比例即可過度調(diào)權(quán)重反而是在擬合驗(yàn)證集。pred_lgb model_lgb.predict(test_X, num_iterationmodel_lgb.best_iteration) pred_xgb model_xgb.predict(test_X) pred_cat model_cat.predict(test_X) final_pred 0.5 * pred_lgb 0.3 * pred_xgb 0.2 * pred_cat final_price np.expm1(final_pred)這里三個(gè)模型都輸出 log 價(jià)格直接加權(quán)最后 expm1 還原。如果你手頭只有兩個(gè)模型權(quán)重可以改成 0.6/0.4。要注意的是融合前一定要確認(rèn)所有模型用的是同一份測(cè)試集特征并且在相同的 log 空間里輸出否則加權(quán)沒有意義。Stacking 比加權(quán)平均更強(qiáng)但風(fēng)險(xiǎn)也更大。做法是把每折 OOF 預(yù)測(cè)作為第二層的特征然后用一個(gè)簡(jiǎn)單模型比如 Ridge去擬合真實(shí)標(biāo)簽。Stacking 的問題在于第二層很容易過擬合尤其是訓(xùn)練數(shù)據(jù)量不足一萬條時(shí)融合收益會(huì)被過擬合吃掉。我的經(jīng)驗(yàn)是如果 OOF 分?jǐn)?shù)提升不超過 0.001就優(yōu)先用加權(quán)平均只有數(shù)據(jù)量超過 5 萬條或者單模型差異足夠大時(shí)才值得上 Stacking。下面是一個(gè)在實(shí)踐中被反復(fù)驗(yàn)證過的結(jié)論把交叉驗(yàn)證做扎實(shí)比調(diào) 100 組超參數(shù)都有效。CV 分?jǐn)?shù)和線上分?jǐn)?shù)的相關(guān)性是你判斷一切特征和模型改動(dòng)是否有效的唯一標(biāo)準(zhǔn)。只要 CV 和線上相關(guān)性好你的每次改進(jìn)才是真的改進(jìn)否則你只是在對(duì)著排行榜碰運(yùn)氣。5. 二手車價(jià)格預(yù)測(cè)的 5 個(gè)常見坑翻車現(xiàn)場(chǎng)與后悔藥前面講的都是“正確做法”但真正讓你排位靠后的往往是那些看起來無害的處理習(xí)慣。這 5 個(gè)坑我都在復(fù)現(xiàn)這類賽題時(shí)踩過或者見別人踩過每一個(gè)都配了后悔藥。5.1 標(biāo)簽泄漏把“新車指導(dǎo)價(jià)”直接當(dāng)特征現(xiàn)象交叉驗(yàn)證分?jǐn)?shù)極低單模型 RMSLE 能到 0.30 以內(nèi)自己都覺得要拿獎(jiǎng)了結(jié)果一提交線上分?jǐn)?shù)比 CV 高出一大截排名還不如隨機(jī)猜測(cè)。原因數(shù)據(jù)里通常會(huì)有“新車指導(dǎo)價(jià)”或者“廠商建議價(jià)”這類字段。它確實(shí)是很有用的價(jià)格錨點(diǎn)但它和目標(biāo)成交價(jià)高度相關(guān)甚至可以說指導(dǎo)價(jià)已經(jīng)包含了價(jià)格信息。當(dāng)你把指導(dǎo)價(jià)原封不動(dòng)作為特征時(shí)模型學(xué)到的是“成交價(jià)≈指導(dǎo)價(jià)×0.8”之類的映射可測(cè)試集里同樣有這個(gè)字段所以訓(xùn)練時(shí)看似正確但實(shí)際上模型并沒有學(xué)習(xí)到車況、里程這些真正的定價(jià)因素。一旦線上數(shù)據(jù)的指導(dǎo)價(jià)和成交價(jià)關(guān)系發(fā)生漂移立刻翻車。解決不要直接用指導(dǎo)價(jià)把它變成“貶值率”或“價(jià)格折扣”再作為特征貶值率成交價(jià)/指導(dǎo)價(jià)這樣目標(biāo)就和指導(dǎo)價(jià)解耦了。如果這個(gè)字段確實(shí)要保留也要在交叉驗(yàn)證時(shí)做敏感性分析看看去掉它對(duì) CV 分?jǐn)?shù)的影響。還有一種更保險(xiǎn)的做法凡是和目標(biāo)強(qiáng)相關(guān)且業(yè)務(wù)上屬于“結(jié)果”的字段全部剔除。在復(fù)現(xiàn)階段我還會(huì)單獨(dú)算一下指導(dǎo)價(jià)和成交價(jià)的斯皮爾曼相關(guān)系數(shù)如果超過 0.9就提醒自己一定要做變換。5.2 缺失值填充太粗暴用 0 填充類別特征現(xiàn)象模型不報(bào)錯(cuò)特征重要性排名很高CV 分?jǐn)?shù)卻上不去而且在某些細(xì)分品牌上的預(yù)測(cè)嚴(yán)重偏離。原因0 在類別編碼里成為了一個(gè)新類別但缺失并不等于“無”或“手動(dòng)擋”這類含義。以變速箱字段為例如果缺失的車輛其實(shí)是自動(dòng)擋你填 0 后模型可能把“0”分裂成一個(gè)單獨(dú)分支專門預(yù)測(cè)一個(gè)奇怪的中間價(jià)格。樹模型雖然能處理缺失但那是基于模型自己的分裂決策而不是你預(yù)先填充的武斷值。解決類別字段的缺失統(tǒng)一用一個(gè)單獨(dú)字符串 missing 填充然后再編碼數(shù)值字段用中位數(shù)而不是 0 填充如果是布爾型字段比如“是否有事故”缺失就填 unknown。這樣做的核心思想是讓模型自己判斷缺失模式與目標(biāo)的關(guān)系而不是強(qiáng)加一個(gè)業(yè)務(wù)含義。如果數(shù)據(jù)量大我還會(huì)跑兩個(gè)版本做對(duì)比一個(gè)用 0 填充一個(gè)用 missing 填充看 CV 分?jǐn)?shù)的差異是否明顯用數(shù)據(jù)說話而不是拍腦袋。5.3 交叉驗(yàn)證與線上不一致隨機(jī) K 折遇上時(shí)間漂移現(xiàn)象隨機(jī) 5 折 CV 分?jǐn)?shù)穩(wěn)定在 0.25線上分?jǐn)?shù)卻是 0.36而且每次提交都穩(wěn)定差 0.1 左右。原因賽題數(shù)據(jù)如果是按時(shí)間分批收集的汽車價(jià)格會(huì)受市場(chǎng)行情、新車降價(jià)、政策變化等多種因素影響。隨機(jī) K 折把所有樣本混在一起等于讓模型用后幾個(gè)月的數(shù)據(jù)去“訓(xùn)練”前幾個(gè)月的樣本或者反過來說驗(yàn)證集里的樣本在訓(xùn)練集里找到了來自未來的同類信息CV 分?jǐn)?shù)自然虛高。線上評(píng)估時(shí)用的是嚴(yán)格按時(shí)間劃分的新數(shù)據(jù)這種信息被切斷分?jǐn)?shù)就崩了。解決先確認(rèn)數(shù)據(jù)里有沒有時(shí)間戳比如上牌日期、采集日期。如果有按時(shí)間排序后對(duì)樣本編號(hào)取前 80% 做訓(xùn)練、后 20% 做驗(yàn)證。如果你用的是隨機(jī) K 折至少保證 KFold 的 shuffleTrue并且固定 seed方便排查。還有一個(gè)驗(yàn)證方法是把數(shù)據(jù)按月份分組畫一張“每個(gè)月平均成交價(jià)”的曲線看看是否存在明顯趨勢(shì)有趨勢(shì)就按時(shí)間切分沒有趨勢(shì)才可以用隨機(jī) K 折。這個(gè)檢查 10 分鐘就能做完卻能避免后面幾天的無效工作。5.4 對(duì)數(shù)變換的陷阱預(yù)測(cè)值取 exp 后系統(tǒng)性偏低現(xiàn)象RMSLE 看起來合格但把預(yù)測(cè)價(jià)格從 log 空間還原后發(fā)現(xiàn)平均預(yù)測(cè)值比真實(shí)平均值低 5% 到 10%整體偏移。原因在 log 空間用 MSE 做優(yōu)化等價(jià)于擬合目標(biāo)的對(duì)數(shù)幾何均值。當(dāng)你把對(duì)數(shù)均值變換回原始空間時(shí)得到的是幾何均值而真實(shí)數(shù)據(jù)的均值是算術(shù)均值兩者天然存在差距。尤其當(dāng)價(jià)格分布右偏時(shí)這個(gè)差距會(huì)更明顯。LightGBM 的 objectiveregression 直接作用于 log 目標(biāo)所以預(yù)測(cè)的 log 均值天然是偏低的。解決常見做法是做一個(gè)偏差校正系數(shù)。先用訓(xùn)練集每個(gè)測(cè)試樣本的預(yù)測(cè)值乘以一個(gè)系數(shù) cc 真實(shí)價(jià)格均值 / 預(yù)測(cè)價(jià)格均值這個(gè)系數(shù)用 OOF 預(yù)測(cè)計(jì)算得到然后套在測(cè)試集預(yù)測(cè)上。也可以嘗試在訓(xùn)練時(shí)把目標(biāo)換成原始價(jià)格配合 huber loss 等對(duì)異常值魯棒的損失函數(shù)看 CV 分?jǐn)?shù)的變化。我個(gè)人習(xí)慣用 OOF 校正系數(shù)簡(jiǎn)單有效。具體實(shí)現(xiàn)時(shí)我會(huì)把 OOF 預(yù)測(cè)的 exp 值和真實(shí)價(jià)格放在同一張表里算一個(gè)加權(quán)平均系數(shù)再把這個(gè)系數(shù)固化到 predict 腳本里避免每次提交時(shí)手動(dòng)改。5.5 融合時(shí)權(quán)重拍腦袋沒有考慮相關(guān)性現(xiàn)象兩個(gè)單模型 CV 分?jǐn)?shù)都是 0.26融合之后 CV 分?jǐn)?shù)反而變成 0.27還不如取最好的單模型。原因融合的前提是模型之間具有多樣性。如果你把同一個(gè) LightGBM 用不同的隨機(jī)種子跑了兩次這兩個(gè)模型的預(yù)測(cè)高度相關(guān)錯(cuò)誤模式幾乎一樣加權(quán)平均不僅不會(huì)糾錯(cuò)還可能把某些正確預(yù)測(cè)平均偏。如果再加一個(gè) CatBoost但它和 LightGBM 在特征使用上太接近融合收益也有限。解決在融合前先計(jì)算各模型 OOF 預(yù)測(cè)之間的皮爾遜相關(guān)系數(shù)。只保留相關(guān)系數(shù)低于 0.95 的模型進(jìn)入融合如果兩個(gè)模型相關(guān)系數(shù)超過 0.97說明它們幾乎等價(jià)留一個(gè)就行。權(quán)重不要純靠感覺可以用一個(gè)簡(jiǎn)單的網(wǎng)格搜索在驗(yàn)證集上遍歷權(quán)重組合選出 CV 分?jǐn)?shù)最低的組合但要在多折上確認(rèn)穩(wěn)定性防止過擬合驗(yàn)證集。我一般把權(quán)重搜索步長設(shè)為 0.1組合數(shù)量控制在 100 以內(nèi)跑完后的最優(yōu)權(quán)重如果和啟發(fā)式權(quán)重差距很大反而要警惕是不是驗(yàn)證集太小。6. 把項(xiàng)目說明寫成別人能復(fù)現(xiàn)的東西源碼結(jié)構(gòu)、運(yùn)行順序與你的第一個(gè)改進(jìn)點(diǎn)一個(gè)能拿獎(jiǎng)的方案源碼質(zhì)量和項(xiàng)目說明同樣重要。很多人跑完模型就丟在一堆 Untitled.ipynb 里等到想復(fù)現(xiàn)的時(shí)候發(fā)現(xiàn)忘了哪步在前哪步在后。我習(xí)慣把代碼拆成幾個(gè)獨(dú)立模塊每個(gè)模塊只干一件事然后通過 README 把整條流水線串起來。car_price_prediction/ ├── data/ │ ├── train.csv │ └── test.csv ├── src/ │ ├── preprocess.py │ ├── features.py │ ├── train.py │ └── predict.py ├── notebooks/ │ └── EDA.ipynb ├── README.md └── requirements.txtpreprocess.py 負(fù)責(zé)加載數(shù)據(jù)、缺失值填充、異常值過濾features.py 負(fù)責(zé)構(gòu)造車齡、目標(biāo)編碼、組合特征并輸出特征矩陣train.py 負(fù)責(zé) K 折訓(xùn)練和保存模型每個(gè)模型存成 pkl 或 txtpredict.py 讀取模型拼接測(cè)試集特征輸出提交格式。運(yùn)行順序就是 preprocess 到 features 到 train 再到 predict每一步都從上一部的輸出讀數(shù)據(jù)避免重復(fù)執(zhí)行。README 里我會(huì)寫清楚四件事賽題任務(wù)和評(píng)估指標(biāo)、依賴庫版本說明、一條命令從頭訓(xùn)練到預(yù)測(cè)的執(zhí)行方式、以及復(fù)現(xiàn)時(shí)需要注意的坑比如目標(biāo)編碼必須用訓(xùn)練集的映射。不需要寫成論文只要讓另一個(gè)工程師照著做能在半小時(shí)內(nèi)跑出和賽題排名對(duì)應(yīng)的分?jǐn)?shù)就算合格。如果你想在這個(gè)基礎(chǔ)上做第一個(gè)改進(jìn)我建議從驗(yàn)證策略開始把隨機(jī) K 折改成按時(shí)間切分或者加入 GroupKFold 按品牌分組往往比調(diào)模型參數(shù)更有效。我在最初做這類賽題時(shí)總把時(shí)間花在調(diào)參上后來發(fā)現(xiàn)把驗(yàn)證策略做扎實(shí)比調(diào) 100 組參數(shù)都管用。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取