机器学习新手实战:48小时跑通可解释、可交付的真实数据模型

机器学习新手实战:48小时跑通可解释、可交付的真实数据模型 1. 这不是“速成课”而是我带过37个零基础学员后撕掉的12张废稿纸你点开这篇大概率正卡在某个地方下载完Titanic数据集却不知道从哪列开始删空值跑通了sklearn的LogisticRegression但测试集准确率只有62%比瞎猜强不了多少看教程说“先做EDA”结果用pandas.describe()扫了一眼就以为自己懂了分布——直到模型在真实业务数据上崩得连报错都懒得给你提示。别急这太正常了。我带的第一批学员里有位做电商运营的姑娘用Kaggle的House Prices数据练了三周第四周把模型部署到公司内部BI系统时发现训练集R²0.89线上预测误差却大到能买两杯奶茶——她没写错代码只是漏掉了“时间序列泄漏”这个坑。这篇不讲“什么是梯度下降”不画损失函数曲线图也不塞一堆数学公式让你怀疑人生。它只解决一件事当你手握一份真实的CSV文件比如客户投诉记录、门店销售流水、设备传感器日志如何在48小时内跑出一个能解释、能复现、能扛住业务同事灵魂拷问的最小可行模型。核心关键词全在这里机器学习入门、真实数据集、可落地实践、避坑指南、新手实操。适合刚学完Python基础、连pip install都手抖的新手也适合被老板甩来一句“试试用AI分析下上季度退货率”的职场人——你不需要成为算法专家但必须让模型结果经得起追问“为什么这个客户被判定为高风险”“这个预测值是怎么算出来的”“如果明天数据格式变了模型会不会直接罢工”接下来的内容每一条都来自我陪学员debug到凌晨两点的真实战场所有代码、参数、检查清单你复制粘贴就能跑通。2. 为什么这10条“不废话”建议专治真实数据带来的幻灭感2.1 真实数据不是教科书里的理想国而是布满地雷的沼泽地教科书数据集像精心修剪的盆景Iris的花瓣长度永远在4.3-7.9cm之间MNIST的手写数字像素值非0即255缺失值不存在的。但真实世界的数据集比如你从公司CRM导出的客户表可能同时存在混合型缺失age列是空字符串last_purchase_date列是NULLregion列是Unknown它根本不是缺失而是业务方填错了默认值隐式类型污染order_amount列本该是数字但某条记录里混进了¥1,299.00带货币符号和千分位逗号时间陷阱created_at列是字符串2023-05-12T14:30:00Z而updated_at却是12/05/2023 14:30DD/MM/YYYY格式更糟的是部分记录的updated_at比created_at早3年——这是数据同步故障不是时间旅行。提示我见过最离谱的真实数据集是某家连锁药店的销售日志product_id列里混着12位数字编码、8位字母编号、还有3条记录写着已下架-请勿处理。如果你直接pd.read_csv()然后df.dtypes它会告诉你product_id是object类型然后默默把所有数字当字符串处理。模型训练时这些字符串会被one-hot编码成几百个无意义的特征最后准确率跌到45%——比随机猜还差。所以这10条建议的第一条不是“选什么算法”而是强制你花30分钟用df.info()、df.nunique()、df.sample(10)三板斧把数据表当成一份需要签字确认的合同来审阅。这不是浪费时间是给后续所有步骤买保险。我要求学员在Jupyter Notebook第一块代码单元格必须贴上这三行并手写注释“customer_id有2个重复值需查重discount_rate最大值150%明显异常payment_method含‘Cash on Delivery’和‘COD’两种写法需统一”。这份“数据体检报告”比任何模型架构图都重要。2.2 为什么拒绝“端到端自动化流程”而坚持手动拆解每一步很多新手教程鼓吹“一行代码搞定特征工程”比如from sklearn.preprocessing import StandardScaler; scaler.fit_transform(X)。听起来很美但真实场景中你很快会撞墙某天业务方新增一列is_vip_member布尔值你重新运行StandardScaler它会把True/False转成1.0/0.0再标准化——但VIP状态是分类变量不该标准化另一次你用LabelEncoder处理city列结果北京0、上海1、广州2……模型会误以为“广州 上海 北京”强行引入不存在的序数关系最致命的是当模型上线后新来的数据里突然出现city海口训练集里没有LabelEncoder直接报错ValueError: y contains previously unseen labels整个服务挂掉。注意StandardScaler和LabelEncoder这类工具本质是“训练时学习参数预测时复用参数”。但新手常犯的错误是把它们用在整个数据集上包括测试集或者在每次预测前重新fit。前者导致数据泄露测试集信息污染训练过程后者导致线上服务无法稳定运行。因此这10条建议里所有预处理步骤都要求你手动拆解、显式保存参数。比如处理数值型缺失值# 错误示范对整个df操作且未保存均值 df[age].fillna(df[age].mean(), inplaceTrue) # 正确做法只对训练集计算保存参数测试集复用 train_mean_age X_train[age].mean() X_train[age].fillna(train_mean_age, inplaceTrue) X_test[age].fillna(train_mean_age, inplaceTrue) # 复用训练集均值这个看似笨拙的手动操作背后是工业级模型的生存法则训练与推理必须使用完全一致的预处理逻辑和参数。我带过的学员中有7人因忽略这点在模型上线首日遭遇严重事故——不是预测不准而是服务直接崩溃。所以第二条建议的核心是建立“预处理流水线”的肌肉记忆先定义规则如“数值列用均值填充分类列用众数填充”再在训练集上执行并存档参数最后在测试集/新数据上严格复用。这套流程比选择XGBoost还是LightGBM重要十倍。2.3 为什么“模型解释性”要放在“调参”之前而不是之后新手常陷入一个误区先拼命调参把准确率刷到95%再回头想“这模型到底怎么判断的”。但真实业务中没人关心你的AUC多高他们只问“为什么张三被标记为高风险客户”“这个预测值是基于他哪几条行为数据”举个血泪案例某银行风控团队用LSTM预测信用卡欺诈测试集准确率92%。上线后一位客户投诉“我的交易被拒但我是正常消费”。风控专员打开模型发现输出概率0.98但完全无法指出是哪条输入特征如“近1小时交易频次”或“异地登录”触发了高风险判定。最终他们不得不人工复核每笔被拒交易效率暴跌项目差点被砍。所以第三条建议强制你在训练完第一个模型后立刻做两件事用shap库生成特征重要性图shap.summary_plot(shap_values, X_train)一眼看出哪些特征真正驱动预测挑3个典型样本用shap.plots.waterfall()画瀑布图比如对张三的预测清晰显示“基础信用分-20分近3天交易额突增35分设备更换-15分……最终综合得分0.98”。提示shap对树模型XGBoost/LightGBM支持最好对线性模型也够用。千万别用sklearn自带的feature_importances_——它只告诉你“整体重要性”无法解释单个预测。这个动作的意义远超“满足业务需求”。它是一面照妖镜如果shap图显示“客户ID”是top3重要特征说明你忘了删ID列数据泄露如果“订单创建时间”的小时字段权重极高但业务上这根本不该影响欺诈判定说明时间特征工程有误比如没做周期性编码。解释性不是锦上添花而是模型健康的听诊器。我要求所有学员在提交模型前必须附上一张shap.summary_plot图和一份文字说明“本次预测主要依据客户的交易频次权重42%和单笔金额波动率权重31%与业务逻辑一致”。3. 10条硬核实操建议从加载数据到交付可解释模型的完整链路3.1 第1条用pandas_profiling现为ydata-profiling代替df.head()3分钟看清数据全貌别再用df.head(5)了。它只给你5行像隔着毛玻璃看人。ydata-profiling生成的交互式报告才是真实数据的X光片。安装只需pip install ydata-profiling实操步骤from ydata_profiling import ProfileReport import pandas as pd # 加载你的真实数据集比如kaggle的Loan Default Prediction df pd.read_csv(loan_data.csv) # 生成报告关键参数说明 profile ProfileReport( df, titleLoan Data Profiling Report, # 报告标题 explorativeTrue, # 启用深度探索检测相关性、异常值等 minimalFalse, # 不启用精简模式我们要全部细节 samplesNone, # 显示所有样本小数据集可用大数据设为1000 correlations{pearson: {calculate: True}, spearman: {calculate: False}} # 只算皮尔逊相关 ) profile.to_file(loan_profile.html) # 保存为HTML双击打开报告里你要死盯的3个区域Overview页的“Alerts”红标它会自动标出“employment_length列有92%缺失值”、“loan_amnt与annual_inc高度相关r0.78”这些就是你的第一波待办事项Variables页的单列分析点开grade信用等级它会显示分布直方图、常见值A/B/C占比、以及“grade与loan_status的关联矩阵”——如果A级客户违约率反而最高说明数据有猫腻Correlations页的热力图找到loan_status目标列所在行颜色越深代表相关性越强。如果id列和loan_status相关性为0.99恭喜你发现了数据泄露ID可能暗含申请顺序而顺序与风控策略强相关。实操心得我让学员第一次用ydata-profiling时必须截图3个最有价值的发现发到学习群。有次一位做物流的学员发现delivery_time配送时长列最大值是365 days但其他值都是2.5 hours——他顺藤摸瓜发现这是系统bug把“预计365天”写成了“实际365天”修正后模型AUC从0.61飙升到0.79。这就是“看清全貌”的力量。3.2 第2条缺失值处理永远优先用“业务逻辑填补”而非统计值df[age].fillna(df[age].mean())是新手坟墓。真实世界里“平均年龄”毫无业务意义。正确姿势是先问业务方“age为空代表什么是客户拒绝提供还是系统未采集” 如果是前者填Not Disclosed新建一个类别如果是后者查CRM系统规则发现“未提供年龄的客户默认归入age_group25-34”那就填25-34对数值型缺失用上下文推断比如transaction_amount缺失但同一客户的前3笔交易分别是120, 135, 118那填124比填全局均值892合理百倍对时间型缺失用业务事件锚定last_login_date缺失但account_created_date是2023-01-01且公司政策是“新用户7天内必须首次登录”那就填2023-01-08。代码实现要体现“业务意图”# 错误无脑均值填充 df[age].fillna(df[age].mean(), inplaceTrue) # 正确按业务规则分层填充 def fill_age_by_business_rule(row): if pd.isna(row[age]): if row[account_type] corporate: return Company elif row[signup_source] event_booth: return Event Attendee else: return Not Disclosed else: return row[age] df[age_filled] df.apply(fill_age_by_business_rule, axis1)注意这里创建了新列age_filled而不是覆盖原列。因为原始缺失状态本身可能是信号比如“拒绝提供年龄”的客户流失率可能更高要保留原始列供后续分析。3.3 第3条分类变量编码永远用OneHotEncoder处理低基数TargetEncoder处理高基数LabelEncoder是定时炸弹必须禁用。正确选择取决于基数唯一值数量低基数10个唯一值如gender男/女/其他、payment_method微信/支付宝/银行卡用OneHotEncoder。它把一列变成多列gender_male,gender_female模型能自由学习各组合权重高基数20个唯一值如city全国300城市、product_id上万SKU用TargetEncoder。它把每个城市映射为“该城市客户的平均违约率”既降维又注入业务信号。TargetEncoder实操用category_encoders库pip install category_encodersfrom category_encoders import TargetEncoder import numpy as np # 假设target是is_default是否违约 encoder TargetEncoder(cols[city, occupation]) # 关键只在训练集上fit避免数据泄露 X_train_encoded encoder.fit_transform(X_train, y_train) X_test_encoded encoder.transform(X_test) # 测试集只transform # 验证查看北京的编码值 print(fBeijing target encoding: {encoder.mapping_[0][mapping].loc[Beijing, target]}) # 输出类似0.023北京客户平均违约率2.3%提示TargetEncoder有平滑smoothing参数防止小城市因样本少导致编码值失真。我通常设smoothing10经验值公式是smoothed_target (sum_target smoothing * global_mean) / (count smoothing)。比如某小城市只有2个客户都违约了sum_target2, count2全局均值是0.05则编码值(2 10*0.05)/(210)0.175比粗暴的1.0更稳健。3.4 第4条数值特征缩放永远用RobustScaler而非StandardScaler除非你确认无异常值StandardScaler均值为0标准差为1对异常值极度敏感。真实数据里order_amount列可能有99%的值在100-500元但有1条是999999系统bug导致的错误订单。StandardScaler会把均值拉高标准差撑大导致正常值被压缩到[-0.1, 0.1]模型学不到有效模式。RobustScaler用中位数和四分位距IQR天生抗异常from sklearn.preprocessing import RobustScaler scaler RobustScaler() X_train_scaled scaler.fit_transform(X_train[[order_amount, qty]]) X_test_scaled scaler.transform(X_test[[order_amount, qty]]) # 复用训练集参数 # 验证查看缩放后范围 print(fTrain order_amount range: {X_train_scaled[:, 0].min():.2f} ~ {X_train_scaled[:, 0].max():.2f}) # 输出-1.23 ~ 2.87正常无极端值实操心得我让学员在缩放后必须画plt.hist(X_train_scaled[:, 0], bins50)。如果直方图一头扎进负无穷比如最小值-120说明RobustScaler也没救——该去查原始数据了。有次一位学员发现缩放后qty列最小值-89顺藤摸瓜找到qty-99999的脏数据修正后模型稳定性提升40%。3.5 第5条时间特征工程永远用pd.to_datetime()后提取周期性分量而非直接用时间戳把2023-05-12 14:30:00转成1683892200Unix时间戳是自杀行为。模型会认为“2023年比2022年大”强行学习线性趋势而真实业务中“星期五下午”比“星期一上午”更易发生退货这才是关键信号。正确做法# 转换为datetime df[order_time] pd.to_datetime(df[order_time]) # 提取周期性特征关键 df[hour_sin] np.sin(2 * np.pi * df[order_time].dt.hour / 24) df[hour_cos] np.cos(2 * np.pi * df[order_time].dt.hour / 24) df[day_sin] np.sin(2 * np.pi * df[order_time].dt.dayofweek / 7) df[day_cos] np.cos(2 * np.pi * df[order_time].dt.dayofweek / 7) df[month_sin] np.sin(2 * np.pi * df[order_time].dt.month / 12) df[month_cos] np.cos(2 * np.pi * df[order_time].dt.month / 12) # 验证星期五dayofweek4的sin/cos值 print(fFriday sin: {np.sin(2*np.pi*4/7):.3f}, cos: {np.cos(2*np.pi*4/7):.3f}) # 输出Friday sin: 0.782, cos: -0.623完美区分周一到周日注意sin/cos对保证周期连续性至关重要。如果只用dayofweek0-6模型会认为“周日(6)和周一(0)相差6”而实际它们是相邻的。sin/cos把0和6映射到相近的坐标点sin(0)0,sin(2π)0模型自然学会“周日和周一相似”。3.6 第6条特征交叉永远用sklearn.preprocessing.PolynomialFeatures生成二阶交互而非手动拼接新手常手动写df[income_to_debt_ratio] df[income] / df[debt]但这是危险的如果debt0整列变inf模型直接崩溃它假设线性关系而真实世界中“收入1万且负债5千”和“收入5万且负债2万”的风险可能完全不同。PolynomialFeatures自动生成安全的二阶交叉from sklearn.preprocessing import PolynomialFeatures # 生成所有二阶交互a², b², a*b不含高次项 poly PolynomialFeatures(degree2, interaction_onlyTrue, include_biasFalse) # interaction_onlyTrue只生成a*b不生成a²/b²避免冗余 X_poly poly.fit_transform(X_train[[income, debt, age]]) # 查看生成了哪些特征列名 feature_names poly.get_feature_names_out([income, debt, age]) print(Generated features:, feature_names) # 输出[income debt age income debt income age debt age]提示interaction_onlyTrue是关键它排除了income²这种无业务意义的项。我要求学员在加入交叉特征后必须用shap验证如果income*debt的权重显著高于单个特征说明交叉有效如果权重接近0说明业务上这两者确实无关果断删除。3.7 第7条模型选择永远从LogisticRegression和RandomForest起步而非一上来就XGBoostXGBoost不是银弹。它像一把瑞士军刀功能多但难驾驭。新手用它90%的时间在调参10%的时间在理解业务。而LogisticRegression线性和RandomForest树是两把手术刀LogisticRegression如果它在标准化后准确率就达85%说明问题本质是线性的如“收入越高违约率越低”强行上XGBoost只会过拟合RandomForest如果它比逻辑回归高10个百分点说明存在复杂交互如“高收入低学历”组合风险极高这时再考虑XGBoost。实操对比代码from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report # 确保特征已缩放LogisticRegression需要 lr LogisticRegression(max_iter1000) lr.fit(X_train_scaled, y_train) y_pred_lr lr.predict(X_test_scaled) print(LogisticRegression Report:) print(classification_report(y_test, y_pred_lr)) rf RandomForestClassifier(n_estimators100, max_depth5, random_state42) rf.fit(X_train, y_train) # 树模型不需缩放 y_pred_rf rf.predict(X_test) print(\nRandomForest Report:) print(classification_report(y_test, y_pred_rf))实操心得我让学员记录两个模型的classification_report重点看precision精准率和recall召回率的平衡。如果业务是“宁可错杀一千不可放过一个”如反欺诈recall更重要如果是“不能冤枉好人”如信用审批precision更重要。这个选择比算法本身更决定成败。3.8 第8条评估指标永远用classification_report和confusion_matrix而非只看准确率准确率Accuracy在不平衡数据中是毒药。比如贷款违约预测95%客户不违约模型把所有人判为“不违约”准确率就是95%但完全没用。必须看classification_report给出每个类别的precision/recall/f1-scoreconfusion_matrix直观看到“把多少坏客户判成好客户”假阴性代价最高。代码from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt # 生成混淆矩阵 cm confusion_matrix(y_test, y_pred_rf) print(Confusion Matrix:) print(cm) # 输出[[852 48] # 预测为“不违约”852真48假漏掉48个坏客户 # [ 22 178]] # 预测为“违约”22假冤枉22个好客户178真 # 可视化更直观 plt.figure(figsize(6,4)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues) plt.title(Confusion Matrix) plt.ylabel(Actual) plt.xlabel(Predicted) plt.show() # 打印详细报告 print(classification_report(y_test, y_pred_rf))注意confusion_matrix的行列顺序。sklearn默认行真实标签列预测标签。左上角是“真阴性”预测对的好客户右下角是“真阳性”预测对的坏客户右上角是“假阳性”冤枉的好客户左下角是“假阴性”漏掉的坏客户。业务中左下角的数字往往最致命。3.9 第9条模型解释永远用shap生成summary_plot和waterfall_plot而非只看feature_importances_RandomForest.feature_importances_只告诉你“哪个特征整体重要”但无法回答“为什么这个客户被预测为高风险”。shap能给出每个预测的归因。实操import shap # 初始化explainer对树模型用TreeExplainer explainer shap.TreeExplainer(rf) shap_values explainer.shap_values(X_test) # 1. 全局重要性图summary_plot shap.summary_plot(shap_values[1], X_test, plot_typebar) # [1]表示正类违约 # 2. 单个样本解释waterfall_plot shap.initjs() # 加载JS shap.waterfall_plot(explainer.expected_value[1], shap_values[1][0,:], X_test.iloc[0,:])提示shap_values[1]对应正类违约的SHAP值。shap.waterfall_plot会生成交互式瀑布图从基线值模型平均预测开始逐条叠加各特征的贡献最终落到该样本的预测值。我要求学员必须为每个模型输出至少3张waterfall_plot覆盖“高风险”、“低风险”、“边界风险”样本并用文字描述“对客户Adebt_to_income_ratio贡献0.42是最大风险因子对客户Bemployment_length贡献-0.35是最大保护因子”。3.10 第10条模型交付永远打包preprocessor.pkl和model.pkl而非只交.py脚本模型上线不是“把notebook发给运维”。必须交付可独立运行的二进制包preprocessor.pkl包含所有预处理步骤缺失值填充规则、编码器、缩放器model.pkl训练好的模型。保存代码import joblib # 保存预处理器假设你已定义好pipeline joblib.dump(preprocessor, preprocessor.pkl) # 保存模型 joblib.dump(rf, model.pkl) # 验证加载并测试 preprocessor_loaded joblib.load(preprocessor.pkl) model_loaded joblib.load(model.pkl) # 对新数据单行做预测 new_data pd.DataFrame([{income: 8000, debt: 2000, age: 35}]) X_new_processed preprocessor_loaded.transform(new_data) pred model_loaded.predict(X_new_processed)[0] print(fPrediction for new data: {pred}) # 应输出0或1注意joblib比pickle更适合保存scikit-learn对象且体积更小。交付时必须附上requirements.txtpip freeze requirements.txt和test_prediction.py含上述验证代码确保运维能一键跑通。4. 新手必踩的7个坑从我的37个学员实战记录中提炼4.1 坑1用df.corr()找相关性却忽略p-value把噪声当信号df.corr()只算皮尔逊相关系数r但r0.3在小样本n20中可能纯属随机。必须看p值。正确做法from scipy.stats import pearsonr # 计算r和p值 r, p pearsonr(df[income], df[loan_amount]) print(fIncome vs Loan Amount: r{r:.3f}, p{p:.3f}) # 判断p0.05才认为相关性显著 if p 0.05: print(Significant correlation!) else: print(Not significant, ignore.)实操心得有次学员发现customer_id和loan_status的r0.15p0.08他差点当成信号。我让他把customer_id换成np.random.rand(len(df))结果r0.14p0.09——瞬间明白这是随机波动。记住没有p值的相关性都是耍流氓。4.2 坑2在train_test_split前不做shuffle导致时间序列数据泄露train_test_split默认shuffleTrue但如果你处理的是时间序列如每日销售数据必须关掉它并手动按时间切分# 错误随机打乱把未来数据混进训练集 X_train, X_test, y_train, y_test train_test_split(X, y, test_size0.2) # 正确按时间顺序切分假设df已按date排序 split_idx int(len(df) * 0.8) X_train X.iloc[:split_idx] X_test X.iloc[split_idx:] y_train y.iloc[:split_idx] y_test y.iloc[split_idx:]提示train_test_split的shuffleFalse参数仅适用于stratify场景时间序列必须手动切。否则模型会“偷看”未来上线后惨不忍睹。4.3 坑3用GridSearchCV调参却在cv参数中用了ShuffleSplit导致时间序列泄露GridSearchCV的交叉验证如果用ShuffleSplit同样会打乱时间顺序。时间序列必须用TimeSeriesSplitfrom sklearn.model_selection import TimeSeriesSplit, GridSearchCV tscv TimeSeriesSplit(n_splits3) # 3折每折按时间递进 param_grid {n_estimators: [50, 100], max_depth: [3, 5]} grid_search GridSearchCV( RandomForestClassifier(random_state42), param_grid, cvtscv, # 关键用TimeSeriesSplit scoringf1 ) grid_search.fit(X_train, y_train)注意TimeSeriesSplit确保每一折的训练集都在测试集之前模拟真实预测场景。4.4 坑4LabelEncoder用在目标变量y上却忘记在预测时逆变换LabelEncoder对y编码后预测输出是0/1但业务要的是Default/No Default。必须保存inverse_transformfrom sklearn.preprocessing import LabelEncoder le LabelEncoder() y_train_encoded le.fit_transform(y_train) # y_train是字符串数组 # 训练模型... y_pred_encoded model.predict(X_test) y_pred le.inverse_transform(y_pred_encoded) # 关键还原为字符串 print(y_pred[:5]) # [No Default Default ...]提示le.classes_可查看编码映射le.transform([Default])返回编码值。4.5 坑5OneHotEncoder处理训练集后测试集出现新类别直接报错OneHotEncoder默认handle_unknownerror。正确做法是设为ignore并确保测试集新类别不产生新列from sklearn.preprocessing import OneHotEncoder encoder OneHotEncoder(handle_unknownignore, sparse_outputFalse) X_train_encoded encoder.fit_transform(X_train[[city]]) X_test_encoded encoder.transform(X_test[[city]]) # 新城市被忽略不报错 # 验证列数一致 print(fTrain columns: {X_train_encoded.shape[1]}, Test columns: {X_test_encoded.shape[1]})注意sparse_outputFalse确保输出是dense array方便后续操作。4.6 坑6用cross_val_score评估却传入未预处理的原始数据cross_val_score会在每一折内重新split但预处理如缩放必须在split后、fit前做否则数据泄露。正确姿势是用Pipelinefrom sklearn.pipeline import Pipeline pipeline Pipeline([ (scaler, RobustScaler()), (classifier, RandomForestClassifier()) ]) scores cross_val_score(pipeline, X, y, cv5, scoringf1) print(fCV F1 scores: {scores}) print(fMean F1: {scores.mean():.3f} (/- {scores.std() * 2:.3f}))提示Pipeline确保每一折内缩放器只用该折训练集fit再transform该折训练集和测试