據(jù)預(yù)處理到模型部署的AI項(xiàng)目實(shí)戰(zhàn))
一個人剛接觸機(jī)器學(xué)習(xí)時最容易被帶偏的方向是直接撲向深度學(xué)習(xí)框架。但做了這些年實(shí)際項(xiàng)目我的觀點(diǎn)一直很明確真正該作為第一站、也值得長期留在工具箱里的是scikit-learn。它不需要你寫復(fù)雜的前向傳播腳本不需要盯著損失曲線懷疑人生只需要把數(shù)據(jù)整理干凈用一套統(tǒng)一的API把模型訓(xùn)練、評估、調(diào)參、部署整個流程串起來——這就是一條非常完整的AI模型開發(fā)全流程。這篇文章我打算用一個房價回歸項(xiàng)目作為主線從環(huán)境準(zhǔn)備一直走到模型保存上線。中間會穿插大量我在實(shí)際項(xiàng)目中踩過的坑包括數(shù)據(jù)泄漏、參數(shù)搜索效率、Pipeline設(shè)計(jì)這些教科書很少講但工作中一定會遇到的問題。無論你是剛?cè)腴T的新手還是已經(jīng)跑過幾個demo想系統(tǒng)化自己流程的開發(fā)者這條鏈路應(yīng)該都能給你一些可復(fù)現(xiàn)的參考。1. 為什么選scikit-learn先把工具箱的脾氣摸透1.1 三個動作統(tǒng)治一切APIscikit-learn最大的特點(diǎn)不是某個模型多強(qiáng)而是它把所有算法工具都收斂到了極少數(shù)幾個接口上。你只需要記住三個動作fit學(xué)習(xí)、predict預(yù)測、transform變換。fit(X, y)訓(xùn)練模型或者讓預(yù)處理器從數(shù)據(jù)中學(xué)習(xí)參數(shù)predict(X)用訓(xùn)練好的模型對新樣本做預(yù)測transform(X)用學(xué)到的規(guī)則把數(shù)據(jù)轉(zhuǎn)成另一種形式典型的就是標(biāo)準(zhǔn)化。新手可能覺得這沒什么但真正用久了會意識到這個設(shè)計(jì)有多重要。換模型的時候代碼幾乎不用動只是把類名換掉。今天用邏輯回歸明天換隨機(jī)森林只要你還在scikit-learn體系內(nèi)前后代碼結(jié)構(gòu)和流程完全一致。這和自己在PyTorch里寫一套訓(xùn)練循環(huán)換模型就要改forward、改優(yōu)化器配置的體驗(yàn)完全不同。另一個好處是文檔質(zhì)量。官方文檔對每個參數(shù)都給出了說明和默認(rèn)值示例代碼基本都能直接跑通。我當(dāng)年學(xué)這個庫的時候就是靠反復(fù)看官方示例把概念串起來的。1.2 和pandas、numpy的分工協(xié)作scikit-learn本身不負(fù)責(zé)數(shù)據(jù)讀取和處理它吃進(jìn)去的是一個二維數(shù)組或者類數(shù)組結(jié)構(gòu)。實(shí)際項(xiàng)目中通常是用pandas把CSV或Excel讀進(jìn)來做初步清洗然后轉(zhuǎn)換出特征矩陣X和目標(biāo)向量y給模型用。這里有一個新手必踩的坑pandas里某一列提取出來是Series是一維的直接當(dāng)成X傳給模型會報Expected 2D array, got 1D array instead之類的錯誤。解決辦法也很簡單import pandas as pd df pd.DataFrame({面積: [80, 120, 95], 房價: [200, 300, 240]}) X df[[面積]] # 保持二維 y df[房價] # 一維向量我見過不少人在這里卡半天其實(shí)就是X必須保留特征維度的概念沒搞清楚。記住一句話X永遠(yuǎn)是二維的哪怕你只有一個特征。2. 一整條項(xiàng)目流程跑通房價回歸模型的從0到12.1 先看數(shù)據(jù)再定目標(biāo)這次拿到的原始數(shù)據(jù)長什么樣我這里用自己之前在某個模擬項(xiàng)目里處理過的一份房價數(shù)據(jù)來演示字段風(fēng)格和很多公開房產(chǎn)數(shù)據(jù)集很接近面積、房間數(shù)、房齡、層數(shù)、地段評分還有一些缺失率不等的列。目標(biāo)變量是房價。拿到數(shù)據(jù)做的第一件事不是建模而是用pandas快速看一眼結(jié)構(gòu)import pandas as pd df pd.read_csv(house_price_demo.csv) print(df.shape) print(df.head()) print(df.info()) print(df.describe())info()能一次看清每一列的類型和缺失數(shù)量describe()則給數(shù)值列的分布概覽。這一步能避免后面許多問題。比如我們發(fā)現(xiàn)房齡存在缺失、地段評分是數(shù)值但范圍明顯不同這些都會直接影響后續(xù)的預(yù)處理策略。我在真實(shí)項(xiàng)目里見過太多人跳過EDA直接fit()結(jié)果模型報錯才回頭查數(shù)據(jù)。Debug半天發(fā)現(xiàn)是列里有字符串混進(jìn)了數(shù)值列。所以哪怕是時間緊張至少也要把這幾個函數(shù)跑一遍。2.2 第一次訓(xùn)練用最簡單的模型拿到baseline數(shù)據(jù)梳理完之后先不急著搞復(fù)雜的特征工程我要做的第一件事是切分訓(xùn)練集和測試集然后訓(xùn)練一個最普通的線性回歸模型跑出baseline。from sklearn.model_selection import train_test_split from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error feature_cols [面積, 房間數(shù), 房齡, 層數(shù), 地段評分] X df[feature_cols] y df[房價] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) model LinearRegression() model.fit(X_train, y_train) y_pred model.predict(X_test) rmse mean_squared_error(y_test, y_pred, squaredFalse) print(Baseline RMSE:, rmse)test_size0.2表示留出20%的樣本做最終評估random_state42是隨機(jī)數(shù)種子固定之后每次切分結(jié)果一樣方便復(fù)現(xiàn)對比。最基礎(chǔ)的線性回歸沒有超參數(shù)要調(diào)跑出來一個RMSE后面所有的工作都以這個數(shù)字為參照如果你花了半天做特征工程結(jié)果RMSE比baseline還高那你就要反思方向了。順便提醒一句mean_squared_error里面squaredFalse返回的是RMSE也就是均方根誤差單位與目標(biāo)變量一致更好解釋。默認(rèn)的squaredTrue返回MSE早期版本這個參數(shù)名不太一樣升級時容易踩兼容性的坑。2.3 為什么非要分訓(xùn)練集和測試集這個問題看起來基礎(chǔ)但真的是理解機(jī)器學(xué)習(xí)的關(guān)鍵。模型要的不是在舊數(shù)據(jù)上背答案而是對新數(shù)據(jù)給出準(zhǔn)確預(yù)測。如果把全部數(shù)據(jù)都用來訓(xùn)練模型表現(xiàn)得再好也不能說明任何問題——考試題和練習(xí)題是同一套分?jǐn)?shù)高只能說明記性好。訓(xùn)練集就是練習(xí)題測試集就是模擬考。模型在訓(xùn)練集上學(xué)規(guī)律在測試集上驗(yàn)證它是不是真的學(xué)會了規(guī)律而非死記硬背。這也是為什么切分集的時候要特別注意隨機(jī)性如果測試集恰好都來自同一片區(qū)域、同一個時間段評價結(jié)果就失真了。3. 數(shù)據(jù)預(yù)處理的三個隱藏坑標(biāo)準(zhǔn)化、缺失值與數(shù)據(jù)泄漏3.1 標(biāo)準(zhǔn)化為什么樹模型不縮放也能跑線性模型不縮放就崩特征之間的量綱差距很大。面積可能是幾十到幾百房齡可能是0到50放在同一數(shù)量級還好說但如果有個特征是建造年份值是1990、2005這種直接喂給線性回歸梯度下降和正則化項(xiàng)就會被大數(shù)值特征牽著走。StandardScaler的作用是把每個特征變成均值接近0、標(biāo)準(zhǔn)差接近1的分布。用法上要注意fit和transform分兩步走from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test)fit_transform等于先學(xué)均值和標(biāo)準(zhǔn)差再用它們轉(zhuǎn)換數(shù)據(jù)。測試集這里只能用transform絕對不能重新fit_transform。為什么因?yàn)闇y試集在真實(shí)場景里代表未來未知的數(shù)據(jù)你考察的是這個模型未來上線后的表現(xiàn)不能用未來的信息去偷看測試集的分布。這個問題扯遠(yuǎn)一點(diǎn)就是數(shù)據(jù)泄漏后面專門展開。有人會問為什么隨機(jī)森林這類樹模型不搞標(biāo)準(zhǔn)化也能出結(jié)果因?yàn)闃淠P妥龅氖翘卣骶S度上的切分每次只按一個特征找閾值特征取值范圍的大小不直接影響分裂邏輯。但如果是線性模型、SVM、KNN這類基于距離或梯度的模型不標(biāo)準(zhǔn)化會導(dǎo)致模型訓(xùn)練極不穩(wěn)定甚至不收斂。3.2 缺失值處理中位數(shù)不是默認(rèn)選項(xiàng)而是策略選項(xiàng)缺失值處理沒有萬能答案。SimpleImputer提供了幾種策略策略適用場景mean數(shù)據(jù)整體分布對稱無極端離群值median有離群值希望填充值更穩(wěn)健most_frequent類別型特征用出現(xiàn)最多的類別填充constant業(yè)務(wù)上明確缺失值有特殊含義比如用0或未知填充我實(shí)際處理中會先看缺失率。如果某個特征缺失率超過40%除非它有很強(qiáng)的業(yè)務(wù)含義否則更傾向于直接刪掉這一列而不是花精力去設(shè)計(jì)填充方案。缺失率低的情況下我會優(yōu)先用中位數(shù)而不是均值——原因很實(shí)際房價、收入這類數(shù)據(jù)經(jīng)常有極端值個別天價房會把均值拉高中位數(shù)更能代表大多數(shù)樣本的水平。要注意的是SimpleImputer也遵循fit和transform兩段式。填充值必須只從訓(xùn)練集中學(xué)習(xí)。有人圖省事對整個DataFrame做imputer.fit_transform(df)等于把測試集的信息提前泄露給了訓(xùn)練過程。3.3 數(shù)據(jù)泄漏那些看起來正常其實(shí)違反原則的做法數(shù)據(jù)泄漏是模型評估虛高的最大元兇。最常見的兩種先對整個X做標(biāo)準(zhǔn)化或填充再切分訓(xùn)練測試集用全量數(shù)據(jù)做特征選擇或統(tǒng)計(jì)匯總再喂給模型。第一種情況標(biāo)準(zhǔn)化把測試集的均值和標(biāo)準(zhǔn)差混進(jìn)了特征值里模型在訓(xùn)練時等于看過測試集的一部分統(tǒng)計(jì)信息評估出來的指標(biāo)會比真實(shí)水平好看但上線后立刻露餡。第二種更隱蔽。比如用全量數(shù)據(jù)算出某個特征與目標(biāo)的相關(guān)性篩選排名靠前的特征后再做訓(xùn)練。這個篩選過程本身就用到了測試集的信息同樣屬于泄漏。這里可以記住一個通用原則訓(xùn)練階段所有對數(shù)據(jù)的觀察和學(xué)習(xí)都只能在訓(xùn)練集上發(fā)生。測試集必須被當(dāng)作完全未知的數(shù)據(jù)隔離到最后。4. 模型選型與交叉驗(yàn)證讓證據(jù)替你做決策4.1 不按場景選模型等于閉著眼挑工具scikit-learn里的模型家族很多但根據(jù)任務(wù)類型可以很快收斂。回歸任務(wù)我通常從LinearRegression、RandomForestRegressor、GradientBoostingRegressor里選分類任務(wù)則是LogisticRegression、RandomForestClassifier、GradientBoostingClassifier。線性模型和樹模型的本質(zhì)差異值得說清楚。線性模型的假設(shè)是特征與目標(biāo)之間存在線性關(guān)系模型復(fù)雜度低、訓(xùn)練極快、結(jié)果可解釋但面對非線性關(guān)系時表現(xiàn)有限。樹模型通過不斷切分特征空間來逼近目標(biāo)能自動處理特征間的交互和非線性但更容易過擬合、訓(xùn)練慢、模型解釋也更難。我的經(jīng)驗(yàn)是如果特征數(shù)量特別多但樣本量不大優(yōu)先線性模型因?yàn)闃淠P驮谶@種場景下非常容易過擬合如果特征數(shù)量和樣本量都比較健康而且存在明顯的非線性關(guān)系再上樹模型。不要一開始就堆一個大集成模型那會讓后續(xù)排查變得很痛苦。4.2 交叉驗(yàn)證到底在交叉什么我們前面只切了一次訓(xùn)練集和測試集但單次切分是有運(yùn)氣成分的。某一次切分中測試集恰好都比較簡單分?jǐn)?shù)虛高或者都比較難分?jǐn)?shù)偏低。交叉驗(yàn)證就是為了解決這個問題。以5折交叉驗(yàn)證為例把訓(xùn)練集切成5份每次拿其中4份訓(xùn)練、1份做驗(yàn)證輪流5次最后把5次驗(yàn)證分?jǐn)?shù)取平均。這樣每一個樣本都當(dāng)過驗(yàn)證樣本評估結(jié)果更穩(wěn)定。from sklearn.model_selection import cross_val_score scores cross_val_score( model, X_train_scaled, y_train, cv5, scoringr2 ) print(CV R2:, scores.mean(), /-, scores.std())有個容易懵的點(diǎn)sklearn的scoring約定是越大越好所以像MSE、RMSE這類越小越好的指標(biāo)在cross_val_score里要用負(fù)數(shù)形式比如scoringneg_mean_squared_error。我第一次用的時候給scoringr2是很正常的但為了統(tǒng)一接口neg_mean_squared_error返回值是負(fù)的MSE數(shù)值越大說明誤差越小。這塊文檔里寫得很清楚但確實(shí)容易看混。4.3 模型對比的前三步baseline、默認(rèn)參數(shù)、交叉驗(yàn)證用交叉驗(yàn)證快速對比幾個候選模型比在單次切分下比分?jǐn)?shù)要靠譜得多。我習(xí)慣的做法是先跑baseline線性模型再看樹模型默認(rèn)參數(shù)下的交叉驗(yàn)證表現(xiàn)。模型CV R2均值CV R2標(biāo)準(zhǔn)差測試集RMSELinearRegression0.620.0532.4RandomForestRegressor0.790.0421.7GradientBoostingRegressor0.840.0317.9這是一份典型的虛構(gòu)對比結(jié)果但模式是真實(shí)存在的??磧蓚€東西均值和標(biāo)準(zhǔn)差。均值代表模型整體水平標(biāo)準(zhǔn)差代表穩(wěn)定性。單一一次測試分?jǐn)?shù)可能會騙你但5次交叉驗(yàn)證的均值和方差能把模型真實(shí)水平暴露得更徹底。在這個例子里GradientBoosting的均值最高、標(biāo)準(zhǔn)差還小顯然是更好的候選。5. Pipeline管線與自動調(diào)參從手工流程到一鍵搜索5.1 為什么不能靠手工記住每一步到了這個階段流程已經(jīng)變成了填補(bǔ)缺失值、標(biāo)準(zhǔn)化、喂模型、預(yù)測。如果手工一步步來做很容易出兩個問題。一是漏步驟。比如你在訓(xùn)練集上做了缺失值填充和標(biāo)準(zhǔn)化但預(yù)測線上新數(shù)據(jù)時忘記調(diào)用同一個imputer和scaler的transform模型接收到的數(shù)據(jù)分布跟訓(xùn)練時不一致預(yù)測結(jié)果會非常離譜。二是參數(shù)搜索不便。調(diào)參時如果每次都用一段手工腳本把預(yù)處理重新執(zhí)行一遍代碼會越來越長改一處漏一處。Pipeline就是來解決這個問題的。它把一系列步驟串成一條流水線fit的時候自動按順序執(zhí)行predict的時候自動對同一條管線做同樣處理from sklearn.pipeline import Pipeline from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler from sklearn.ensemble import GradientBoostingRegressor pipeline Pipeline([ (imputer, SimpleImputer(strategymedian)), (scaler, StandardScaler()), (model, GradientBoostingRegressor(random_state42)) ]) pipeline.fit(X_train, y_train) y_pred pipeline.predict(X_test)整條流水線對外就是一個大的模型對象。訓(xùn)練時先中位數(shù)填充再標(biāo)準(zhǔn)化再訓(xùn)練梯度提升模型預(yù)測時同樣先填充、再標(biāo)準(zhǔn)化、再預(yù)測。這個對象可以拿去交叉驗(yàn)證、可以拿去GridSearchCV搜索、可以保存到磁盤非常省心。5.2 把預(yù)處理和模型一起調(diào)參GridSearchCV的正確打開方式如果不用PipelineGridSearchCV默認(rèn)只能調(diào)模型那一個步驟的參數(shù)。一旦把預(yù)處理放進(jìn)Pipeline搜索空間就擴(kuò)到了全流程你可以同時搜缺失值策略、標(biāo)準(zhǔn)化方式、模型參數(shù)。參數(shù)名用步驟名__參數(shù)名的格式雙下劃線連接from sklearn.model_selection import GridSearchCV param_grid { imputer__strategy: [median, mean], model__n_estimators: [100, 300], model__max_depth: [3, 5], model__learning_rate: [0.05, 0.1], } grid GridSearchCV( pipeline, param_grid, cv5, scoringr2, n_jobs-1 ) grid.fit(X_train, y_train) print(grid.best_params_) print(grid.best_score_)n_jobs-1是讓所有CPU核心一起跑能省大量時間。best_score_是交叉驗(yàn)證下的最優(yōu)平均分?jǐn)?shù)best_params_是它對應(yīng)的參數(shù)組合。注意imputer__strategy這個寫法很關(guān)鍵它說明你要搜索的是Pipeline第一步里SimpleImputer的strategy參數(shù)。這個命名規(guī)則不熟悉的人容易拼錯一旦某個參數(shù)名寫錯GridSearchCV會直接報錯說Invalid parameter那通常就是雙下劃線寫成了單下劃線或者步驟名和類名搞混了。5.3 GridSearchCV與RandomizedSearchCV怎么選參數(shù)一旦多起來GridSearchCV會非常痛苦。假設(shè)3個參數(shù)各有4個候選值組合數(shù)是4^364次訓(xùn)練每次還跑5折交叉驗(yàn)證那就是320次模型擬合幾分鐘到幾十分鐘都有可能。RandomizedSearchCV的思路不是窮舉所有組合而是從參數(shù)空間里隨機(jī)采樣固定數(shù)量的組合。比如n_iter50就隨機(jī)嘗試50種組合同樣跑5折交叉驗(yàn)證。這個方法在我實(shí)踐中的效果出奇地好對大多數(shù)模型來說真正有效的超參數(shù)組合往往集中在某個區(qū)域隨機(jī)搜索能高效地覆蓋到這些區(qū)域。我的建議是參數(shù)數(shù)量超過2個優(yōu)先用隨機(jī)搜索先粗找一遍確定大致范圍后用GridSearchCV在最優(yōu)附近做小范圍微調(diào)。這是性價比最高的做法硬懟全網(wǎng)格搜索容易讓人對調(diào)參這件事產(chǎn)生心理陰影。6. 上線前的最后檢查評估指標(biāo)、過擬合與模型保存6.1 回歸任務(wù)的三個指標(biāo)怎么配合看回歸任務(wù)最常用的是RMSE、MAE和R2。它們給出的信息角度不太一樣。指標(biāo)含義什么時候最有用RMSE預(yù)測誤差的平方均值開根號對大誤差敏感希望懲罰嚴(yán)重偏離的預(yù)測MAE平均絕對誤差反映平均偏差大小希望直觀了解平均差多少錢R2模型解釋了多少目標(biāo)方差接近1越好衡量模型相對均值模型的提升光看一個指標(biāo)容易誤判。比如有一個離群樣本預(yù)測得極差RMSE會被拉高很多但MAE可能看起來沒那么糟糕。反過來R2接近0.85聽起來不錯但如果業(yè)務(wù)場景本身噪聲很大這個分?jǐn)?shù)可能已經(jīng)是天花板了。所以實(shí)際評估時我會把這三個一起打印出來看再結(jié)合業(yè)務(wù)判斷。分類任務(wù)也要額外提醒一句類別不平衡的時候準(zhǔn)確率是陷阱。比如99%的樣本是A類模型全部預(yù)測A類也有99%的準(zhǔn)確率看起來完美其實(shí)什么都沒學(xué)。這時要看精確率、召回率、F1尤其是少數(shù)類上這些指標(biāo)的變化。6.2 怎么用訓(xùn)練集和驗(yàn)證集的分?jǐn)?shù)差判斷過擬合調(diào)參之后我還會做一步額外的驗(yàn)證打印模型在訓(xùn)練集上的分?jǐn)?shù)和交叉驗(yàn)證分?jǐn)?shù)對比差距。如果訓(xùn)練集R2是0.97交叉驗(yàn)證R2只有0.72那基本可以斷定過擬合了——模型把訓(xùn)練集的特征背得太死換一批數(shù)據(jù)就露餡。緩解方向包括減小模型復(fù)雜度比如降低max_depth、調(diào)高正則化參數(shù)、增加更多訓(xùn)練數(shù)據(jù)、減少特征。但也不要追求兩者完全一致。訓(xùn)練分?jǐn)?shù)略高于驗(yàn)證分?jǐn)?shù)是一個正?,F(xiàn)象有點(diǎn)類似模擬考成績略高于練習(xí)題的合理差異。關(guān)鍵看差距是否在可容忍范圍內(nèi)而不是死盯數(shù)字相等。6.3 保存模型與真正上線的姿勢模型要上線第一件事是保存。scikit-learn官方推薦用joblib而不是pickleimport joblib joblib.dump(pipeline, house_price_pipeline.joblib)加載預(yù)測loaded_pipeline joblib.load(house_price_pipeline.joblib) new_data pd.DataFrame([{面積: 90, 房間數(shù): 2, 房齡: 8, 層數(shù): 3, 地段評分: 7.5}]) pred loaded_pipeline.predict(new_data)這里有一個我踩過的大坑有人只保存了最后的模型沒有保存預(yù)處理器。到了線上新數(shù)據(jù)直接裸著喂給模型而模型在訓(xùn)練時接收的是標(biāo)準(zhǔn)化之后的數(shù)據(jù)——兩邊分布對不上預(yù)測結(jié)果完全失真。把整條Pipeline保存下來等于把預(yù)處理邏輯也一起打包了線上加載后直接預(yù)測不會再出現(xiàn)漏變換的問題。我個人的習(xí)慣是每次訓(xùn)練完都把Pipeline和評估指標(biāo)一起存進(jìn)一個帶日期的文件夾里線上出問題能隨時回滾到歷史版本。這個習(xí)慣幫我省掉過不少臨時救火的麻煩。