間序列處理:重采樣、滾動(dòng)特征與數(shù)據(jù)泄漏排查)
DataFrame 的時(shí)間序列處理我前后踩過(guò)三輪坑才算真正用順。第一輪是把時(shí)間列當(dāng)普通字符串排序結(jié)果亂序數(shù)據(jù)喂進(jìn)模型指標(biāo)飄得沒(méi)法復(fù)現(xiàn)第二輪是重采樣窗口邊界沒(méi)對(duì)齊日匯總和原始分鐘數(shù)據(jù)對(duì)不上賬排查到半夜第三輪最狠構(gòu)造的特征里混進(jìn)了未來(lái)信息離線評(píng)估漂亮得離譜實(shí)際跑起來(lái)直接失效。這三個(gè)坑基本覆蓋了時(shí)間序列 DataFrame 的絕大多數(shù)陷阱也正好說(shuō)明了為什么這一塊必須單獨(dú)拎出來(lái)講。它是 DataFrame 系列的收尾篇也是銜接機(jī)器學(xué)習(xí)前置技術(shù)棧的關(guān)鍵一環(huán)——前面講的索引、切片、合并、聚合本質(zhì)都是把數(shù)據(jù)擺整齊而時(shí)間序列這一篇要解決的是讓數(shù)據(jù)帶著時(shí)間語(yǔ)義動(dòng)起來(lái)。如果你正在做設(shè)備采集分析、業(yè)務(wù)指標(biāo)監(jiān)控、銷量預(yù)測(cè)或者準(zhǔn)備把一張帶時(shí)間戳的表轉(zhuǎn)成模型能吃的特征矩陣這篇的內(nèi)容基本可以照著抄。下文所有代碼都基于常規(guī)實(shí)踐整理環(huán)境是 Python 3.10 以上配 Pandas 2.x2.x 和 1.x 在部分時(shí)間序列行為上有差異我會(huì)在涉及的地方標(biāo)出來(lái)。1. 時(shí)間序列 DataFrame 的底層邏輯與設(shè)計(jì)取舍1.1 為什么時(shí)間列不能只當(dāng)普通列用很多人第一次處理時(shí)間數(shù)據(jù)的直覺(jué)是時(shí)間不就是一列字符串嗎排序、篩選、分組照做就行。這個(gè)做法在數(shù)據(jù)量小、需求單純的時(shí)候確實(shí)能跑一旦涉及按天匯總、取最近七天均值、補(bǔ)齊缺失的分鐘點(diǎn)這類操作就會(huì)立刻暴露出問(wèn)題。原因在于 Pandas 的時(shí)間序列能力并不是掛在列上的而是掛在索引上的——DatetimeIndex是一個(gè)帶有頻率語(yǔ)義的特殊索引對(duì)象它能回答下一個(gè)時(shí)間點(diǎn)是誰(shuí)這種普通列回答不了的問(wèn)題。你寫df.resample(D)Pandas 需要知道當(dāng)前索引的粒度、有沒(méi)有空洞、邊界怎么取這些都依賴索引是DatetimeIndex這個(gè)前提。把時(shí)間留在普通列里resample直接報(bào)錯(cuò)rolling的時(shí)間窗口寫法也用不了只能退化成按行數(shù)滾動(dòng)的近似方案精度和語(yǔ)義雙輸。我自己習(xí)慣的做法是原始數(shù)據(jù)讀進(jìn)來(lái)之后第一時(shí)間把時(shí)間列轉(zhuǎn)成datetime64[ns]然后set_index提到索引位置再sort_index()排一次序。這三步做完后面所有操作才有意義。排序這一步特別容易被跳過(guò)因?yàn)榇蠖鄶?shù)采集數(shù)據(jù)天然是順序?qū)懭氲目雌饋?lái)不用排。但只要中間有過(guò)補(bǔ)數(shù)、合并、多源拼接順序就可能被打亂而resample、rolling、shift全都是順序敏感的——順序錯(cuò)了結(jié)果不會(huì)報(bào)錯(cuò)只會(huì)靜默算錯(cuò)這是最難查的一類問(wèn)題。1.2 頻率語(yǔ)義freq 到底約束了什么freq這個(gè)參數(shù)經(jīng)常被誤解成數(shù)據(jù)實(shí)際的間隔其實(shí)它表達(dá)的是時(shí)間軸上的柵格規(guī)格。舉個(gè)例子一張表里記錄的是設(shè)備觸發(fā)事件的時(shí)間戳間隔完全不規(guī)則你可以給它設(shè)freqNone默認(rèn)也可以強(qiáng)行設(shè)成1min。設(shè)成1min之后Pandas 會(huì)認(rèn)為這條時(shí)間軸是按分鐘對(duì)齊的asfreq(1min)就會(huì)把不在整分鐘上的點(diǎn)丟掉或者對(duì)齊行為完全不同。所以freq是一個(gè)聲明不是一個(gè)檢測(cè)——你聲明了什么Pandas 就按什么處理聲明錯(cuò)了它也不會(huì)提醒你。這個(gè)設(shè)計(jì)取舍背后其實(shí)很合理自動(dòng)推斷頻率在數(shù)據(jù)有噪聲時(shí)極不可靠與其猜錯(cuò)不如讓使用者顯式聲明。代價(jià)就是我們必須自己確認(rèn)數(shù)據(jù)的真實(shí)粒度。我常用的檢查手段是把索引轉(zhuǎn)成 Series 求差分然后看眾數(shù)import pandas as pd gap df.index.to_series().diff().value_counts().head(5) print(gap)輸出的前幾名基本就反映了真實(shí)粒度。如果第一名是0 days 00:01:00且占比九成以上那freq1min就是安全的如果前幾名分散得很厲害說(shuō)明數(shù)據(jù)有大量缺失或者本身就是事件流這時(shí)候強(qiáng)行設(shè)freq只會(huì)給自己埋雷不如保持None用resample的時(shí)候再顯式指定目標(biāo)頻率。這個(gè)習(xí)慣幫我省過(guò)很多次排查時(shí)間——頻率聲明錯(cuò)誤導(dǎo)致的問(wèn)題往往要等到下游聚合結(jié)果明顯不合理才被發(fā)現(xiàn)。1.3 幾種常見(jiàn)方案的橫向?qū)Ρ葧r(shí)間序列的建模方式不止一種選錯(cuò)了后面全是返工。我把實(shí)際項(xiàng)目里用過(guò)的幾種方案列個(gè)表方便對(duì)照自己的場(chǎng)景挑。方案時(shí)間位置適用場(chǎng)景主要優(yōu)勢(shì)主要代價(jià)普通列 手動(dòng)分組普通列一次性報(bào)表、低頻分析無(wú)需學(xué)習(xí)成本無(wú)法用 resample/時(shí)間窗口 rollingDatetimeIndex resample索引固定頻率聚合、降采樣語(yǔ)義清晰、代碼短要求數(shù)據(jù)大致規(guī)整整數(shù)序號(hào) 時(shí)間列普通列不規(guī)則事件流、序列模型順序可控、便于對(duì)齊時(shí)間語(yǔ)義要自己維護(hù)PeriodIndex索引按月/季等周期統(tǒng)計(jì)周期邊界天然正確跨周期運(yùn)算受限第三行那個(gè)方案值得多說(shuō)一句。做序列模型比如基于滑動(dòng)窗口的回歸時(shí)我有時(shí)會(huì)故意不用DatetimeIndex而是保留一列整數(shù)seq當(dāng)索引時(shí)間作為普通列掛著。原因是模型訓(xùn)練階段更關(guān)心第 t 步到第 t-1 步的嚴(yán)格先后關(guān)系用整數(shù)序號(hào)可以避免時(shí)間空洞帶來(lái)的隱式錯(cuò)位特征對(duì)齊也更直觀。這不是說(shuō)DatetimeIndex不好而是說(shuō)工具要匹配階段——探索分析和特征工程用時(shí)間索引喂模型前轉(zhuǎn)成規(guī)整的二維矩陣。分清楚這個(gè)邊界很多到底該用哪種的糾結(jié)就沒(méi)了。2. 時(shí)間索引的建立與清洗臟數(shù)據(jù)的第一道閘2.1 to_datetime 的參數(shù)怎么選才不出事pd.to_datetime是入口函數(shù)也是最容易埋雷的地方。最常見(jiàn)的坑是不指定format讓它自己猜。Pandas 的推斷邏輯在混合格式下會(huì)退化成逐行解析速度慢幾十倍不說(shuō)遇到2024-03-01和01/03/2024混在一起的情況它可能把后者解析成 1 月 3 日——月日顛倒結(jié)果全錯(cuò)而且不報(bào)錯(cuò)。我在一個(gè)多源合并的表上吃過(guò)這個(gè)虧兩個(gè)數(shù)據(jù)源一個(gè)用 ISO 格式一個(gè)用斜杠格式合并后時(shí)間線整體錯(cuò)位了半個(gè)月查了兩天才定位到。所以我現(xiàn)在的固定寫法是能確定格式就顯式寫format格式不統(tǒng)一就先按來(lái)源拆開分別轉(zhuǎn)轉(zhuǎn)完再合并。import pandas as pd raw pd.DataFrame({ ts: [2024-03-01 08:00:00, 2024-03-01 08:01:00, 2024-03-01 08:02:00], value: [1.2, 1.5, 1.1], }) raw[ts] pd.to_datetime(raw[ts], format%Y-%m-%d %H:%M:%S, errorsraise)errors參數(shù)也值得展開說(shuō)。默認(rèn)是raise遇到解析不了的值直接拋異常改成coerce會(huì)把壞值變成NaT流程能繼續(xù)跑下去。我傾向于在探索階段用raise逼自己把臟數(shù)據(jù)全部找出來(lái)只有在已經(jīng)確認(rèn)臟值占比極低、且能接受丟棄的場(chǎng)景下才用coerce。見(jiàn)過(guò)不少代碼一上來(lái)就errorscoerce結(jié)果整列里有三成解析失敗全變成NaT后續(xù)dropna一刪樣本少了一大截還沒(méi)人察覺(jué)。另一類坑來(lái)自時(shí)間戳里的意外成分比如帶毫秒、帶時(shí)區(qū)偏移、帶中文時(shí)分秒。帶時(shí)區(qū)的字符串轉(zhuǎn)出來(lái)是datetime64[ns, tz]和本地時(shí)間的不同列運(yùn)算會(huì)報(bào)錯(cuò)需要統(tǒng)一tz_localize或tz_convert??鐣r(shí)區(qū)采集的場(chǎng)景里我的建議是一律先轉(zhuǎn)成 UTC 存著展示和業(yè)務(wù)判斷的時(shí)候再轉(zhuǎn)回本地偏移。這樣算差值、算間隔時(shí)永遠(yuǎn)不會(huì)因?yàn)榍袚Q偏移量而出現(xiàn)一小時(shí)誤差也避免了短暫偏移調(diào)整期內(nèi)的重復(fù)或缺失時(shí)間點(diǎn)——這類問(wèn)題在日志里極難發(fā)現(xiàn)表現(xiàn)就是某個(gè)小時(shí)的樣本數(shù)莫名其妙多出或少掉一批。2.2 set_index、排序與重復(fù)時(shí)間戳的處理轉(zhuǎn)完類型就可以提索引了。這里有個(gè)順序細(xì)節(jié)先set_index再sort_index和先sort_values再set_index結(jié)果一樣但后者更慢因?yàn)閟ort_values走的是普通列排序路徑。直接排序索引效率更高df raw.set_index(ts).sort_index()真實(shí)數(shù)據(jù)里重復(fù)時(shí)間戳非常普遍尤其是多設(shè)備、多渠道寫入同一張表的時(shí)候。重復(fù)索引會(huì)讓resample的某些聚合口徑變得微妙也會(huì)讓reindex報(bào)錯(cuò)。處理方法要看業(yè)務(wù)含義如果是重復(fù)上報(bào)保留最后一條更合理后寫覆蓋先寫如果是同一時(shí)刻不同維度那就不該合并應(yīng)該加維度列做成MultiIndex。dup_cnt df.index.duplicated().sum() if dup_cnt: df df[~df.index.duplicated(keeplast)]keep取first、last、False三種False是把所有重復(fù)項(xiàng)全刪掉慎用——它會(huì)連帶丟掉本來(lái)有效的那條。我一般先統(tǒng)計(jì)重復(fù)數(shù)量占比很低千分之幾才直接去重占比高的話說(shuō)明數(shù)據(jù)源有問(wèn)題得回頭查采集鏈路不能靠去重掩過(guò)去。還有一個(gè)容易被忽略的點(diǎn)MultiIndex下的時(shí)間序列操作。當(dāng)索引是(設(shè)備編號(hào), 時(shí)間)兩層時(shí)resample需要配合level參數(shù)指定在哪一層做df_multi df_multi.resample(1h, levelts).mean()level寫錯(cuò)或者不寫會(huì)得到完全不相關(guān)的結(jié)果或直接報(bào)錯(cuò)。這個(gè)用法在處理多序列數(shù)據(jù)多臺(tái)設(shè)備、多個(gè)門店時(shí)幾乎是必備的比循環(huán)groupby快一個(gè)量級(jí)我做過(guò)一次對(duì)比二十萬(wàn)行、200 個(gè)分組resample(level)比groupby().apply()快大約四倍。2.3 補(bǔ)齊時(shí)間軸reindex 的正確打開方式缺失時(shí)間點(diǎn)有兩種處理哲學(xué)一種是沒(méi)有就是沒(méi)有保持稀疏一種是補(bǔ)齊柵格再填值保持規(guī)整。分析階段我偏向后者因?yàn)檎麠l時(shí)間軸完整之后rolling、diff、shift的語(yǔ)義才準(zhǔn)確。補(bǔ)齊用reindex配合date_range比resample更可控因?yàn)樗粫?huì)做任何聚合full_idx pd.date_range( startdf.index.min(), enddf.index.max(), freq1min, namets, ) df_full df.reindex(full_idx)namets這個(gè)參數(shù)別漏不寫的話新索引沒(méi)名字后面reset_index會(huì)得到index這個(gè)列名接下游容易對(duì)不上。補(bǔ)出來(lái)的空位是NaN怎么填取決于數(shù)據(jù)性質(zhì)測(cè)量值可以前向填充ffill或線性插值計(jì)數(shù)類指標(biāo)應(yīng)該填 0比率類指標(biāo)最好別亂填寧可標(biāo)成缺失讓模型自己處理。我見(jiàn)過(guò)把缺失的比率直接ffill二十多個(gè)點(diǎn)的做法等于人為制造了一段平坦的假數(shù)據(jù)模型學(xué)到的就是這個(gè)假規(guī)律評(píng)估階段看起來(lái)還行實(shí)際預(yù)測(cè)時(shí)就會(huì)在同樣的位置給出可疑的平穩(wěn)輸出。注意ffill一定要設(shè)limit否則長(zhǎng)時(shí)間斷流會(huì)被一個(gè)值填滿整段看起來(lái)像真的連續(xù)觀測(cè)。我的習(xí)慣是limit3最多補(bǔ)三個(gè)點(diǎn)超出的保持NaN交給下游丟棄或單獨(dú)標(biāo)記。3. 重采樣與頻率轉(zhuǎn)換降采樣和升采樣的實(shí)操細(xì)節(jié)3.1 resample 的聚合字典與命名規(guī)范降采樣高頻轉(zhuǎn)低頻最常用的就是resample。基礎(chǔ)寫法很簡(jiǎn)單但真正用起來(lái)聚合方式往往不是一個(gè)函數(shù)能打發(fā)的——平均值、最大值、樣本數(shù)、缺失率一次要算好幾個(gè)指標(biāo)。這時(shí)候用命名聚合Pandas 0.25 之后支持比傳字典更清晰daily df.resample(D).agg( val_mean(value, mean), val_max(value, max), val_min(value, min), sample_cnt(value, count), missing_cnt(value, lambda s: s.isna().sum()), )(列名, 函數(shù))這種元組寫法直接產(chǎn)出扁平列名比agg({value: [mean,max]})出來(lái)的多級(jí)列名好處理得多。多級(jí)列名后續(xù)要droplevel或者手動(dòng)拼名字很容易出錯(cuò)尤其是下游要按列名取特征的時(shí)候。這一點(diǎn)我在給模型準(zhǔn)備特征表時(shí)深有體會(huì)——多級(jí)列名導(dǎo)成 CSV 再讀回來(lái)列名會(huì)變成(value, mean)這樣的元組字符串再想取某列就非常難受。所以從聚合那一步就把名字定干凈后面省事。3.2 窗口邊界closed 和 label 到底怎么影響結(jié)果resample里最容易算錯(cuò)的就是窗口邊界。默認(rèn)closedleft、labelleft意思是左閉右開、標(biāo)簽用左端點(diǎn)。舉個(gè)具體例子resample(5min)在08:00到08:05之間默認(rèn)口徑包含08:00:00到08:04:59的數(shù)據(jù)時(shí)間標(biāo)簽記為08:00。這個(gè)默認(rèn)值符合大多數(shù)人的直覺(jué)但當(dāng)你要和另一套系統(tǒng)對(duì)齊時(shí)就必須顯式確認(rèn)——對(duì)方是左閉右開還是左開右閉標(biāo)簽打在區(qū)間起點(diǎn)還是終點(diǎn)差一點(diǎn)就是整體偏移。left_closed df.resample(5min, closedleft, labelleft).mean() right_closed df.resample(5min, closedright, labelright).mean()這兩行結(jié)果看起來(lái)只差一個(gè)時(shí)間標(biāo)簽但實(shí)際數(shù)值分布完全不同因?yàn)槁涞矫總€(gè)桶里的原始點(diǎn)不一樣。我在一次對(duì)賬里就是因?yàn)檫@個(gè)差了一整個(gè)時(shí)間桶上游按右閉算、我按左閉算兩邊的日匯總在跨天時(shí)刻差了整整五分鐘的數(shù)據(jù)量級(jí)不大但對(duì)不上賬就是問(wèn)題。還有一個(gè)參數(shù)origin在按天、按周聚合時(shí)很關(guān)鍵。默認(rèn)originstart_day也就是按自然日切如果要按從某個(gè)時(shí)間點(diǎn)開始的 24 小時(shí)切就得傳originstart或具體時(shí)間戳。做班次統(tǒng)計(jì)、按批次時(shí)長(zhǎng)聚合的場(chǎng)景這個(gè)參數(shù)直接決定結(jié)果對(duì)不對(duì)。3.3 asfreq 和 resample 的差別別混著用這兩個(gè)函數(shù)經(jīng)常被當(dāng)成等價(jià)物其實(shí)定位完全不同。resample是分組聚合必須給一個(gè)聚合方式或者默認(rèn)取均值asfreq是重采樣到新柵格只做取樣不聚合多出來(lái)的點(diǎn)直接丟空出來(lái)的位置給NaN。做升采樣低頻轉(zhuǎn)高頻的時(shí)候我?guī)缀踔挥胊sfreq因?yàn)樯蓸颖緛?lái)的語(yǔ)義就是放大柵格、保留原值、填空缺用resample反而會(huì)引入不必要的聚合邏輯。up df.asfreq(30s) up[value] up[value].interpolate(methodtime)interpolate(methodtime)這個(gè)寫法值得單獨(dú)說(shuō)。普通的interpolate()是按位置線性插值methodtime是按時(shí)間距離加權(quán)。數(shù)據(jù)間隔不均勻的時(shí)候兩者差別明顯比如08:00值是 1、08:04值是 5中間08:01那個(gè)點(diǎn)按位置插值給 2等距假設(shè)按時(shí)間插值同樣給 2看起來(lái)一樣但如果中間點(diǎn)是08:03按位置給 3按時(shí)間給 4。顯然按時(shí)間更符合物理意義。不規(guī)則采集的數(shù)據(jù)做插值一定要加methodtime否則時(shí)間間隔的信息白丟了。3.4 一個(gè)完整的分層聚合實(shí)戰(zhàn)把上面這些串起來(lái)走一遍從分鐘級(jí)原始數(shù)據(jù)到日級(jí)特征表的完整流程。假設(shè)手頭是某設(shè)備采集項(xiàng)目導(dǎo)出的一份記錄字段是時(shí)間和讀數(shù)存在亂序、少量重復(fù)、部分時(shí)段斷流。import numpy as np import pandas as pd # 1) 解析與索引 df (raw .assign(tslambda d: pd.to_datetime(d[ts], format%Y-%m-%d %H:%M:%S)) .set_index(ts) .sort_index()) # 2) 去重 df df[~df.index.duplicated(keeplast)] # 3) 補(bǔ)齊分鐘柵格 full_idx pd.date_range(df.index.min(), df.index.max(), freq1min, namets) df df.reindex(full_idx) # 4) 短缺口前向填充長(zhǎng)缺口保留 NaN df[value] df[value].ffill(limit3) # 5) 日級(jí)聚合 daily df.resample(D).agg( val_mean(value, mean), val_std(value, std), val_max(value, max), sample_cnt(value, count), missing_cnt(value, lambda s: int(s.isna().sum())), ) # 6) 派生指標(biāo) daily[coverage] daily[sample_cnt] / 1440 daily[range] daily[val_max] - daily[val_min]這里第 6 步的coverage覆蓋率是我每次都會(huì)加的一列。它等于有效樣本數(shù)除以理論樣本數(shù)直接反映當(dāng)天數(shù)據(jù)質(zhì)量。覆蓋率低于某個(gè)閾值的日期后面建模時(shí)我會(huì)單獨(dú)標(biāo)記甚至剔除——用覆蓋率只有三成的天算出來(lái)的日均值噪聲比信號(hào)大得多混進(jìn)訓(xùn)練集只會(huì)拉低模型表現(xiàn)。這個(gè)指標(biāo)沒(méi)有任何技術(shù)難度但很多人的特征表里就是沒(méi)有導(dǎo)致他們永遠(yuǎn)不知道自己的日均值里有多少天是殘缺的。4. 滾動(dòng)窗口與滯后特征機(jī)器學(xué)習(xí)前置的核心武器4.1 rolling 的窗口類型與 min_periods 陷阱rolling有兩種窗口定義方式整數(shù)窗口按行數(shù)和時(shí)間窗口按時(shí)間跨度。按行數(shù)寫rolling(7)就是前 7 行前提是數(shù)據(jù)已經(jīng)按固定頻率補(bǔ)齊否則7 行可能對(duì)應(yīng)三天也可能對(duì)應(yīng)七小時(shí)語(yǔ)義完全不同。時(shí)間窗口寫rolling(7D)才是字面意思上的過(guò)去七天Pandas 內(nèi)部按時(shí)間戳判斷不看行數(shù)。做業(yè)務(wù)特征我強(qiáng)烈建議用時(shí)間窗口因?yàn)闃I(yè)務(wù)口徑通常按自然時(shí)間定義最近七天而不是最近七條記錄。df[ma_7d] df[value].rolling(7D, min_periods1).mean() df[std_7d] df[value].rolling(7D, min_periods3).std()min_periods這個(gè)參數(shù)是個(gè)大坑。默認(rèn)值等于窗口長(zhǎng)度意味著前六個(gè)點(diǎn)的滾動(dòng)均值全是NaN——數(shù)據(jù)頭部直接損失。設(shè)成min_periods1能讓頭部也算出值但早期的值是兩三個(gè)點(diǎn)算出來(lái)的方差極大和后面的值不在一個(gè)尺度上。我的處理方式是折中設(shè)min_periods為窗口長(zhǎng)度的一半左右早期那一段直接在下游截掉不用。這樣既不會(huì)因?yàn)轭^部NaN導(dǎo)致整行被dropna刪掉也避免了用兩三個(gè)點(diǎn)冒充七天均值。窗口的center參數(shù)也要留意默認(rèn)False表示窗口右端對(duì)齊當(dāng)前點(diǎn)只用歷史設(shè)True會(huì)讓窗口居中等于用了未來(lái)數(shù)據(jù)。做特征工程時(shí)center必須是False一旦設(shè)成True你的滾動(dòng)均值里就包含了未來(lái)信息離線指標(biāo)會(huì)虛高。這個(gè)參數(shù)在畫圖平滑曲線時(shí)很好用在建模特征里是禁用項(xiàng)兩種場(chǎng)景要分開記別寫混。4.2 expanding 與 ewm長(zhǎng)記憶特征的兩條路線rolling是固定窗口expanding是從開頭累積到當(dāng)前點(diǎn)窗口長(zhǎng)度一直變大。適合刻畫累計(jì)表現(xiàn)和歷史全量統(tǒng)計(jì)df[cum_mean] df[value].expanding(min_periods10).mean() df[cum_max] df[value].expanding(min_periods10).max()ewm是指數(shù)加權(quán)越近的點(diǎn)權(quán)重越大數(shù)學(xué)上等價(jià)于一個(gè)無(wú)限長(zhǎng)但權(quán)重衰減的窗口。做有明顯趨勢(shì)和突變的指標(biāo)比如流量、活躍度ewm通常比簡(jiǎn)單滾動(dòng)均值跟得更緊滯后更小。關(guān)鍵是span和alpha的換算關(guān)系alpha 2 / (span 1)span7對(duì)應(yīng)alpha0.25。我一般直接用span因?yàn)樗投嗌偬斓闹庇X(jué)對(duì)應(yīng)得更直接。df[ewm_7] df[value].ewm(span7, adjustFalse, min_periods3).mean()adjustFalse這個(gè)參數(shù)務(wù)必加上。默認(rèn)True時(shí)早期結(jié)果會(huì)做歸一化修正數(shù)值形態(tài)和False有明顯差別而且在數(shù)據(jù)頭部差異最大。做特征時(shí)統(tǒng)一用False因?yàn)樗倪f推形式更接近在線更新的語(yǔ)義也便于你在流式計(jì)算里復(fù)現(xiàn)同樣的值。如果訓(xùn)練時(shí)用了默認(rèn)值、推理時(shí)自己手寫遞推公式兩邊必然對(duì)不上這類線上線下不一致的問(wèn)題排查起來(lái)極其折磨。4.3 shift / diff / pct_change滯后特征的正確姿勢(shì)滯后特征是時(shí)間序列建模的主力三個(gè)函數(shù)各管一段shift平移、diff差分、pct_change變化率。用法簡(jiǎn)單但有兩個(gè)細(xì)節(jié)必須講清楚。第一個(gè)是shift的方向。shift(1)是把數(shù)據(jù)往下推一行也就是當(dāng)前行拿到的是上一行的值——這才是用歷史預(yù)測(cè)現(xiàn)在的正確構(gòu)造。shift(-1)是把未來(lái)值拉到當(dāng)前行這是標(biāo)簽構(gòu)造用的當(dāng)前特征對(duì)應(yīng)下期目標(biāo)絕對(duì)不能出現(xiàn)在特征里。我在代碼審查時(shí)見(jiàn)過(guò)把shift(-1)當(dāng)特征寫進(jìn)流水線的那基本等于作弊。第二個(gè)是pct_change的除零和負(fù)值。當(dāng)上期值為 0 時(shí)變化率是inf當(dāng)上期值為負(fù)時(shí)變化率的符號(hào)會(huì)翻過(guò)來(lái)語(yǔ)義完全變味。穩(wěn)妥做法是先檢查分母分布必要時(shí)改用diff或者加一個(gè)極小偏移df[diff_1] df[value].diff(1) df[lag_1] df[value].shift(1) safe_base df[value].shift(1).where(lambda s: s.abs() 1e-9) df[pct_1] (df[value] - safe_base) / safe_base用where把接近 0 的分母替換成NaN比直接replace(0, np.nan)更安全因?yàn)楹笳咧惶幚砭_的 0處理不了1e-12這種實(shí)際上等效于 0 的值。除出來(lái)的inf一旦進(jìn)了模型標(biāo)準(zhǔn)化會(huì)被直接帶偏整列特征失效。4.4 一套可直接套用的特征構(gòu)造流水線把上面的零件組裝成一個(gè)函數(shù)這是我在項(xiàng)目里反復(fù)用的骨架。核心原則只有一條所有用到歷史統(tǒng)計(jì)量的特征都要先在原始序列上shift(1)再計(jì)算否則當(dāng)前點(diǎn)的值會(huì)進(jìn)到自己的特征里。def build_ts_features(df, targetvalue, lags(1, 2, 3), windows(7, 14)): out df.copy() base out[target] # 滯后特征 for k in lags: out[flag_{k}] base.shift(k) # 滾動(dòng)統(tǒng)計(jì)先 shift 再 roll防泄漏 for w in windows: shifted base.shift(1) out[froll_mean_{w}] shifted.rolling(w, min_periodsmax(2, w // 2)).mean() out[froll_std_{w}] shifted.rolling(w, min_periodsmax(2, w // 2)).std() out[froll_max_{w}] shifted.rolling(w, min_periodsmax(2, w // 2)).max() # 差分與變化率 out[diff_1] base.diff(1) safe base.shift(1).where(lambda s: s.abs() 1e-9) out[pct_1] (base - safe) / safe # 時(shí)間特征 idx out.index out[hour] idx.hour out[weekday] idx.weekday out[is_weekend] (idx.weekday 5).astype(int) # 周期性編碼 out[hour_sin] np.sin(2 * np.pi * idx.hour / 24) out[hour_cos] np.cos(2 * np.pi * idx.hour / 24) return outshift(1)再rolling這個(gè)組合是整段代碼的靈魂。很多人寫特征時(shí)只對(duì)lag做了shift滾動(dòng)統(tǒng)計(jì)直接在原序列上算結(jié)果第 t 行的roll_mean_7里包含了第 t 行的真實(shí)值——模型只要盯著這個(gè)特征就能看到答案離線評(píng)估自然漂亮。這個(gè)錯(cuò)誤的隱蔽性在于單看代碼base.rolling(7).mean()完全合理根本不像有 bug。唯一的防法是養(yǎng)成條件反射——看到滾動(dòng)統(tǒng)計(jì)先問(wèn)一句這個(gè)窗口的右端點(diǎn)包不包含當(dāng)前時(shí)刻。時(shí)間特征的周期性編碼也值得說(shuō)一下。hour直接當(dāng)數(shù)值喂進(jìn)去模型會(huì)認(rèn)為 23 點(diǎn)和 0 點(diǎn)相差 23實(shí)際它們只差 1。用sin/cos做環(huán)形編碼把 24 小時(shí)映射到單位圓上距離關(guān)系就正確了。這個(gè)技巧對(duì)星期、月份同樣適用代價(jià)是多了幾列特征收益是模型不用再自己學(xué)這個(gè)映射關(guān)系小樣本下提升挺明顯。4.5 切分與防泄漏最后一道關(guān)卡特征表造好之后切訓(xùn)練集和測(cè)試集這一步必須按時(shí)間切不能隨機(jī)切。隨機(jī)切會(huì)讓測(cè)試集的時(shí)間點(diǎn)散落在訓(xùn)練集內(nèi)部模型實(shí)際是在插值而不是外推評(píng)估結(jié)果虛高。正確做法是按時(shí)間點(diǎn)截?cái)鄐plit_point int(len(feature_df) * 0.8) train feature_df.iloc[:split_point] test feature_df.iloc[split_point:]如果要做多折驗(yàn)證別用默認(rèn)的 K 折用時(shí)間序列專用的滾動(dòng)切分保證每折的驗(yàn)證集都在訓(xùn)練集之后。還有一個(gè)細(xì)節(jié)標(biāo)準(zhǔn)化、歸一化這類操作統(tǒng)計(jì)量只能用訓(xùn)練集算然后應(yīng)用到測(cè)試集。很多人在整張表上fit完再切均值方差里已經(jīng)包含了測(cè)試集信息這是典型的隱性泄漏。我習(xí)慣在流水線里把fit嚴(yán)格限定在訓(xùn)練切片上寧可多寫兩行也不圖省事。提示造完特征先用一句feature_df.isna().mean().sort_values(ascendingFalse).head()看一眼各列缺失率。頭部那幾行被shift挖空是正常的如果有中間段落的列缺失率異常高多半是原始時(shí)間軸沒(méi)補(bǔ)齊或者聚合口徑寫錯(cuò)了別急著往下走。5. 常見(jiàn)問(wèn)題與排查實(shí)錄5.1 問(wèn)題速查表下面這張表是我這幾年遇到頻率最高的一批問(wèn)題按癥狀—原因—處理三列整理遇到類似現(xiàn)象可以直接對(duì)號(hào)入座。癥狀大概率原因處理方式resample 報(bào)TypeError索引不是 DatetimeIndex檢查set_index后有沒(méi)有to_datetime聚合結(jié)果比原始數(shù)據(jù)少很多時(shí)間列解析失敗變NaT后被丟用errorsraise重跑定位壞值日匯總和手工核對(duì)差一截closed/label口徑不一致顯式指定兩個(gè)參數(shù)并與上游對(duì)齊rolling 頭部全NaNmin_periods默認(rèn)等于窗口長(zhǎng)度降到窗口長(zhǎng)度一半頭部截?cái)鄏olling 中間出現(xiàn)大段NaN時(shí)間軸有空洞時(shí)間窗口跨不過(guò)去先reindex補(bǔ)齊再滾動(dòng)pct_change出inf上期為 0分母where掉近零值模型離線指標(biāo)好得反常特征含未來(lái)信息排查shift(-1)、centerTrue、未 shift 的 rolling推理結(jié)果與訓(xùn)練分布不符ewm的adjust不一致統(tǒng)一adjustFalse合并后時(shí)間線整體偏移多源時(shí)間格式不統(tǒng)一分源解析后再合并統(tǒng)一時(shí)區(qū)5.2 幾個(gè)只有踩過(guò)才知道的細(xì)節(jié)第一個(gè)是resample對(duì)空桶的處理。如果某個(gè)時(shí)間窗口內(nèi)一個(gè)樣本都沒(méi)有聚合結(jié)果里這一行會(huì)存在但全是NaNcount會(huì)是 0。這在做特征時(shí)很危險(xiǎn)——如果你用count當(dāng)樣本量特征模型會(huì)看到一堆 0如果用mean當(dāng)特征那是一堆NaN。我的做法是單獨(dú)保留count列作為質(zhì)量指標(biāo)其他統(tǒng)計(jì)量遇到NaN直接保留不填讓下游根據(jù)count決定這條樣本用不用。一刀切fillna(0)是最糟的選擇會(huì)把沒(méi)有數(shù)據(jù)偽裝成數(shù)值為零。第二個(gè)是Grouper和groupby的配合。當(dāng)你要按周聚合、但周的定義不是自然周比如從周一開始算resample(W)可能不符合預(yù)期pd.Grouper(freqW-MON, closedleft, labelleft)更靈活。另外多列分組時(shí)Grouper可以只對(duì)時(shí)間列生效其他列正常分組weekly df.groupby([ pd.Grouper(freqW-MON, closedleft, labelleft), device_id, ]).agg(val_mean(value, mean))這個(gè)寫法比先resample再unstack干凈得多尤其是在分組維度多的時(shí)候。第三個(gè)是性能。resample().agg()在千萬(wàn)行級(jí)別會(huì)明顯變慢我實(shí)測(cè)過(guò)幾種寫法用命名聚合比傳字典快用內(nèi)置聚合函數(shù)比lambda快很多l(xiāng)ambda里的s.isna().sum()這類操作會(huì)退化成逐組 Python 調(diào)用。如果數(shù)據(jù)量大把自定義邏輯挪到聚合之后用向量化算能省掉一大半時(shí)間。另外一個(gè)思路是先降采樣到粗粒度再算比如日級(jí)特征不需要從分鐘級(jí)逐點(diǎn)算可以先聚合到小時(shí)再往上滾誤差在可接受范圍內(nèi)而速度提升明顯。第四個(gè)是時(shí)間序列的對(duì)齊問(wèn)題多序列合并時(shí)尤其明顯。兩張表的采樣時(shí)刻不完全一致df1 df2會(huì)產(chǎn)生大量NaN因?yàn)?Pandas 按索引精確對(duì)齊。這時(shí)候用resample先統(tǒng)一到同一柵格或者用merge_asof做近似匹配。merge_asof要求兩邊都按時(shí)間排序direction參數(shù)控制取最近的過(guò)去還是最近的未來(lái)做傳感器數(shù)據(jù)與事件打標(biāo)對(duì)齊時(shí)特別好用比reindexffill更精確因?yàn)樗梢赃x擇最近鄰而不是單純的前向。最后分享一個(gè)我自己養(yǎng)成的習(xí)慣任何一次時(shí)間序列處理開頭先打印三行——索引類型、索引是否單調(diào)遞增、時(shí)間跨度。這三行幾乎能提前暴露八成的潛在問(wèn)題。print(type(df.index).__name__) print(df.index.is_monotonic_increasing) print(df.index.min(), -, df.index.max(), | rows:, len(df))看起來(lái)是廢話但我確實(shí)靠這三行抓到過(guò)好幾次數(shù)據(jù)只導(dǎo)入了半天的低級(jí)錯(cuò)誤——文件截?cái)?、查詢條件漏了時(shí)間上限這類問(wèn)題不會(huì)報(bào)錯(cuò)只會(huì)讓你在幾小時(shí)后對(duì)著一堆反常的聚合結(jié)果發(fā)懵。花三秒鐘打印比花三小時(shí)排查劃算得多。