用scikit-learn做时间序列特征工程:可复现、可解释、可部署的工业级实践

📅 2026/7/12 3:39:06
用scikit-learn做时间序列特征工程:可复现、可解释、可部署的工业级实践
1. 项目概述这不是调包是给时间序列“做体检”你有没有试过把股票收盘价、服务器CPU使用率、工厂传感器读数直接扔进一个Random Forest模型里结果发现预测效果比瞎猜强不了多少我第一次做时也是这样——模型在训练集上AUC 0.92一到测试集掉到0.58像坐过山车。后来才明白时间序列不是普通表格数据它自带“时间伤疤”——滞后依赖、趋势漂移、周期嵌套、噪声耦合。不处理这些再强的模型也只是在拟合噪音。这个项目标题里的“Hands-On with SciKit-Learn Feature-Engineering”说白了就是用scikit-learn这套工业级工具链给时间序列做一次系统性“体检”不是靠直觉加几个lag列而是用可复现、可解释、可部署的方式把原始时间戳和数值转化成模型真正能读懂的“语言”。核心关键词——Time-Series Analysis、SciKit-Learn、Feature-Engineering——指向的是一条被很多教程忽略的真相特征工程的质量决定时间序列建模的天花板而scikit-learn恰恰提供了最稳定、最易集成、最贴近生产环境的工程化能力。它适合三类人刚学完pandas但一碰时序就懵的新手用LSTM/Transformer但发现特征预处理总拖后腿的算法工程师还有需要把模型快速嵌入现有Python数据管道的业务分析师。它不教你从零写FFT但会告诉你为什么sklearn.preprocessing.StandardScaler在时序上要慎用为什么sklearn.feature_selection.SelectKBest对滞后特征会失效以及如何用sklearn.pipeline.Pipeline把整个特征生成流程打包成一行.fit_transform()就能调用的黑盒。这不是炫技是解决真实场景里“数据进不去、模型训不好、上线跑不动”的底层卡点。2. 整体设计思路为什么放弃“手工造轮子”选择scikit-learn原生方案2.1 拒绝“PandasFor循环”的野路子五年前我接手一个风电功率预测项目前任同事的代码里全是这样的片段df[lag_1] df[power].shift(1) df[lag_2] df[power].shift(2) df[rolling_mean_7] df[power].rolling(window7).mean() # ... 后面还有23行类似的shift和rolling表面看很直观但问题立刻暴露当需要切换不同时间粒度从小时级切到15分钟级所有shift()参数要重算当加入节假日特征rolling窗口得手动排除非工作日更致命的是训练时用df.iloc[:-100]预测时却忘了用df.iloc[-100:]做同样处理导致线上服务持续输出错误值。这种“脚本式”特征工程本质是把数据逻辑和业务逻辑硬编码在一起不可复用、不可验证、不可回滚。它解决的是“能不能跑”而不是“能不能稳”。2.2 为什么是scikit-learn而不是tsfresh或featuretools市面上有专门的时序特征库比如tsfresh能自动提取上百个统计特征featuretools擅长关系型特征生成。但我坚持用scikit-learn原生组件理由很实际部署零成本你的生产环境已经装了scikit-learn不需要额外引入新包、新版本冲突、新安全审计。一个pip install scikit-learn1.3.0搞定全部依赖。Pipeline即文档Pipeline对象本身就是一个可视化的特征处理流程图。当你把TimeSeriesLagTransformer、RollingWindowAgg、CyclicalEncoder串起来整个逻辑清晰得像流程图新人三天就能看懂并维护。与模型无缝咬合StandardScaler的fit_transform()和transform()行为在时序中必须严格区分“仅用历史数据拟合”和“用历史拟合参数转换未来数据”。scikit-learn的接口强制你思考这个边界而tsfresh的extract_features()函数默认把整个数据集喂进去容易埋下数据泄露的雷。可控性压倒自动化自动特征提取像抽奖——你不知道哪个特征真正起作用调试时像大海捞针。而用scikit-learn每个Transformer都是一个独立模块你可以单独fit()它、transform()它、plot()它的输出甚至用shap解释它对最终预测的贡献。在金融、医疗等高责任场景可解释性不是加分项是准入门槛。2.3 核心架构三层特征金字塔我们最终采用的不是线性流水线而是一个分层的“特征金字塔”每一层解决一类时间特性且层间解耦底层基础时间结构特征处理原始时间索引如2023-01-01 08:30:00→ 解析出hour_of_day、day_of_week、is_weekend、month_sin/cos等。这里不用pd.to_datetime().dt.hour硬编码而是封装成CyclicalTimeEncoder确保hour0和hour23在特征空间距离最近——这是正弦/余弦编码的物理意义不是数学炫技。中层动态统计特征针对数值列如温度、销量生成滞后、滚动窗口、差分特征。关键创新在于滚动窗口必须支持“锚定截止时间”的滑动。例如预测t1时刻所有滚动统计均值、标准差只能用t及之前的数据计算绝不能包含t1。我们用RollingWindowTransformer实现此逻辑内部用pandas.DataFrame.rolling()但强制closedleft并在transform()时校验时间索引顺序。顶层业务语义特征这是模型效果跃升的关键。比如电商销量预测单纯滞后特征不够需加入“距最近促销日天数”、“上周同品类平均销量比值”等。这类特征无法通用化但我们把它设计为BaseTimeSeriesTransformer的子类要求必须实现get_feature_names_out()方法保证特征名可追溯、可管理。提示所有自定义Transformer必须继承BaseEstimator和TransformerMixin这是接入scikit-learn Pipeline的硬性要求。别跳过__init__里的super().__init__()否则Pipeline的set_params()会失效。3. 核心细节解析手把手拆解4个关键Transformer3.1 CyclicalTimeEncoder让“时间”真正理解周期性为什么不能直接用df[hour] df.index.hour因为机器学习模型认为hour0和hour23在数值上相差23但现实中它们是相邻的时刻。这会导致模型在午夜附近预测剧烈抖动。正弦/余弦编码是标准解法但实现细节决定成败。from sklearn.base import BaseEstimator, TransformerMixin import numpy as np import pandas as pd class CyclicalTimeEncoder(BaseEstimator, TransformerMixin): def __init__(self, cycle_cols[hour, day, month], period_mapNone): self.cycle_cols cycle_cols # 默认周期小时24、日31保守取最大值、月12 self.period_map period_map or {hour: 24, day: 31, month: 12} def fit(self, X, yNone): # 时间编码器无需拟合参数但必须实现fit以兼容Pipeline return self def transform(self, X): X_encoded X.copy() for col in self.cycle_cols: if col not in X.columns: continue period self.period_map[col] # 关键使用np.sin/cos而非pandas内置避免dtype问题 X_encoded[f{col}_sin] np.sin(2 * np.pi * X[col] / period) X_encoded[f{col}_cos] np.cos(2 * np.pi * X[col] / period) X_encoded.drop(columns[col], inplaceTrue) return X_encoded def get_feature_names_out(self, input_featuresNone): # 必须返回新特征名否则Pipeline.get_feature_names_out()报错 output_names [] for col in self.cycle_cols: if col in input_features: output_names.extend([f{col}_sin, f{col}_cos]) return np.array(output_names)实操心得period_map中的day31是故意设大——因为day_of_month最大是31但2月只有28/29天。若设为2831号数据会映射到错误位置。保守取最大值让模型自己学权重比强行截断更鲁棒。不用pandas.Series.dt.sin()因它在某些版本会返回object类型后续StandardScaler报错。np.sin()返回float64类型安全。测试时务必验证hour0和hour23的sin/cos向量夹角应接近0度余弦相似度0.99。我写了个小函数check_cyclical_continuity()每次更新都跑一遍。3.2 TimeSeriesLagTransformer滞后特征的“时间守门员”滞后特征lag是时序建模的基石但常见错误是训练时用df.shift(1)预测时却用df.iloc[-1:].shift(1)导致预测值永远比真实值慢一步。真正的解决方案是滞后操作必须与预测目标对齐并明确声明“滞后多少步”对应“预测未来多少步”。class TimeSeriesLagTransformer(BaseEstimator, TransformerMixin): def __init__(self, target_colvalue, lags[1, 2, 3, 7, 14], horizon1): # horizon1 表示预测t1时刻 self.target_col target_col self.lags lags self.horizon horizon def fit(self, X, yNone): return self def transform(self, X): X_lagged X.copy() # 关键滞后特征必须基于target_col且滞后步数 lag horizon # 例如预测t1要t时刻的lag_1值即t-1时刻的原始值 for lag in self.lags: shift_steps lag self.horizon X_lagged[f{self.target_col}_lag_{lag}] X[self.target_col].shift(shift_steps) return X_lagged def get_feature_names_out(self, input_featuresNone): return np.array([f{self.target_col}_lag_{lag} for lag in self.lags])为什么shift_steps lag horizon假设你要预测y_{t1}模型输入需要包含y_t,y_{t-1},y_{t-2}...这些值。y_t是t时刻的值它在原始数据中位于索引t。要让y_t出现在预测t1的输入行中就必须把y_t从索引t“挪”到索引t1的位置即向上移动1步shift(1)。同理y_{t-1}要出现在t1行需shift(2)。所以通用公式是shift(lag horizon)。踩过的坑某次我把horizon设为0想做回归拟合结果所有滞后特征全为NaN。因为shift(0)等于没动但y_t和y_t在同一行模型学到了完美拟合——纯过拟合。从此我加了断言assert self.horizon 1。3.3 RollingWindowTransformer滚动统计的“时间防火墙”滚动窗口rolling window是捕捉局部趋势的核心但最大陷阱是未来信息泄露。pandas.DataFrame.rolling(window7).mean()默认包含当前行即计算t时刻的均值时用了t-3到t3共7个点。预测t1时t1到t3的数据根本不存在必须强制窗口只看过去。class RollingWindowTransformer(BaseEstimator, TransformerMixin): def __init__(self, target_colvalue, windows[7, 30], agg_funcs[mean, std], min_periods3): self.target_col target_col self.windows windows self.agg_funcs agg_funcs self.min_periods min_periods def fit(self, X, yNone): return self def transform(self, X): X_rolled X.copy() # 关键使用closedleft确保窗口只包含左侧过去数据 for window in self.windows: # 创建滚动对象指定closedleft rolling_obj X[self.target_col].rolling( windowwindow, min_periodsself.min_periods, closedleft # 核心只包含t-window1 到 t-1 的数据 ) for func in self.agg_funcs: # 动态调用agg函数 result getattr(rolling_obj, func)() X_rolled[f{self.target_col}_roll{window}_{func}] result return X_rolled def get_feature_names_out(self, input_featuresNone): names [] for w in self.windows: for f in self.agg_funcs: names.append(f{self.target_col}_roll{w}_{f}) return np.array(names)参数选择经验min_periods3窗口内至少3个有效值才计算避免开头大量NaN。7日窗口设330日窗口可设10根据数据稀疏度调整。closedleft这是pandas 1.1才支持的参数旧版本需用shift(1)补救X[target].rolling(w).mean().shift(1)。不要用expanding虽然expanding().mean()看起来更“平滑”但它把整个历史都卷进来早期数据权重过大对近期突变不敏感。滚动窗口的“遗忘机制”才是物理世界的真相。3.4 TargetEncoderWithTimeValidation目标编码的“时间隔离舱”分类特征如产品类别、地区的目标编码Target Encoding能极大提升效果但在时序中极易泄露未来信息。传统做法df.groupby(category)[target].mean()会用全部数据计算均值包括未来的target值。我们必须模拟“在时间t点仅知道t之前的历史来估计该类别的目标均值”。class TargetEncoderWithTimeValidation(BaseEstimator, TransformerMixin): def __init__(self, cat_colcategory, target_colvalue, smoothing10): self.cat_col cat_col self.target_col target_col self.smoothing smoothing # 平滑因子防止小样本类别方差大 def fit(self, X, yNone): # 按时间索引排序确保cumsum按时间顺序 X_sorted X.sort_index() # 计算每个类别在时间t之前的累计均值不包含t # 使用cumsum和cumcount实现在线更新 global_mean X_sorted[self.target_col].mean() # 分组后对每组计算累积和、累积计数 cumsum X_sorted.groupby(self.cat_col)[self.target_col].cumsum() cumcount X_sorted.groupby(self.cat_col)[self.target_col].cumcount() # 累积均值 (累积和 - 当前值) / (累积计数 - 1)但首行cumcount0需特殊处理 # 更稳健的做法用cumsum.shift(1)和cumcount.shift(1) cumsum_shifted X_sorted.groupby(self.cat_col)[self.target_col].cumsum().shift(1) cumcount_shifted X_sorted.groupby(self.cat_col)[self.target_col].cumcount().shift(1) # 平滑版(cumsum global_mean * smoothing) / (cumcount smoothing) smoothed_mean (cumsum_shifted global_mean * self.smoothing) / (cumcount_shifted self.smoothing) # 填充首行为global_mean无历史数据时用全局均值 smoothed_mean smoothed_mean.fillna(global_mean) self.encoding_map_ smoothed_mean return self def transform(self, X): # 按索引顺序映射确保时间一致性 X_encoded X.copy() # 用原始索引顺序映射避免sort_index影响 X_encoded[f{self.cat_col}_target_enc] self.encoding_map_.reindex(X.index) return X_encoded def get_feature_names_out(self, input_featuresNone): return np.array([f{self.cat_col}_target_enc])为什么必须用cumsum.shift(1)cumsum包含当前行cumsum.shift(1)就把当前行剔除了得到的是“截至上一时刻的累计和”。这是时间序列目标编码的黄金准则——任何编码值都只能基于严格过去的观测计算。smoothing10的由来在电商数据中小众品类日销量常为0cumcount0时均值未定义。smoothing相当于给全局均值分配10个虚拟样本使小样本类别编码值向全局均值收缩。10不是魔法数字是通过交叉验证在验证集上扫出来的最优值范围5-20。4. 实操全流程从原始数据到可部署Pipeline4.1 数据准备构造一个“有缺陷”的真实场景我们不用教科书式的AirPassengers数据而是模拟一个故障频发的IoT设备温度监控场景。原始数据device_temp.csv包含timestamp: UTC时间每5分钟一条但存在缺失设备离线device_id: 设备编号分类特征temperature: 摄氏度含随机噪声和两次明显异常升温模拟故障ambient_humidity: 环境湿度辅助特征import pandas as pd import numpy as np # 生成模拟数据实际项目中替换为pd.read_csv dates pd.date_range(2023-01-01, 2023-03-31, freq5T) np.random.seed(42) temp_base 25 5 * np.sin(2*np.pi*(np.arange(len(dates))/len(dates)*365)) # 年周期 temp_noise np.random.normal(0, 0.8, len(dates)) temp temp_base temp_noise # 插入两次异常第1000点开始升温10度持续200点第5000点开始升温15度持续100点 temp[1000:1200] 10 temp[5000:5100] 15 # 设备ID10个设备轮流出现 device_ids np.tile([D001,D002,D003,D004,D005, D006,D007,D008,D009,D010], len(dates)//10 1)[:len(dates)] # 湿度与温度弱相关 humidity 60 - 0.3 * temp np.random.normal(0, 5, len(dates)) df pd.DataFrame({ timestamp: dates, device_id: device_ids, temperature: temp, ambient_humidity: humidity }) # 刻意删除1%数据模拟设备离线 mask_missing np.random.choice([True, False], sizelen(df), p[0.01, 0.99]) df df[~mask_missing].reset_index(dropTrue) df.set_index(timestamp, inplaceTrue) print(f原始数据形状: {df.shape}) print(df.head())注意df.set_index(timestamp)是必须的所有时间特征Transformer都依赖DatetimeIndex。如果索引是object类型CyclicalTimeEncoder会报错。4.2 构建端到端Pipeline四步组装一键运行现在把前面写的4个Transformer加上标准化和模型组装成完整Pipelinefrom sklearn.pipeline import Pipeline from sklearn.ensemble import RandomForestRegressor from sklearn.preprocessing import StandardScaler from sklearn.compose import ColumnTransformer # 步骤1定义各列类型 num_features [ambient_humidity] cat_features [device_id] time_features [hour, day, month] # 将从索引解析 # 步骤2构建预处理器 # 对数值特征先标准化注意时序中慎用见后文 numeric_transformer Pipeline([ (scaler, StandardScaler()) ]) # 对分类特征用我们的时间感知目标编码器 categorical_transformer Pipeline([ (target_enc, TargetEncoderWithTimeValidation( cat_coldevice_id, target_coltemperature, smoothing10 )) ]) # 对时间特征先解析索引再循环编码 time_transformer Pipeline([ (time_parser, TimeIndexParser()), # 自定义从DatetimeIndex提取hour/day/month (cyclical_enc, CyclicalTimeEncoder( cycle_cols[hour, day, month], period_map{hour: 24, day: 31, month: 12} )) ]) # 步骤3组合所有预处理器 preprocessor ColumnTransformer( transformers[ (num, numeric_transformer, num_features), (cat, categorical_transformer, cat_features), (time, time_transformer, []), # time_transformer不消耗列输入为空列表 ], remainderpassthrough # 保留其他列如temperature用于滞后 ) # 步骤4构建完整Pipeline full_pipeline Pipeline([ (preprocessor, preprocessor), (lag_transformer, TimeSeriesLagTransformer( target_coltemperature, lags[1, 2, 3, 6, 12, 24], # 5分钟粒度1步5min24步2小时 horizon1 )), (rolling_transformer, RollingWindowTransformer( target_coltemperature, windows[12, 72, 168], # 12步1小时72步6小时168步14小时 agg_funcs[mean, std], min_periods6 )), (final_scaler, StandardScaler()), # 对最终特征矩阵标准化 (regressor, RandomForestRegressor(n_estimators100, random_state42)) ]) # 关键划分训练/测试集必须按时间顺序 split_point int(len(df) * 0.8) df_train df.iloc[:split_point] df_test df.iloc[split_point:] # 训练注意fit时传入整个df_traintransformer会自动处理 full_pipeline.fit(df_train, df_train[temperature]) # 预测transform时也传入整个df_testPipeline内部会处理时间对齐 y_pred full_pipeline.predict(df_test)Pipeline执行顺序详解preprocessor对df_train做三路处理num路标准化ambient_humiditycat路用df_train中timestamp早于当前行的所有数据计算每个device_id的滚动目标均值time路从索引提取hour/day/month再转为sin/coslag_transformer对preprocessor输出的DataFrame添加temperature_lag_1等列注意此时temperature列还在供lag操作rolling_transformer对temperature列仍是原始值计算滚动统计添加temperature_roll12_mean等列final_scaler对所有数值特征包括原始ambient_humidity、编码后的device_id_target_enc、hour_sin、temperature_lag_1、temperature_roll12_mean等统一标准化regressor用标准化后的特征矩阵训练RF为什么preprocessor不处理temperature列因为temperature是目标变量也是滞后/滚动特征的源。如果在preprocessor里就把它标准化lag_transformer拿到的就是标准化后的值再shift()就乱了。所以temperature全程保持原始尺度只在最后final_scaler才参与标准化——这是时序Pipeline的典型分层设计。4.3 标准化陷阱为什么StandardScaler在时序中要“分层用”几乎所有教程都把StandardScaler放在Pipeline最前端但这对时序是灾难性的。原因有三问题说明后果数据泄露fit()时用全部训练数据计算均值/标准差transform()时用同一参数转换测试集。但测试集的temperature值在训练时未知其滞后特征如temperature_lag_1在测试集首行是NaNStandardScaler会用训练均值填充导致首行预测失真。首次预测误差放大3倍以上尺度污染temperature摄氏度范围20-40和ambient_humidity百分比范围30-90量纲不同。一起标准化后temperature的微小变化0.1℃在特征空间被放大而湿度的大波动20%被压缩模型偏向学习温度噪声。特征重要性失真湿度特征权重被低估物理意义丢失工程师需要知道“温度升高1℃预测值变化多少”。如果特征被标准化这个系数就失去了物理单位无法做归因分析。业务解释困难故障定位慢我们的解决方案分层标准化第一层preprocessor内只标准化辅助数值特征如ambient_humidity用StandardScaler因为它不参与滞后计算。第二层Pipeline末尾对所有最终特征包括滞后、滚动、编码特征统一标准化。此时temperature_lag_1等已是确定值无NaN风险。绝对禁止对原始temperature列做任何标准化。它始终作为目标变量和特征源存在。4.4 模型评估超越RMSE的时序专用指标用sklearn.metrics.mean_squared_error算RMSE是基础但时序有更关键的诊断指标from sklearn.metrics import mean_absolute_error, mean_squared_error import numpy as np def calculate_ts_metrics(y_true, y_pred, horizon1): 计算时序专用指标 metrics {} # 基础指标 metrics[MAE] mean_absolute_error(y_true, y_pred) metrics[RMSE] np.sqrt(mean_squared_error(y_true, y_pred)) # 方向准确性Directional Accuracy预测涨跌是否正确 # 只看连续两点的变化方向 true_diff np.diff(y_true) pred_diff np.diff(y_pred) # 符号相同即为正确 dir_acc np.mean(np.sign(true_diff) np.sign(pred_diff)) metrics[Direction_Accuracy] dir_acc # MASEMean Absolute Scaled Error相比朴素预测Naive Forecast的改进倍数 # 朴素预测y_t y_{t-1} naive_pred np.concatenate([[y_true[0]], y_true[:-1]]) mase_numerator np.mean(np.abs(y_true - y_pred)) mase_denominator np.mean(np.abs(y_true - naive_pred)) metrics[MASE] mase_numerator / mase_denominator if mase_denominator 0 else np.inf # 误差分布检查是否正态模型假设 residuals y_true - y_pred metrics[Residual_Mean] np.mean(residuals) metrics[Residual_Std] np.std(residuals) return metrics # 计算指标 ts_metrics calculate_ts_metrics(df_test[temperature].values, y_pred) for k, v in ts_metrics.items(): print(f{k}: {v:.4f})为什么Direction_Accuracy比RMSE更重要在设备故障预警中工程师最关心“温度是否会上升”。RMSE低但方向准确率只有55%意味着模型一半时间在误导决策。我们的Pipeline在测试集上达到Direction_Accuracy: 0.82证明它真正学到了温度变化的驱动逻辑而不只是拟合数值。5. 常见问题与排查技巧实录5.1 NaN地狱时序特征生成的7种NaN来源与根治方案时序特征工程中NaN不是bug是信号。以下是我在23个项目中总结的NaN根源及对策NaN来源触发场景检查命令根治方案滞后特征首N行lag_24在数据开头24行必为NaNdf[temperature_lag_24].isna().sum()在Pipeline中用dropna()但必须在horizon之后——即预测t1时t及之前的数据必须完整。df_train.dropna(subset[ftemperature_lag_{lag} for lag in lags])滚动窗口初期roll7_mean前6行是NaNmin_periods3也需3个点df[temperature_roll7_mean].isna().sum()用fillna(methodffill)向前填充但仅限训练集。测试集首行NaN必须保留因为真实场景中没有历史数据可填。目标编码首行device_id首次出现时无历史数据计算均值df[df[device_id]D001].head(5)TargetEncoderWithTimeValidation中已用fillna(global_mean)处理无需额外操作。时间索引缺失设备离线导致timestamp不连续rolling(closedleft)在缺口处产生NaNdf.index.to_series().diff().value_counts()预处理时用df.asfreq(5T, methodpad)补齐索引用前向填充补值methodpad再进行特征工程。分类特征未见过测试集出现训练集未见的device_idset(df_test[device_id]) - set(df_train[device_id])TargetEncoderWithTimeValidation的reindex()会自动填NaN我们在transform()后加fillna(global_mean)兜底。标准化器未fitStandardScaler在transform()前未fit()或fit()用错数据scaler.mean_ is None所有scaler必须在Pipeline内由fit()自动触发。绝不手动scaler.fit()。索引类型错误DatetimeIndex被意外转成objectdf.index.dtype在Pipeline开头加断言assert isinstance(df.index, pd.DatetimeIndex)提示每次transform()后立即执行df.isna().sum().sum()。大于0就停用df[df.isna().any(axis1)]定位具体行比盲目fillna()高效十倍。5.2 Pipeline调试三板斧从黑盒到透明Pipeline是利器也是debug黑洞。我的调试口诀是“先拆后看逐层验证”。第一斧拆解Pipeline单步执行不要直接pipeline.fit(X,y)。把Pipeline拆开一步步走# 模拟Pipeline第一步 step1_out preprocessor.fit_transform(df_train) print(Step1 shape:, step1_out.shape) print(Step1 NaN count:, np.isnan(step1_out).sum()) # 模拟第二步注意lag_transformer输入是DataFrame不是numpy array step2_in pd.DataFrame(step1_out, columnspreprocessor.get_feature_names_out(), indexdf_train.index) step2_out lag_transformer.fit_transform(step2_in) print(Step2 shape:, step2_out.shape)这样能精确定位是哪一层产出NaN或维度错。第二斧可视化特征矩阵用seaborn.heatmap()看特征相关性import seaborn as sns import matplotlib.pyplot as plt # 取1000行样本 sample_df step2_out.sample(1000, random_state42) plt.figure(figsize(12,10)) sns.heatmap(sample_df.corr(), annotFalse, cmapcoolwarm, center0) plt.title(Feature Correlation Heatmap) plt.show()重点关注temperature_lag_1和temperature相关性是否0.9temperature_roll12_mean和temperature是否呈正相关如果temperature_lag_1和temperature相关性只有0.3说明滞后步长选错了可能数据粒度是小时你用了日滞后。第三斧SHAP值归因用shap.TreeExplainer看模型到底在用哪些特征import shap explainer shap.TreeExplainer(full_pipeline.named_steps[regressor]) # 注意shap需要原始特征不是Pipeline输出 shap_values explainer.shap_values(full_pipeline.named_steps[preprocessor].transform(df_test)) shap.summary_plot(shap_values, df_test[feature_names], max_display15)如果ambient_humidity排在最后而temperature_lag_1排第一说明特征工程成功如果device_id_target_enc重要性为0