简介这份资源面向具备一定机器学习基础、希望以完整案例掌握回归建模全流程的学习者与数据从业者围绕个人健康保险费用预测这一经典场景展开。包内共22个文件以20个Python源代码为主另附1份CSV数据集与1份说明文档压缩包约58KB代码与数据分离便于按需调用与复现。内容覆盖探索性数据分析、统计检验、特征工程与模型调优等环节涉及线性回归、岭回归、Lasso、弹性网络、多项式特征、SVR、决策树、随机森林与XGBoost等多种算法并配合交叉验证、网格搜索及R²、MSE、MAE等指标评估还引入方差膨胀因子、Box-Cox变换与互信息筛选等技巧。目前已有77人学习下载适合作为课程设计、竞赛练手或面试项目的参考方案帮助读者快速理解从数据清洗到模型对比的完整链路。1. 个人健康保险费用预测从一份 54.32 KB 数据集说起个人健康保险费用预测是很多人接触 AI 实战时绕不开的一个回归任务。它不像图像识别那样需要 GPU 集群也不像大模型微调那样动辄几十 GB 显存一份 54.32 KB 的 CSV、20 个源代码文件一台普通笔记本就能跑完全流程。但正是这种小让它成为检验一个人是否真正理解数据建模全链路的试金石——特征工程做没做对、 categorical 变量怎么编码、回归模型怎么选、评估指标怎么看全都藏在这几千行数据里。我见过太多人拿到数据集直接df.to_csv然后model.fit最后 R² 只有 0.7 出头就以为模型不行。实际上这个任务里吸烟与否、BMI、年龄三个特征的交互项就能把 R² 拉到 0.85 以上。问题从来不在模型而在你有没有把数据当数据看而不是当表格看。这篇文章面向的是想用真实数据集跑通一遍完整预测流程的从业者不管你是刚学完 sklearn 的新手还是想找个干净案例验证自己特征工程直觉的老手都能从这份个人健康保险费用数据集里挖到东西。2. 先看懂数据再动手54.32 KB 里藏着什么2.1 字段含义与分布速查这份数据集通常包含 7 列age年龄、sex性别、bmi身体质量指数、children子女数、smoker是否吸烟、region地区、charges保险费用。前 6 列是特征最后一列是预测目标。54.32 KB 的体积意味着大约 1300 到 1400 行样本属于典型的小样本回归问题。拿到数据第一件事不是画图是df.describe()和df.dtypes一起看。数值列和类别列混在一起如果直接丢给模型sklearn 会报错。我一般会先跑一段探查代码把每列的类型、缺失值、唯一值数量一次性打出来import pandas as pd df pd.read_csv(insurance.csv) print(df.dtypes) print(df.isnull().sum()) print(df.nunique()) print(df.describe(includeall))这段代码的逻辑很直接dtypes告诉你哪些列是 object 需要编码isnull().sum()确认有没有缺失值这份数据通常很干净nunique()帮你判断类别列的基数——比如 region 只有 4 个值sex 只有 2 个这种低基数类别用 one-hot 就够了不需要 target encoding 那套复杂操作。describe(includeall)则同时给出数值列的均值和类别列的频次一眼就能看出 smoker 里 yes 的比例大概在 20% 左右属于不平衡但不算极端的情况。参数上没什么好调的唯一要注意的是includeall不加的话只显示数值列类别列的信息就漏了。如果数据里有日期列parse_dates参数要提前加上不过这份数据没有时间维度可以忽略。2.2 目标变量 charges 的偏态与处理选择charges 的分布是右偏的大部分人费用集中在 1 万以下但有一小撮人费用超过 5 万。这种长尾会让 MSE 损失函数对高值样本过度敏感模型为了压低那几个大值的误差反而牺牲了中低段的拟合精度。我一般会先画个直方图确认偏度然后决定要不要做 log 变换import numpy as np import matplotlib.pyplot as plt df[charges].hist(bins50) plt.show() print(偏度:, df[charges].skew()) df[log_charges] np.log1p(df[charges]) print(log 后偏度:, df[log_charges].skew())np.log1p而不是np.log是因为 charges 虽然理论上都是正数但用 log1p 可以避免万一出现 0 值时 log(0) 报错。变换后偏度通常会从 1.5 左右降到 0.3 以内接近正态。训练时用 log_charges 做目标预测完再np.expm1还原回原始尺度算 MAE 和 RMSE这样评估指标才有业务解释力。注意如果最终只关心排序而不关心绝对金额log 变换可以不做但只要你要报 RMSE 给业务方看log 变换几乎是必选项。我见过有人不做变换直接训RMSE 比做了变换的高出 30%还以为是模型选错了。3. 特征工程把 6 列原始字段变成模型能吃的信号3.1 类别编码one-hot 与 ordinal 的取舍sex、smoker、region 三列是 object 类型。sex 和 smoker 是二值region 是四值。最稳妥的做法是 one-hot但要注意drop_firstTrue避免虚拟变量陷阱——虽然对树模型来说多重共线性不影响分裂但对线性回归和神经网络不 drop 会导致系数不可解释。df pd.get_dummies(df, columns[sex, smoker, region], drop_firstTrue)这行代码执行后sex 变成 sex_malesmoker 变成 smoker_yesregion 变成 region_northwest、region_southeast、region_southwest 三列。drop_firstTrue把每个类别的第一个值作为基准组丢掉了基准组的信息隐含在全 0里。参数上唯一要权衡的是dummy_na。如果类别列里有缺失值dummy_naTrue会把 NaN 也当成一个独立类别否则 NaN 行在 one-hot 后会变成全 0和基准组混淆。这份数据没有缺失所以不用管。但如果你拿到的版本有缺失记得先决定是填充还是当独立类别。另一个常见做法是对 smoker 用 ordinal 编码yes1, no0因为吸烟与否本身有明确的二元语义。但 one-hot 和 ordinal 在二值情况下完全等价只是列名不同所以没必要纠结。3.2 BMI 分箱与交互项把线性模型拉回赛道BMI 和 charges 的关系不是线性的。BMI 低于 30 时费用随 BMI 增长很平缓一旦超过 30肥胖阈值费用斜率明显变陡。如果直接用原始 BMI 喂给线性回归模型会低估高 BMI 人群的费用。我的做法是加一个bmi_obese二值特征同时构造smoker_bmi交互项df[bmi_obese] (df[bmi] 30).astype(int) df[smoker_bmi] df[smoker_yes] * df[bmi] df[age_smoker] df[age] * df[smoker_yes]bmi_obese让模型能单独给肥胖人群一个截距偏移smoker_bmi则捕捉吸烟且高 BMI的叠加效应——这部分人群的费用往往不是吸烟费用加肥胖费用的简单相加而是有乘数效应。age_smoker同理年龄越大且吸烟风险不是线性叠加。这三个特征加进去线性回归的 R² 通常能从 0.75 跳到 0.83 以上。树模型对交互项不敏感因为它自己能学分裂但加了也没坏处只是特征重要性会分散。我一般会先跑一版不加交互的基线再加进去对比确认提升确实来自交互项而不是随机波动。注意交互项要在 one-hot 之后构造因为smoker_yes列是 one-hot 生成的。如果你先构造交互再 one-hot列名会对不上。4. 模型训练与调参从线性回归到梯度提升的完整对比4.1 基线模型线性回归和随机森林谁先上我习惯先跑两个基线线性回归和随机森林。线性回归用来确认特征和目标之间有没有基本的线性关系随机森林用来确认非线性模型能比线性好多少。两个都跑完心里就有底了。from sklearn.model_selection import train_test_split from sklearn.linear_model import LinearRegression from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score import numpy as np X df.drop(columns[charges, log_charges]) y df[log_charges] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) lr LinearRegression() lr.fit(X_train, y_train) lr_pred np.expm1(lr.predict(X_test)) rf RandomForestRegressor(n_estimators200, random_state42) rf.fit(X_train, y_train) rf_pred np.expm1(rf.predict(X_test)) for name, pred in [(LR, lr_pred), (RF, rf_pred)]: print(name, MAE:, mean_absolute_error(np.expm1(y_test), pred)) print(name, RMSE:, np.sqrt(mean_squared_error(np.expm1(y_test), pred))) print(name, R2:, r2_score(np.expm1(y_test), pred))train_test_split的random_state42是为了可复现test_size0.2在 1300 行数据上大约留 270 行做测试足够稳定。np.expm1是把 log 尺度预测值还原回原始金额评估必须在原始尺度上做否则 MAE 的单位是 log 金额没有业务意义。随机森林的n_estimators200是我在小数据集上的默认值再往上加收益递减但训练时间线性增长。random_state同样为了复现。如果跑出来 RF 比 LR 好很多说明非线性关系强值得上梯度提升如果差不多说明特征工程已经把非线性捕捉得差不多了不用折腾。4.2 梯度提升调参三个真正影响结果的参数梯度提升Gradient Boosting在这个任务上通常比随机森林好 3% 到 5% 的 R²。但参数多全调一遍不现实。我一般只动三个n_estimators、learning_rate、max_depth。from sklearn.ensemble import GradientBoostingRegressor from sklearn.model_selection import GridSearchCV param_grid { n_estimators: [100, 200, 300], learning_rate: [0.05, 0.1, 0.2], max_depth: [3, 4, 5], } gb GradientBoostingRegressor(random_state42) grid GridSearchCV(gb, param_grid, cv5, scoringneg_mean_absolute_error, n_jobs-1) grid.fit(X_train, y_train) print(最优参数:, grid.best_params_) best_pred np.expm1(grid.predict(X_test)) print(调参后 MAE:, mean_absolute_error(np.expm1(y_test), best_pred))n_estimators和learning_rate是跷跷板关系学习率小需要更多树学习率大树少但容易过拟合。我一般先固定 learning_rate0.1调 n_estimators 到验证集误差不再下降再微调 learning_rate。max_depth控制单棵树复杂度3 到 5 之间足够再深几乎必然过拟合因为样本量只有一千多。cv5在 1300 行数据上每折约 200 行验证方差可接受。scoring用neg_mean_absolute_error而不是neg_mean_squared_error是因为 MAE 对离群值更鲁棒而 charges 里恰好有离群值。n_jobs-1用满 CPU 核这个网格 27 个组合5 折交叉普通笔记本几分钟跑完。注意GridSearchCV 返回的best_estimator_已经用全量训练集重新拟合过了直接拿来预测即可不需要再手动 fit 一次。5. 避坑与排查这份数据集上最容易翻车的 4 个点5.1 现象R² 很高但 MAE 大得离谱原因在 log 尺度上算 R² 和 MAE忘了还原回原始尺度。log 尺度的 MAE 可能是 0.2看起来很小但还原后是几千块。解决所有评估指标必须在np.expm1之后算训练时可以看 log 尺度的 loss 曲线但报告给业务方的一定是原始金额。5.2 现象one-hot 后特征数暴涨模型训练变慢原因region 列如果基数很大比如按城市编码one-hot 会生成几百列。这份数据的 region 只有 4 个值不会出问题但如果你拿到的版本 region 粒度更细就要改用 target encoding 或 frequency encoding。解决先df[region].nunique()看一眼超过 10 就考虑替代编码方案。5.3 现象随机森林特征重要性里 age 排第一但业务方说吸烟才是关键原因age 是连续变量取值范围大树模型分裂时更容易选中它smoker 是二值分裂增益被稀释。解决不要只看默认的 feature_importances_用permutation_importance在验证集上算或者直接看 smoker 分组后的费用均值差异。业务解释和模型重要性是两回事别混为一谈。5.4 现象交叉验证分数波动很大每次跑结果不一样原因小数据集上随机划分的方差大尤其当测试集里恰好抽到几个高费用样本时RMSE 会突然变大。解决用KFold固定随机种子或者跑 10 次不同 random_state 的 train_test_split 取平均。我一般会跑 5 次不同种子看 R² 的均值和标准差标准差超过 0.03 就说明数据量不够结论要谨慎。6. 把模型用起来一个可复用的预测函数与验证习惯训练完模型不是终点能对新样本做预测才算闭环。我一般会写一个predict_charges函数把特征工程和模型预测串在一起输入原始字段输出预测金额def predict_charges(age, sex, bmi, children, smoker, region, model, feature_columns): input_df pd.DataFrame([{ age: age, sex: sex, bmi: bmi, children: children, smoker: smoker, region: region }]) input_df pd.get_dummies(input_df, columns[sex, smoker, region], drop_firstTrue) input_df[bmi_obese] (input_df[bmi] 30).astype(int) input_df[smoker_bmi] input_df.get(smoker_yes, 0) * input_df[bmi] input_df[age_smoker] input_df[age] * input_df.get(smoker_yes, 0) input_df input_df.reindex(columnsfeature_columns, fill_value0) log_pred model.predict(input_df) return np.expm1(log_pred)[0] # 使用示例 sample_pred predict_charges( age45, sexmale, bmi32.5, children2, smokeryes, regionsoutheast, modelgrid.best_estimator_, feature_columnsX.columns ) print(f预测费用: {sample_pred:.2f})这个函数的关键在reindex(columnsfeature_columns, fill_value0)。因为单样本 one-hot 后可能缺少某些类别列比如输入 region 是 southeast但训练集里 southeast 对应的列在 drop_first 后可能不存在reindex 保证列顺序和训练时完全一致缺失的列填 0。没有这一步sklearn 会报 feature names mismatch 错误。参数上feature_columns必须保存训练时的X.columns我一般会把它和模型一起 pickle 存下来。验证习惯上我每次上线新模型前会跑一个回归测试集从原始数据里留 50 条完全不参与训练的样本每次模型更新后都在这 50 条上算 MAE确认没有退化。这个习惯帮我挡过两次因为特征工程改动导致的静默错误——模型没报错但预测值整体偏移了 20%。最后说个我自己的教训早期做这个任务时我花了两天调 XGBoost 参数R² 从 0.84 调到 0.86沾沾自喜。后来把 smoker_bmi 交互项加进去同样的 XGBoost 直接到 0.89。特征工程的一小时抵得上调参的两天。希望帮到你。本文还有配套的精品资源点击获取