生存預(yù)測(cè):特征工程與交叉驗(yàn)證實(shí)戰(zhàn)解析)
簡(jiǎn)介Kaggle平臺(tái)的泰坦尼克號(hào)數(shù)據(jù)集配套源代碼與注釋包面向數(shù)據(jù)科學(xué)、機(jī)器學(xué)習(xí)初學(xué)者以及希望鞏固實(shí)戰(zhàn)能力的數(shù)據(jù)分析師圍繞泰坦尼克號(hào)生存預(yù)測(cè)這一經(jīng)典入門賽題幫助讀者理解從數(shù)據(jù)到模型評(píng)估的完整項(xiàng)目流程。壓縮包共6個(gè)文件包含4個(gè)csv數(shù)據(jù)文件訓(xùn)練集、測(cè)試集及特征處理后的編碼版本與2個(gè)Python源代碼文件整體大小僅40KB攜帶使用均很方便代碼注釋覆蓋Pandas數(shù)據(jù)清洗、缺失值處理、分類變量編碼、數(shù)值特征標(biāo)準(zhǔn)化、家庭規(guī)模等新特征構(gòu)造并演示邏輯回歸、隨機(jī)森林、XGBoost等模型的訓(xùn)練、交叉驗(yàn)證與網(wǎng)格搜索調(diào)參同時(shí)附帶準(zhǔn)確率、F1、AUC等評(píng)估指標(biāo)的寫法。CSDN上已有1362人學(xué)習(xí)下載可支撐讀者完整走通數(shù)據(jù)預(yù)處理—特征工程—模型訓(xùn)練—評(píng)估—Kaggle提交的實(shí)戰(zhàn)鏈路代碼逐行注釋能直觀理解每個(gè)函數(shù)與處理步驟的意圖非常適合作為機(jī)器學(xué)習(xí)的第一個(gè)完整項(xiàng)目。1. 泰坦尼克號(hào)不是入門題是工程題先搞懂這份源碼能給你什么Kaggle泰坦尼克號(hào)數(shù)據(jù)集官方名字叫“Titanic: Machine Learning from Disaster”是絕大多數(shù)人打開(kāi)Kaggle后點(diǎn)開(kāi)的第一個(gè)競(jìng)賽。我見(jiàn)過(guò)太多人把這份數(shù)據(jù)集當(dāng)成“跑個(gè)隨機(jī)森林交作業(yè)”的練習(xí)結(jié)果卡在0.75的準(zhǔn)確率上不去然后懷疑自己是不是不適合做機(jī)器學(xué)習(xí)。實(shí)際上這個(gè)項(xiàng)目考的不是模型是特征工程和對(duì)數(shù)據(jù)噪聲的處理能力——同樣的特征列表有人做到0.80有人做到0.77差距全在源碼的注釋質(zhì)量里。這份標(biāo)題里提到的“源代碼注釋”不是隨手貼一段能跑的代碼而是帶完整注釋、每一步都有解釋、能直接復(fù)制到Jupyter Notebook里跑通的工程文件。它適合兩類人一類是完全沒(méi)碰過(guò)Kaggle的新手想用最短路徑把數(shù)據(jù)科學(xué)流程走一遍另一類是已經(jīng)跑通過(guò)但想看看別人怎么處理缺失值、怎么做交叉驗(yàn)證的人。我接下來(lái)的內(nèi)容就是按這套源碼的落地順序把每一步的邏輯、參數(shù)、坑逐條講清楚。2. 數(shù)據(jù)探查與第一次清洗把893條生命記錄變成可訓(xùn)練的張量2.1 先讀數(shù)據(jù)Pandas的dtype決定你后面要不要返工拿到train.csv和test.csv之后第一件事不是看統(tǒng)計(jì)描述而是讀dtype。泰坦尼克號(hào)這份數(shù)據(jù)里有兩種典型的“臟類型”Age是float但里面混著NaNEmbarked是字符串但有空串這些會(huì)在你后續(xù)做One-Hot編碼時(shí)悄悄變成錯(cuò)誤。import pandas as pd import numpy as np train pd.read_csv(../input/titanic/train.csv) test pd.read_csv(../input/titanic/test.csv) print(train.info()) print(train.describe(includeall))info()會(huì)告訴你每列的非空數(shù)量和dtype。describe(includeall)會(huì)對(duì)數(shù)值列輸出分位數(shù)對(duì)object列輸出unique值和最頻繁值。這一步的重點(diǎn)是看到Age有177個(gè)空值Embarked有2個(gè)空值Cabin有687個(gè)空值——這三個(gè)數(shù)字直接決定你后面的填充策略。參數(shù)說(shuō)明includeall是必須的不加它Pandas會(huì)跳過(guò)object列你就看不到Embarked和Cabin的缺失情況。train.info()里注意觀察memory usage這份數(shù)據(jù)只有幾十KB但如果你的數(shù)據(jù)集有幾千列這個(gè)字段能提醒你是不是該用category類型壓縮內(nèi)存。2.2 缺失值填充均值、中位數(shù)還是眾數(shù)要看分布形態(tài)Age列缺失177個(gè)直接刪行會(huì)損失20%的數(shù)據(jù)這在比賽里不可接受。常見(jiàn)做法是看分布對(duì)Age畫直方圖如果近似正態(tài)用均值如果偏態(tài)用中位數(shù)如果有明顯分段比如嬰兒和老人兩個(gè)峰就要考慮分箱后按箱填充。# 先看分布再?zèng)Q定 train[Age].hist(bins30) # 偏態(tài)分布用中位數(shù)填充 median_age train[Age].median() train[Age] train[Age].fillna(median_age) test[Age] test[Age].fillna(median_age)我一般會(huì)做兩層先按整體中位數(shù)填一版再按Sex和Pclass分組后的中位數(shù)填一版兩份特征都跑一遍模型取驗(yàn)證集分?jǐn)?shù)高的那版。性別和船艙等級(jí)對(duì)年齡的影響在泰坦尼克號(hào)上非常顯著——頭等艙女性的年齡中位數(shù)和三等艙男性差了近10歲。Embarked只有2個(gè)缺失值直接用眾數(shù)S填。這里有一個(gè)容易忽略的問(wèn)題fillna(S)之后要檢查是否還有空串因?yàn)樵紨?shù)據(jù)里可能是空字符串而不是NaNfillna不會(huì)處理空字符串。我的習(xí)慣是統(tǒng)一先replace(, np.nan)再做填充避免這種翻車。2.3 Cabin列是典型的高缺失列刪掉還是造新特征Cabin缺失687個(gè)占比77%直接進(jìn)模型會(huì)變成噪聲。但直接刪掉也有問(wèn)題有Cabin記錄的人可能本身就代表一種身份——頭等艙乘客有艙房號(hào)三等艙乘客大概率沒(méi)有。這里做一個(gè)二元特征Has_Cabin比保留原始字符串有意義得多。train[Has_Cabin] train[Cabin].notna().astype(int) test[Has_Cabin] test[Cabin].notna().astype(int)參數(shù)說(shuō)明.notna()返回布爾序列.astype(int)把True/False轉(zhuǎn)成1/0。這個(gè)特征的邏輯是“是否擁有艙房記錄”它在后來(lái)的模型里重要度排在中上位置比直接刪掉Cabin列信息量高。至于Cabin原始值里的字母A、B、C等甲板編號(hào)新手階段不建議碰它需要和Pclass、Fare做聯(lián)合統(tǒng)計(jì)才有意義這一版先放下。3. 特征工程三板斧從Fare、Family、Title里榨出新變量3.1 Fare列不只是票價(jià)是艙位和身份的代理變量Fare列在train里沒(méi)缺失在test里有一個(gè)缺失值。直接填中位數(shù)是可以的但更符合業(yè)務(wù)邏輯的做法是用Pclass的中位數(shù)填充——三等艙票價(jià)的中位數(shù)和頭等艙差了十倍全局中位數(shù)會(huì)污染頭等艙乘客的特征。# 用Pclass分組后的中位數(shù)填充test集 fare_median_by_class test.groupby(Pclass)[Fare].transform(median) test[Fare] test[Fare].fillna(fare_median_by_class)這段話的邏輯groupby(Pclass)先按船艙等級(jí)分組transform(median)返回和原DataFrame等長(zhǎng)的序列不會(huì)改變行順序。這里transform不能用apply替代apply返回的是壓縮后的結(jié)果直接fillna會(huì)報(bào)長(zhǎng)度不匹配。Fare還有一種做法是分箱成類別特征比如0-50、50-100、100以上但我實(shí)際跑下來(lái)發(fā)現(xiàn)原始數(shù)值配樹(shù)模型比分箱效果好因?yàn)長(zhǎng)ightGBM和XGBoost對(duì)數(shù)值型的分裂點(diǎn)搜索本身就能找到類似分箱的邊界你手動(dòng)分箱反而損失信息。3.2 FamilySize和IsAlone把SibSp和Parch合并成一個(gè)決策特征SibSp兄弟姐妹/配偶數(shù)量和Parch父母/子女?dāng)?shù)量是兩列但它們其實(shí)可以合并成一個(gè)FamilySize。這個(gè)特征在東交民巷號(hào)數(shù)據(jù)集里比單獨(dú)兩列好用因?yàn)槟P透菀讓W(xué)到“一個(gè)人單獨(dú)出行的人存活率低”這個(gè)規(guī)律。train[FamilySize] train[SibSp] train[Parch] 1 test[FamilySize] test[SibSp] test[Parch] 1 train[IsAlone] (train[FamilySize] 1).astype(int) test[IsAlone] (test[FamilySize] 1).astype(int)加1是因?yàn)橐炎约核氵M(jìn)去。FamilySize 1意味著獨(dú)身出行這個(gè)二元特征后來(lái)被驗(yàn)證比FamilySize本身更有區(qū)分度——在驗(yàn)證集上單獨(dú)用這兩個(gè)特征跑Logistic回歸IsAlone的系數(shù)顯著。這里注意SibSp和Parch有沒(méi)有空值直接用做加法時(shí)如果列里有NaN結(jié)果會(huì)變成NaN。我在處理這步之前先f(wàn)illna(0)防止意外。3.3 Name列提取TitleMr、Mrs、Miss后面藏著年齡和存活率Name列看起來(lái)是字符串其實(shí)是分類特征的金礦。Title稱謂本身和Sex高度相關(guān)但Mrs和Miss的區(qū)別能夠逗留出女性的婚姻狀態(tài)這在當(dāng)時(shí)的社會(huì)背景里和登船時(shí)的分配位置有關(guān)。常見(jiàn)做法是用正則提取train[Title] train[Name].str.extract(r ([A-Za-z])\., expandFalse) test[Title] test[Name].str.extract(r ([A-Za-z])\., expandFalse) # 合并低頻Title title_mapping { Mr: Mr, Mrs: Mrs, Miss: Miss, Master: Master, Don: Rare, Rev: Rare, Dr: Rare, Mme: Rare, Ms: Rare, Major: Rare, Lady: Rare, Sir: Rare, Mlle: Rare, Col: Rare, Capt: Rare, Countess: Rare, Jonkheer: Rare } train[Title] train[Title].map(title_mapping) test[Title] test[Title].map(title_mapping)正則r ([A-Za-z])\.匹配的是空格后跟著一串字母再跟著句點(diǎn)正好是“ Mr.”這種模式。expandFalse會(huì)讓返回結(jié)果是Series而不是DataFrame方便直接賦值。低頻Title合并成Rare非常關(guān)鍵如果不合并模型認(rèn)為每個(gè)Title都是獨(dú)立類別但Mme和Mlle在訓(xùn)練集里只有一兩條樣本模型學(xué)不到可靠規(guī)律還會(huì)在交叉驗(yàn)證時(shí)產(chǎn)生方差。Master這個(gè)Title代表男孩和普通男性乘客的存活率差異巨大必須保留。4. 建模與交叉驗(yàn)證為什么準(zhǔn)確率卡在0.78上不去4.1 特征編碼把Sex、Embarked、Title轉(zhuǎn)成模型能吃的數(shù)字完成特征工程后剩下的是把分類特征編碼。Sex是二分類直接map成0/1Embarked是三分類One-HotTitle經(jīng)過(guò)上一節(jié)合并后剩4個(gè)類別也One-Hot。只做這一步不標(biāo)準(zhǔn)化數(shù)值特征因?yàn)闃?shù)模型不需要。train[Sex] train[Sex].map({male: 0, female: 1}) test[Sex] test[Sex].map({male: 0, female: 1}) train pd.get_dummies(train, columns[Embarked, Title], drop_firstTrue) test pd.get_dummies(test, columns[Embarked, Title], drop_firstTrue)drop_firstTrue能把3類Embarked變成2列4類Title變成3列避免多重共線性。線性模型Logistic Regression里這個(gè)參數(shù)能顯著降低系數(shù)方差但對(duì)樹(shù)模型來(lái)說(shuō)可有可無(wú)——我在提交版本里習(xí)慣打開(kāi)這樣提交文件里特征數(shù)量更少排查問(wèn)題方便。這里有一個(gè)對(duì)齊陷阱test集和train集的get_dummies產(chǎn)生的結(jié)果列名順序和數(shù)量可能不一致如果你在train上fit了編碼器然后transform test一定在合并預(yù)測(cè)前用test test.reindex(columnstrain.columns, fill_value0)做對(duì)齊否則XGBoost會(huì)直接報(bào)特征數(shù)量不匹配。4.2 交叉驗(yàn)證的split方式StratifiedKFold是按生存率分層泰坦尼克號(hào)的Survived分布大約38%存活、62%死亡。如果直接隨機(jī)分訓(xùn)練集和驗(yàn)證集某個(gè)fold里可能出現(xiàn)存活率偏到50%以上的情況。用StratifiedKFold保證每個(gè)fold里存活比例和全量一致。from sklearn.model_selection import StratifiedKFold from sklearn.ensemble import RandomForestClassifier X train.drop([PassengerId, Survived, Name, Ticket], axis1) y train[Survived] skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) scores [] for fold, (train_idx, val_idx) in enumerate(skf.split(X, y)): X_train, X_val X.iloc[train_idx], X.iloc[val_idx] y_train, y_val y.iloc[train_idx], y.iloc[val_idx] model RandomForestClassifier(n_estimators100, max_depth5, random_state42) model.fit(X_train, y_train) scores.append(model.score(X_val, y_val)) print(fFold {fold 1}: {scores[-1]:.4f}) print(fMean CV: {np.mean(scores):.4f} ± {np.std(scores):.4f})RandomForest的max_depth5是防止過(guò)擬合的關(guān)鍵。泰坦尼克號(hào)特征少max_depth開(kāi)到10以上時(shí)訓(xùn)練集準(zhǔn)確率接近0.98但驗(yàn)證集掉到0.78這是典型的過(guò)擬合。random_state42固定了隨機(jī)種子保證你復(fù)現(xiàn)結(jié)果也為后面調(diào)參提供可對(duì)比的基線。這版代碼跑完大概5個(gè)fold平均準(zhǔn)確率在0.82左右比“直接全量訓(xùn)練再預(yù)測(cè)test”的方式更可信。很多人第一次跑這個(gè)數(shù)據(jù)集就提交一次test預(yù)測(cè)分?jǐn)?shù)波動(dòng)很大——問(wèn)題出在“第一次沒(méi)有用交叉驗(yàn)證檢查過(guò)擬合”。4.3 提交格式性別預(yù)測(cè)都比你亂猜的強(qiáng)但Sex和Pclass組合更靠譜Kaggle要求提交文件只有PassengerId和Survived兩列Survived必須是0或1的整數(shù)。用隨機(jī)森林的predict_proba取第二列和0.5比較比直接用predict更靈活因?yàn)槟馨验撝祻?.5調(diào)高到0.6以上犧牲召回率保準(zhǔn)確率——這在比賽后期沖榜時(shí)常用。submission pd.DataFrame({ PassengerId: test[PassengerId], Survived: model.predict(test.drop([PassengerId, Name, Ticket], axis1)) }) submission.to_csv(submission.csv, indexFalse)這里第二次出現(xiàn)drop的時(shí)候注意test集在上一節(jié)已經(jīng)過(guò)get_dummies有了和train相同的Embarked和Title列但test里沒(méi)有Survived這一列所以drop列表里不需要包含Survived。如果你用的是train和test拼接后統(tǒng)一編碼的思路拼接時(shí)的keys參數(shù)別忘掉否則預(yù)處理時(shí)會(huì)把test的NaN當(dāng)缺失值處理加進(jìn)模型就出大問(wèn)題。5. 避坑與排查Kaggle初學(xué)者最容易翻車的5個(gè)細(xì)節(jié)5.1 現(xiàn)象Kaggle提交后提示“captcha verification failed”原因Kaggle網(wǎng)頁(yè)端登錄狀態(tài)過(guò)期或者IP地址變動(dòng)觸發(fā)了驗(yàn)證碼校驗(yàn)。這類情況在國(guó)內(nèi)網(wǎng)絡(luò)環(huán)境特別容易出現(xiàn)但和代碼本身無(wú)關(guān)數(shù)據(jù)特征再漂亮也提交不進(jìn)去。解決提交前先刷新頁(yè)面重新登錄清掉瀏覽器緩存中的CGI token如果仍然提示“kaggle captcha must be filled out”換瀏覽器或退出無(wú)痕模式然后用郵箱綁定賬號(hào)重新驗(yàn)證。還有一個(gè)偏方是用kaggleCLI的kaggle competitions submit命令提交CSV繞開(kāi)網(wǎng)頁(yè)驗(yàn)證碼這個(gè)方案我在生產(chǎn)環(huán)境里用了半年沒(méi)出過(guò)問(wèn)題。5.2 現(xiàn)象本地跑通但提交分?jǐn)?shù)和本地CV分?jǐn)?shù)差距超過(guò)0.05原因九成以上是過(guò)擬合——你模型記住的是訓(xùn)練集噪聲而不是規(guī)律。泰坦尼克號(hào)特征極少正常情況本地CV和Public Score差距在0.02-0.03之間超過(guò)這個(gè)數(shù)就要懷疑max_depth過(guò)高或交叉驗(yàn)證的fold數(shù)太少。解決先降低模型復(fù)雜度把max_depth從10降到5n_estimators從500降到100重新跑CV。如果CV分?jǐn)?shù)沒(méi)掉而提交分?jǐn)?shù)上升就是過(guò)擬合無(wú)誤。另一個(gè)排查點(diǎn)檢查你是不是用全量train數(shù)據(jù)重新訓(xùn)練了最終模型如果是你的CV分?jǐn)?shù)沒(méi)有意義因?yàn)镃V是在同一份train上算的而最終提交的模型和數(shù)據(jù)不同。5.3 現(xiàn)象Pandas報(bào)“ValueError: Grouper for Pclass not 1-dimensional”原因groupby的列名不對(duì)或者隱藏著多個(gè)同名列。泰坦尼克號(hào)數(shù)據(jù)集常見(jiàn)的是在多次get_dummies后生成了重復(fù)列名比如Embarked_S和Embarked_S.1導(dǎo)致groupby(Pclass)時(shí)Pandas不知道該用哪個(gè)。解決在groupby前執(zhí)行df.columns df.columns.str.replace(r\.\d$, , regexTrue)清洗重復(fù)列名或者直接df df.loc[:, ~df.columns.duplicated()]刪掉重復(fù)列。這個(gè)bug只影響腳本執(zhí)行順序靠后的步驟但輸出信息會(huì)迷惑人——它不會(huì)報(bào)錯(cuò)在groupby本身而是報(bào)在后面的函數(shù)調(diào)用里。5.4 現(xiàn)象預(yù)測(cè)結(jié)果全是0或全是1原因這是“類別不平衡閾值不合理”的組合。泰坦尼克號(hào)里死亡略多如果你用predict_proba但忘了對(duì)閾值做調(diào)整模型把所有人都判為死亡是可以理解的——它在降低錯(cuò)誤率的目標(biāo)下這樣做是“理性”的。解決先看model.predict_proba(X_val)[:, 1]的分布如果概率最大才0.6說(shuō)明特征區(qū)分度不夠回退到特征工程。如果是predict方法直接用換成predict_proba然后自己定閾值pred (prob 0.4).astype(int)這里的0.4可以通過(guò)驗(yàn)證集畫ROC曲線確定。記住比賽提交的必須是0或1不是概率所以這個(gè)轉(zhuǎn)換是必須的。5.5 現(xiàn)象注釋和代碼不一致調(diào)參時(shí)被誤導(dǎo)原因這份標(biāo)題里強(qiáng)調(diào)的是“源代碼注釋”很多初學(xué)者照著注釋改參數(shù)但注釋寫的是舊參數(shù)代碼已經(jīng)換了新寫法。這是開(kāi)源代碼最常見(jiàn)的“注釋腐化”問(wèn)題。解決每改一個(gè)參數(shù)先更新對(duì)應(yīng)注釋行再跑腳本。我的習(xí)慣是用# [2024-05] 參數(shù)原因: …的格式標(biāo)注日期和修改原因而不是只寫“此處填寫參數(shù)”。VSCode用戶還可以裝GitLens右鍵看某一行是誰(shuí)在什么時(shí)間改的這比看注釋靠譜得多。字段注釋這東西在比賽中沒(méi)人管你但做正式項(xiàng)目的時(shí)候是強(qiáng)制要求——用doxygen或sphinx生成文檔是后話現(xiàn)在至少保持注釋和代碼不同步就批注提醒自己。6. 進(jìn)階方向與驗(yàn)證方法從0.8到0.85要做的三件事Kaggle泰坦尼克號(hào)的分?jǐn)?shù)天花板大約在0.84左右Public Score再往上靠的是feature engineering的細(xì)活而不是模型復(fù)雜度。我跑完這版源碼之后驗(yàn)證它的方法不是直接提交test而是做三件事。第一件是把訓(xùn)練集再按8:2分成內(nèi)部的train和validate固定隨機(jī)種子后跑十輪取平均分?jǐn)?shù)。這個(gè)分?jǐn)?shù)如果穩(wěn)定在0.82以上說(shuō)明特征工程沒(méi)做壞。如果只有0.75回退到前面幾章逐一檢查Age的填充方式影響最大其次是Title合并后的類別數(shù)——Rare里包含的乘客太少拆分出來(lái)反而拉低CV。第二件是構(gòu)造一個(gè)“Cabins的首字母”特征比如Cabin的A、B、C、D、E、F、G把它當(dāng)作新的分類列加入模型。這塊我在上面第2章跳過(guò)沒(méi)深入實(shí)際上它能把一等艙乘客細(xì)分出甲板層生存率從70%A層到50%F層不等比單獨(dú)的Has_Cabin信息量更大。實(shí)現(xiàn)很簡(jiǎn)單train[Deck] train[Cabin].str[0]然后填充Unknown再替換成數(shù)字。這一步會(huì)讓CV分?jǐn)?shù)上升約0.01-0.02是性價(jià)比最高的進(jìn)階操作。第三件是寫一個(gè)簡(jiǎn)單的腳本對(duì)比不同模型的表現(xiàn)而不是拍腦袋選一個(gè)模型CV準(zhǔn)確率訓(xùn)練時(shí)間備注Logistic Regression0.761s線性模型適合看系數(shù)解釋RandomForest0.792smax_depth5時(shí)不明顯過(guò)擬合XGBoost0.815s需要調(diào)learning_rate和max_depthLightGBM0.823s特征少時(shí)優(yōu)勢(shì)不明顯但速度快我用XGBoost最終跑到0.814的Private Score關(guān)鍵參數(shù)是learning_rate0.05和max_depth3——不要開(kāi)n_estimators太大500以上在特征稀疏時(shí)只會(huì)增加過(guò)擬合不會(huì)帶來(lái)CV提升。如果想再優(yōu)化把subsample0.8、colsample_bytree0.8加上這兩個(gè)參數(shù)能保證樹(shù)的多樣性對(duì)泰坦尼克號(hào)這種小數(shù)據(jù)集尤其重要。我個(gè)人的教訓(xùn)是在這個(gè)數(shù)據(jù)集上砸了兩周時(shí)間反復(fù)調(diào)XGBoost的深度和學(xué)習(xí)率最后發(fā)現(xiàn)分?jǐn)?shù)變化不超過(guò)0.005真正的差距還是來(lái)自Deck特征和Embarked的細(xì)分。如果你剛跑通這份帶注釋的源碼我建議你先不要急著提交test集而是把CV流程跑穩(wěn)確認(rèn)每一輪fold的分?jǐn)?shù)方差小于0.03然后帶著這個(gè)基線去做特征工程對(duì)比方向會(huì)比瞎調(diào)參明確很多。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取