ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

机器学习的标准工作流程,从数据预处理到模型评估(附可运行代码)

机器学习的标准工作流程,从数据预处理到模型评估(附可运行代码) 翻当时的学习笔记的时候看到这么一页写得很潦草数据清洗、标准化、特征工程、编码、过采样欠采样、6:2:2、网格搜索、贝叶斯优化、泛化能力……每个词单独拎出来我都能说出个大概但当时有个问题一直没想通这些东西到底谁先谁后我记得最清楚的画面是第一次做课程项目我把SMOTE过采样跑完了才想起来划分训练集导师看了一眼说你这是把答案提前告诉模型了。后来自己动手跑了几个项目才算把这页笔记串成一条线。它其实是一条流水线前后顺序有明确的道理乱了就出事。这篇文章就是把这条线从头到尾捋一遍配一份复制就能跑的完整代码。如果你是刚上完课、准备动手做第一个项目的阶段应该能少踩几个坑。一、先把全貌看清楚整个流程就三件事但三件事花的时间完全不成比例数据预处理吃掉整个项目 6~7 成的工作量。一个反直觉的事实是换算法带来的提升通常远不如把数据弄好带来的提升。同样的数据逻辑回归和 XGBoost 的差距可能是三五个点但把特征工程做对可能是二十个点。所以第一阶段别偷懒。一个反直觉的事实换算法带来的提升通常远不如把数据弄好带来的提升。同样的数据逻辑回归和 XGBoost 的差距可能是三五个点但把特征工程做对可能是二十个点。所以第一阶段别偷懒。二、数据预处理最脏最累但最值钱2.1 数据清洗原始数据基本都是脏的。常见四类问题问题类型典型表现常见处理缺失值NaN、空字符串、-999、0删行 / 填中位数众数 / 填业务默认值异常值年龄 250、月消费 999999业务规则修正 / 当缺失处理 / 保留重复值完全重复、主键重复去重类型错误数字存成字符串、日期格式不统一转类型缺失值这里有个容易忽略的细节空字符串不是 NaN。pandas读进来的空单元格才是 NaN业务系统里导出的空字符串往往原样保留df.isna().sum()查不出来。处理前先统一一下df[contract_type] df[contract_type].replace(, np.nan)异常值我想多说一句因为这是我踩过的坑。我第一次处理一张电商用户表用 3σ 把月消费字段的离群点全删了结果把那批消费五万以上的 VIP 全干掉了——模型是变干净了但业务上最值钱的那群人没了。所以判断异常值统计方法3σ、IQR 箱线图只是给你一个候选名单删不删要回到业务里问。我的习惯是明显是录入错误的年龄 250改成缺失值交给填充器处理数值虽然极端但真实的高消费用户保留或者用分位数截断Winsorize而不是删掉。还有个工程上的建议清洗逻辑写成函数别在 Excel 里手动改。手动改过一次下个月数据刷新你还得手动改一遍而且改的还不一定是同一套规则。2.2 数据转换笔记上我在这条旁边写了个类似蒸馏当时没听清老师具体指的啥。后来理解了大概意思是把量纲千差万别的原始值蒸馏到同一把尺子上。具体来说就两种标准化Standardization / Z-scorex (x - 均值) / 标准差结果均值 0、方差 1。归一化Normalization / Min-Maxx (x - min) / (max - min)结果压到 [0, 1]。区别在于归一化对异常值很敏感一个极端值就能把其他所有样本挤到很窄的区间里标准化因为用的是均值和标准差抗干扰能力强一些而且不要求数据服从正态分布这点经常被人误解。没有特殊需求的话默认用标准化。哪些算法必须做转换判断标准其实很简单你只需要看这个算法会不会算距离或者用梯度。必须做KNN、SVM、神经网络、带正则化的线性模型、PCA、K-Means不用做决策树、随机森林、XGBoost、LightGBM树模型只看分裂阈值跟量纲无关不做会怎样举个直观的例子特征里同时有年龄18-75和年收入0-1000000KNN 算欧氏距离的时候收入那一项的差值会完全主导结果年龄这个特征等于没用。2.3 特征工程这是我认为最见功力的地方。笔记里写的构建特征字典一开始我以为是某种数据结构后来发现是工程习惯——给每个特征建一份档案。做项目的时候我都会先列这么一张表字段名类型业务含义取值范围缺失率处理方式来源age数值用户年龄18-756%中位数填充 标准化user_profilecontract_type类别合约类型月付/年付/两年付3%众数填充 独热order_infotenure_months数值已使用月数0-720%标准化user_profile看着像形式主义实际上有几个很实在的用处一是强迫你想清楚每个字段到底是干嘛的经常填着填着就发现这个字段模型不该看后面会说的泄露问题二是别人接手你的代码时不至于抓瞎三是上线的时候这张表就是特征服务的接口文档。至于怎么造特征几个我常用的套路# 1. 比值 / 人均绝对值往往不如相对值有区分度 df[tickets_per_month] df[support_tickets] / (df[tenure_months] 1) # 工单总数 10 单对用了 3 个月和用了 3 年的用户含义天差地别 # 2. 时间差注册到现在多久、上次登录距今天数 df[days_since_last_login] (pd.Timestamp.now() - df[last_login]).dt.days # 3. 分箱把连续值离散化能捕捉非线性关系 df[age_bin] pd.cut(df[age], bins[18, 25, 35, 50, 100], labelsFalse) # 4. 交叉组合两个特征单独看没用组合起来有用 df[high_value_new_user] ((df[monthly_charges] 200) (df[tenure_months] 3)).astype(int) # 5. 聚合统计从明细表造出用户粒度的行为特征 # 近 30 天登录次数、近 7 天平均停留时长 这类表格数据里收益最高的一类特征第 5 类聚合统计在真实业务里收益最高但有个前提只能用预测时点之前的数据算。你要预测用户下个月会不会流失那近 30 天登录次数必须是截止到预测时点的 30 天不能把未来数据算进去。这是时序特征最容易犯的错。2.4 编码模型只认数字类别特征得先翻译一下。三种情况有序类别—— 用标签编码顺序本身携带信息# 学历高中 本科 硕士 博士 edu_map {高中: 0, 本科: 1, 硕士: 2, 博士: 3} df[education] df[education].map(edu_map)无序类别—— 用独热编码One-Hot。注意别用标签编码瞎编顺序微信0、支付宝1、银行卡2会让模型误以为微信和支付宝的距离比微信和银行卡近这纯属无中生有。from sklearn.preprocessing import OneHotEncoder # handle_unknownignore 一定要加 enc OneHotEncoder(handle_unknownignore, sparse_outputFalse)handle_unknownignore这个参数值得单独提。不加的话训练集没见过、测试集出现的类别会直接报错。真实业务里新类别太常见了新上线的支付方式、新开的城市不加就是给自己埋雷。加了以后未知类别会编码成全 0模型不会因为一个没见过的取值直接崩掉。高基数类别—— 取值特别多比如城市、商品 SKU、用户 ID独热编码会把维度撑爆。这时候用目标编码Target Encoding用该类别对应的目标变量均值来替代。但目标编码有严重的泄露风险它用到了标签信息必须在交叉验证的每一折内部单独计算不能先算好再划分数据集。sklearn 里对应的是TargetEncoder1.3 版本或者用category_encoders这个库。2.5 类别不平衡这个坑我印象最深。第一次做二分类任务跑出来准确率 95%我还挺高兴结果一看混淆矩阵——正样本一个都没预测对。因为正样本只占 5%模型只要无脑预测全是负样本准确率就是 95%。所以遇到不平衡数据第一件事是把准确率这个指标拉黑。三种应对方式过采样复制或者合成少数类样本。SMOTE 是经典方法它不只是复制而是在少数类样本之间插值合成新样本from imblearn.over_sampling import SMOTE from imblearn.pipeline import Pipeline as ImbPipeline # 注意用 imblearn 的 Pipeline不是 sklearn 的 pipe ImbPipeline([ (prep, preprocessor), (smote, SMOTE(random_state42)), (clf, RandomForestClassifier()), ])欠采样删掉一部分多数类样本。数据量大的时候挺好用缺点是丢信息。改权重最省事不动数据直接告诉模型少数类更重要。sklearn 里大部分分类器都支持RandomForestClassifier(class_weightbalanced) LogisticRegression(class_weightbalanced) XGBClassifier(scale_pos_weight负样本数/正样本数)我个人习惯先试class_weightbalanced一行代码几乎零成本还不用担心过拟合。这不是偷懒我在同一份数据上对比过一行class_weightbalanced能拿到测试集 ROC-AUC 0.8395 / PR-AUC 0.5577换成 SMOTE 反而只有 0.8198 / 0.4751。不是说 SMOTE 没用而是它合成的样本会引入噪声在类别边界重叠严重的表格数据上容易帮倒忙。所以我的顺序永远是先试零成本的权重法效果不够再考虑采样。这里有个必须记住的顺序问题采样只能在训练集上做。先划分数据集再对训练集过采样。如果先过采样再划分同一个样本的合成兄弟会同时出现在训练集和测试集里测试成绩会虚高这就是为什么我第一次做项目被导师说的那个点。三、模型训练3.1 数据集划分为什么非要分三份因为三件事需要三份不同的数据训练集喂给模型学习参数验证集用来选模型、调超参数测试集只在最后评估一次模拟真实上线后遇到的新数据比例用 6:2:2 还是 8:1:1看数据量数据量小几千条6:2:2验证集和测试集都需要足够样本指标才稳定数据量大十万以上8:1:1 甚至 98:1:11% 也够用了多留点给训练数据特别小几百条别硬分三份直接用 K 折交叉验证划分的时候记得加stratify保持各集合里类别比例一致X_train, X_temp, y_train, y_temp train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) X_val, X_test, y_val, y_test train_test_split( X_temp, y_temp, test_size0.5, stratifyy_temp, random_state42 )不加stratify运气不好会出现某一折里正样本特别少的情况训练出来的模型指标会飘得厉害。还有一种情况要特别注意时间序列数据不能随机划分。你要用 1-6 月的数据预测 7 月那就严格按时间切训练集是 1-4 月验证集 5 月测试集 6 月。随机打乱会让模型看到未来线下指标好看得离谱上线一塌糊涂。3.2 算法选择我的顺序是先跑基线再上复杂模型。基线不是走过场它有两个作用一是告诉你这个问题至少能做到什么程度二是如果复杂模型打不过基线说明你的数据或特征有问题。表格数据也就是大多数业务场景里的结构化数据的一个经验结论梯度提升树基本是天花板。多说一句很多刚入门的同学包括当时的我觉得深度学习一定比随机森林强。在图像、文本这些非结构化数据上确实如此但在几千到几十万行的表格数据上LightGBM 通常又快又好深度网络大概率打不过它还更难调。选算法之前先认清数据类型。3.3 超参数调优先分清两个概念参数是模型自己学出来的比如线性回归的系数超参数是你提前指定的比如树的最大深度。调参调的是后者。网格搜索把所有候选值排列组合穷举一遍。param_grid { clf__max_depth: [6, 10, None], clf__min_samples_leaf: [1, 5, 20], clf__n_estimators: [300], } # 3 × 3 × 1 9 种组合配合 5 折交叉验证 45 次训练问题在于组合爆炸3 个参数各 3 个候选值是 27 次5 个参数各 5 个候选值就是 3125 次配上交叉验证直接跑到天荒地老。随机搜索在候选范围内随机采样 N 组。Bergstra 和 Bengio 2012 年那篇论文的核心结论是——通常只有少数几个超参数真正重要随机搜索因为采样更分散在同样的计算预算下反而更容易找到好的组合。from sklearn.model_selection import RandomizedSearchCV from scipy.stats import randint, uniform param_dist { clf__max_depth: randint(3, 20), clf__min_samples_leaf: randint(1, 30), clf__subsample: uniform(0.6, 0.4), } search RandomizedSearchCV(pipe, param_dist, n_iter50, scoringaverage_precision, cv5, n_jobs-1)我的实际做法先用随机搜索大范围扫一遍锁定大致区间再用网格搜索在小区间里精调。贝叶斯优化它会根据历史试验结果推测下一组最值得试的参数而不是瞎试。适合单次训练很贵的情况深度学习、大数据集。常用库是 Optuna代码量比 GridSearchCV 多一点点但省时间import optuna def objective(trial): params { n_estimators: trial.suggest_int(n_estimators, 100, 500), max_depth: trial.suggest_int(max_depth, 3, 15), learning_rate: trial.suggest_float(learning_rate, 0.01, 0.3, logTrue), } model LGBMClassifier(**params) score cross_val_score(model, X, y, cv5, scoringaverage_precision).mean() return score study optuna.create_study(directionmaximize) study.optimize(objective, n_trials50)不管用哪种都记得配合交叉验证。用单次的训练集-验证集划分来调参结果会受这一次划分的运气影响交叉验证取平均才稳。四、模型评估4.1 先把混淆矩阵背下来所有分类指标都是从这四个数推出来的精确率 Precision TP / (TP FP)你报出来的正例里有多少是真的召回率 Recall TP / (TP FN)真正的正例里你抓住了多少F1上面两个的调和平均想要一个综合数字的时候用怎么选取决于业务代价癌症筛查、金融风控、故障预警——宁可错杀不可放过要召回率。漏掉一个早期癌症患者的代价远大于让一个健康人多做一次检查。垃圾邮件过滤、推荐系统——误杀代价高要精确率。把老板的重要邮件扔进垃圾箱比收一封广告严重多了。4.2 ROC-AUC 和 PR-AUC这两个都是评估排序能力的指标不受阈值选择影响。ROC-AUC0.5 是随机瞎猜1.0 是完美。但它有个毛病——类别严重不平衡时会虚高。正样本只占 1% 的时候模型只要不给正样本排太低AUC 就能轻松上 0.9看着很漂亮实际抓正样本的能力一塌糊涂。PR-AUCaverage precision只看正样本的表现在不平衡场景下比 ROC-AUC 诚实得多。所以我的习惯不平衡数据上主看 PR-AUCROC-AUC 当辅助。回归任务就三个常用指标MAE平均绝对误差好解释、RMSE对大误差更敏感、R²拟合优度越接近 1 越好。4.3 泛化能力到底在看什么泛化能力说白了就是模型在没见过的数据上还能不能打。判断方法很直接——比训练集和测试集的分数训练集分数测试集分数诊断怎么办很高很低过拟合加数据、简化模型、加正则、减特征很低很低欠拟合换复杂模型、加特征、减少正则高高差距小正常挺好可以上线了比训练集还高—有泄露检查数据划分和特征构造最后一行我加了条有泄露的情况看着违反直觉但真遇到的时候特别有提示性测试集分数比训练集还高八成是哪里出了问题最常见的是先做采样/标准化再划分数据或者特征里混进了未来信息。顺便说下学习曲线。横轴是训练样本数纵轴是分数如果两条线训练集分数、验证集分数之间有一条明显的鸿沟且不收敛就是过拟合如果两条线都低且贴在一起就是欠拟合。比看单个数字直观。五、串起来一份完整可运行的代码场景是某 SaaS 产品的用户流失预测二分类且类别不平衡。数据是我用代码模拟的会故意塞入缺失值、异常值和重复行这样你复制过去直接就能跑不用下载任何数据集。依赖pip install pandas scikit-learn# -*- coding: utf-8 -*- 机器学习标准工作流程 —— 完整可运行示例 场景用户流失预测二分类类别不平衡 依赖pip install pandas scikit-learn import numpy as np import pandas as pd from sklearn.model_selection import train_test_split, GridSearchCV, StratifiedKFold from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.impute import SimpleImputer from sklearn.preprocessing import StandardScaler, OneHotEncoder from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import ( classification_report, confusion_matrix, roc_auc_score, average_precision_score, ) RANDOM_STATE 42 rng np.random.default_rng(RANDOM_STATE) # ---------------------------------------------------------------------- # 0. 造一份脏数据这样你复制过去就能跑不用下载任何数据集 # ---------------------------------------------------------------------- def make_dirty_churn_data(n8000): age rng.normal(38, 12, n).clip(18, 75) tenure rng.exponential(20, n).clip(0, 72) monthly rng.normal(120, 45, n).clip(20, 400) tickets rng.poisson(1.6, n) contract rng.choice([月付, 年付, 两年付], n, p[0.55, 0.28, 0.17]) payment rng.choice([微信, 支付宝, 银行卡, 对公转账], n, p[0.4, 0.35, 0.2, 0.05]) # 让标签和特征之间有真实的因果关系否则模型学不到东西 contract_risk np.select( [contract 月付, contract 年付, contract 两年付], [1.3, -0.5, -1.1] ) logit ( -2.6 0.070 * (age - 38) - 0.070 * tenure 0.015 * (monthly - 120) 0.60 * tickets contract_risk rng.normal(0, 0.5, n) ) churn rng.binomial(1, 1 / (1 np.exp(-logit))) df pd.DataFrame( { age: age, tenure_months: tenure, monthly_charges: monthly, support_tickets: tickets, contract_type: contract, payment_method: payment, churn: churn, } ) # --- 动手把数据弄脏模拟真实业务表的样子 --- # (1) 数值列随机缺失 for col, ratio in [(age, 0.06), (monthly_charges, 0.04)]: df.loc[rng.choice(n, int(n * ratio), replaceFalse), col] np.nan # (2) 类别列缺失业务系统里经常是空字符串而不是 NaN df.loc[rng.choice(n, int(n * 0.03), replaceFalse), contract_type] np.nan # (3) 异常值年龄录成了 3 岁和 250 岁 idx rng.choice(n, 12, replaceFalse) df.loc[idx[:6], age] 3 df.loc[idx[6:], age] 250 # (4) 重复行 df pd.concat([df, df.sample(30, random_stateRANDOM_STATE)], ignore_indexTrue) return df raw make_dirty_churn_data() print(原始数据:, raw.shape) print(缺失值统计:\n, raw.isna().sum()) print(流失占比: %.2f%%\n % (raw[churn].mean() * 100)) # ---------------------------------------------------------------------- # 1. 数据清洗 # ---------------------------------------------------------------------- def clean(df): df df.drop_duplicates() # 用业务规则修异常年龄合理区间 [18, 100]超出的一律置为缺失交给后面的填充 器 df.loc[(df[age] 18) | (df[age] 100), age] np.nan # 空字符串也当成缺失 df[contract_type] df[contract_type].replace(, np.nan) # 月费不可能超过 1000 df.loc[df[monthly_charges] 1000, monthly_charges] np.nan return df df clean(raw) print(清洗后:, df.shape) # ---------------------------------------------------------------------- # 2. 特征工程这里只做两件最典型的事比值 分层 # ---------------------------------------------------------------------- def build_features(df): df df.copy() # 每月工单数 工单总数 / 使用时长比单独的工单数更能反映暴躁程度 df[tickets_per_month] df[support_tickets] / (df[tenure_months] 1) # 是否高价值客户 df[is_high_value] (df[monthly_charges] df[monthly_charges].median()).astype(int) return df df build_features(df) TARGET churn NUM_FEATURES [ age, tenure_months, monthly_charges, support_tickets, tickets_per_month, is_high_value, ] CAT_FEATURES [contract_type, payment_method] X df[NUM_FEATURES CAT_FEATURES] y df[TARGET] # ---------------------------------------------------------------------- # 3. 数据集划分 —— 必须在任何学习数据分布的操作之前做 # ---------------------------------------------------------------------- X_train, X_temp, y_train, y_temp train_test_split( X, y, test_size0.2, stratifyy, random_stateRANDOM_STATE ) X_val, X_test, y_val, y_test train_test_split( X_temp, y_temp, test_size0.5, stratifyy_temp, random_stateRANDOM_STATE ) print(\n 训练集 %d / 验证集 %d / 测试集 %d (8:1:1) % (len(X_train), len(X_val), len(X_test))) print( 各 集 流 失 占 比 : %.3f / %.3f / %.3f\n % (y_train.mean(), y_val.mean(), y_test.mean())) # ---------------------------------------------------------------------- # 4. 预处理流水线缺失填充 - 标准化 / 独热编码 # ---------------------------------------------------------------------- numeric_pipe Pipeline([ (imputer, SimpleImputer(strategymedian)), (scaler, StandardScaler()), ]) categorical_pipe Pipeline([ (imputer, SimpleImputer(strategymost_frequent)), (onehot, OneHotEncoder(handle_unknownignore, sparse_outputFalse)), ]) preprocessor ColumnTransformer([ (num, numeric_pipe, NUM_FEATURES), (cat, categorical_pipe, CAT_FEATURES), ]) # ---------------------------------------------------------------------- # 5. 建模逻辑回归当基线随机森林当主力 # ---------------------------------------------------------------------- def make_model(clf): return Pipeline([(prep, preprocessor), (clf, clf)]) lr make_model( LogisticRegression(max_iter1000, class_weightbalanced, random_stateRANDOM_STATE) ) lr.fit(X_train, y_train) val_prob_lr lr.predict_proba(X_val)[:, 1] print([基线] 逻辑回归 验证集 AUC %.4f PR-AUC %.4f % (roc_auc_score(y_val, val_prob_lr), average_precision_score(y_val, val_prob_lr))) rf make_model( RandomForestClassifier(n_estimators300, class_weightbalanced_subsample, random_stateRANDOM_STATE, n_jobs-1) ) rf.fit(X_train, y_train) val_prob_rf rf.predict_proba(X_val)[:, 1] print([主力] 随机森林 验证集 AUC %.4f PR-AUC %.4f % (roc_auc_score(y_val, val_prob_rf), average_precision_score(y_val, val_prob_rf))) # ---------------------------------------------------------------------- # 6. 超参数调优网格搜索 分层 K 折交叉验证只用训练集 验证集 # ---------------------------------------------------------------------- X_tv pd.concat([X_train, X_val]) y_tv pd.concat([y_train, y_val]) param_grid { clf__max_depth: [6, 10, None], clf__min_samples_leaf: [1, 5, 20], clf__n_estimators: [300], } cv StratifiedKFold(n_splits5, shuffleTrue, random_stateRANDOM_STATE) search GridSearchCV( make_model(RandomForestClassifier(class_weightbalanced_subsample, random_stateRANDOM_STATE, n_jobs-1)), param_grid, scoringaverage_precision, cvcv, n_jobs-1, ) search.fit(X_tv, y_tv) print(\n 调参最佳组合:, search.best_params_) print(交叉验证最佳 PR-AUC %.4f % search.best_score_) best_model search.best_estimator_ # ---------------------------------------------------------------------- # 7. 最终评估 —— 测试集到此只用这一次 # ---------------------------------------------------------------------- test_prob best_model.predict_proba(X_test)[:, 1] test_pred (test_prob 0.5).astype(int) print(\n 测试集最终表现 ) print(ROC-AUC %.4f PR-AUC %.4f % ( roc_auc_score(y_test, test_prob), average_precision_score(y_test, test_prob))) print(\n 混淆矩阵 (行真实, 列预测):) print(confusion_matrix(y_test, test_pred)) print() print(classification_report(y_test, test_pred, target_names[未 流 失 , 流 失 ], digits3)) # ---------------------------------------------------------------------- # 8. 看看泛化能力训练集 vs 测试集的差距 # ---------------------------------------------------------------------- train_prob best_model.predict_proba(X_tv)[:, 1] print(训练集 PR-AUC %.4f % average_precision_score(y_tv, train_prob)) print(测试集 PR-AUC %.4f % average_precision_score(y_test, test_prob)) # ---------------------------------------------------------------------- # 9. 特征重要性 # ---------------------------------------------------------------------- feat_names best_model.named_steps[prep].get_feature_names_out() importances best_model.named_steps[clf].feature_importances_ order np.argsort(importances)[::-1] print(\nTop 8 特征重要性:) for i in order[:8]: print( %-28s %.4f % (feat_names[i], importances[i]))我在 Python 3.13 scikit-learn 1.9.0 pandas 3.0.5 上跑出来的结果有节选原始数据: (8030, 7) 缺失值统计: age 480 monthly_charges 321 contract_type 242 流失占比: 17.57% 清洗后: (8000, 7) 训练集 6400 / 验证集 800 / 测试集 800 (8:1:1) 各集流失占比: 0.176 / 0.176 / 0.175 [基线] 逻辑回归 验证集 AUC 0.8681 PR-AUC 0.5941 [主力] 随机森林 验证集 AUC 0.8306 PR-AUC 0.5111 调 参 最 佳 组 合 : {clf__max_depth: None, clf__min_samples_leaf: 20, clf__n_estimators: 300} 交叉验证最佳 PR-AUC 0.5458 测试集最终表现 ROC-AUC 0.8395 PR-AUC 0.5577 混淆矩阵 (行真实, 列预测): [[520 140] [ 37 103]] precision recall f1-score support 未流失 0.934 0.788 0.855 660 流失 0.424 0.736 0.538 140 accuracy 0.779 800 训练集 PR-AUC 0.6586 测试集 PR-AUC 0.5577 差距 0.1009 Top 8 特征重要性: num__tickets_per_month 0.2347 num__age 0.1755 num__tenure_months 0.1749 cat__contract_type_月付 0.1322 num__monthly_charges 0.1032 num__support_tickets 0.0663 cat__contract_type_年付 0.0371 num__is_high_value 0.0307几个值得盯着看的数字第一逻辑回归AUC 0.8681打赢了随机森林0.8306。这不是代码写错了——这份数据的因果关系是我拿线性 logit 函数生成的逻辑回归天生就适合拟合它。真实业务里很少有这么讲道理的数据但这正好说明基线模型不能跳过它帮你判断数据长什么样也帮你在复杂模型翻车时有个参照。第二ROC-AUC 0.8395 看着挺体面PR-AUC 只有 0.5577。同一个模型、同一份数据两个指标差了快 0.3差出来的这部分就是类别不平衡的照妖镜。只报 ROC-AUC很容易让人以为这是个能用的模型。第三最反直觉的一条模型准确率 0.779比全部预测为不流失还低。测试集里流失只占 17.5%无脑猜都不流失的准确率是 82.5%模型反倒只有 77.9%——是不是觉得白训了不是。看混淆矩阵那两行140 个真正会流失的用户模型抓出了 103 个召回率 73.6%代价是 140 个正常用户被误判成流失。这就是class_weightbalanced在起作用——它告诉模型漏掉一个流失用户比误判一个正常用户更贵模型照做了于是拿准确率去换召回率。准确率低不代表模型差只代表你的目标和猜多数类不是一回事。业务要的是提前把可能流失的人捞出来去挽回那 73.6% 的召回率就是值钱的。这也是为什么在不平衡场景下准确率基本没有参考价值。你要是真把class_weightbalanced去掉再跑一遍会看到这个配置准确率召回率精确率ROC-AUCPR-AUC加权balanced0.7790.7360.4240.8400.558不加权默认0.8500.2710.6790.8320.554全猜不流失0.8250———去掉权重准确率从 0.779 涨到 0.850比全猜不流失的 0.825 还高看着是不是顺眼多了但召回率从 0.736 掉到 0.271——140 个会流失的用户只抓出了 38 个。如果你要拿这个模型去做挽回推送等于四分之三的流失用户你连招呼都没打。更值得琢磨的是最后两列ROC-AUC 和 PR-AUC 几乎没动。因为class_weight本质上只是在移动决策阈值就是概率大于 0.5 判为正那条线并没有改变模型把正负样本排序的能力。模型的底子没变变的只是你在哪个位置切一刀。所以结论是准确率、召回率这些指标会跟着阈值剧烈摆动AUC 类的指标才是稳的那个。调阈值或者调权重属于最后的业务决策我愿意用多少误判换多少召回不该拿来评判模型本身的好坏。第四tickets_per_month在特征重要性里排第一0.2347而它的两个原材料support_tickets只有 0.0663、tenure_months0.1749排第三。一个除法算出来的新特征比原始字段更能打这就是特征工程的实际收益。最后训练集 PR-AUC 0.6586、测试集 0.5577差 0.1属于正常范围。要是哪天你看到测试集分数反而比训练集高先回去查数据泄露别高兴得太早。再看代码本身有几个地方是刻意这么写的一是整个预处理和模型都塞进了Pipeline。这不是为了好看而是为了防止数据泄露——SimpleImputer的中位数、StandardScaler的均值方差全都是在训练集上算出来的然后原样应用到验证集和测试集。如果你手动先fit_transform整个数据集再划分测试集的统计信息就混进训练过程了指标会虚高。二是class_weightbalanced处理不平衡一行搞定没引入任何新样本。三是调参阶段用的是训练集验证集合并后做 5 折交叉验证测试集从头到尾只在最后被碰了一次。这个习惯一定要养成每用测试集做一个决策它就脏一分。用测试集反复调参选模型等于把测试集变成了验证集最后那个漂亮的数字没有意义。四是最后那段特征重要性输出tickets_per_month这个我自己造的特征直接排在了第一比它的两个原材料support_tickets和tenure_months单独用都强。这就是特征工程的价值——你自己改改build_features函数加几个新特征进去看看能不能把 PR-AUC 再推高一点。六、几个容易翻车的地方按我踩坑的频率排个序先采样/先标准化后划分数据—— 最经典的泄露。train_test_split必须是全流程的第一道工序。用整个数据集的均值填充缺失值—— 同上属于泄露。放进 Pipeline 里就不会犯。类别不平衡还盯着准确率看—— 95% 的准确率可能一个正样本都没抓到先看混淆矩阵和 PR-AUC。One-Hot 忘了加handle_unknownignore—— 训练没问题上线遇到新类别直接报错。测试集被反复使用—— 调一次参看一次测试集最后报告的数字其实是过拟合到测试集上的。特征里混入了未来信息—— 比如用订单总金额预测用户是否下单这种特征线下 AUC 能到 0.99上线就废。特征字典里来源那一列就是用来排查这个的。忽略业务规则—— 模型说这个用户会流失但你去看数据发现他的账号三天前就被封了。这种样本应该提前剔除。写在最后回头看那页笔记它其实给了正确的顺序只是我当时不知道每一步为什么在那里。真正让我理解这个流程的不是背下来清洗→转换→特征→编码→采样这个顺序而是想明白了一件事凡是从数据里学出来的东西均值、方差、类别映射、采样策略、超参数都只能在训练集上学习然后应用到其他集合上。理解了这一条整个流程的顺序就能自己推出来了——标准化为什么在划分之后SMOTE 为什么只能在训练集上做Pipeline 为什么非写不可全是同一个道理的不同表现。代码在上面我是跑通了的。建议你动手改几个地方试试把class_weightbalanced去掉看准确率是不是会涨回 0.82 以上、召回率又会掉到多少或者把build_features里的tickets_per_month删掉看 PR-AUC 掉几个点。动手改一遍比看十遍记得牢。如果这篇文章对你有帮助欢迎点赞收藏。有说错的地方也欢迎评论区指出我改。
返回列表