
简介基于Python的医疗花费预测项目资源包面向机器学习初学者与课程设计者可用于回归建模、模型对比与融合实践。资源围绕医疗费用数据集覆盖数据检查、空值处理与重复值观察等流程核心部分分别以手写不调包方式实现随机森林和线性回归并借助scikit-learn实现GBDT、SVR、Lasso回归与决策树进一步采用RandomizedSearchCV、GridSearchCV和手动调参三种策略优化模型同时尝试直接平均、加权平均和Stacking堆叠完成模型融合并用K折交叉验证、留一法评估性能适合系统学习回归任务全链路。压缩包共5个文件包括2个Python源码、1份Word设计报告、1个说明文档和1个License文件整体仅1.29MB结构紧凑。已有683人学习下载适合需要完整代码与报告参考的课程设计或入门实践者。1. 医疗花费预测本质上是一个回归问题Python 恰好把全链路工具都备齐了医疗花费预测是保险定价、医保控费和健康管理场景里最常见的回归任务输入一个人的年龄、身体指标、生活习惯、参保区域等结构化字段输出一个连续金额。很多人在第一次跑通模型时会有个直观感受——线性回归的 R2 勉强到 0.7换上树模型直接冲过 0.85再调两轮参数就能逼近 0.9。这种提升不是玄学而是医疗花费里“吸烟者 vs 不吸烟者”“高龄 vs 低龄”这些强非线性关系在起作用。Python 在这类任务上的优势不只是模型库多而是从 pandas 清洗、特征工程、scikit-learn 建模到 joblib 落盘、Flask 提供接口全部用同一种语法就能串起来。本文按一份常见的保险理赔模拟数据来推演但不绑定任何具体数据集。你可以在本地用 openml 或 Kaggle 上带charges字段的医疗费用数据复现也可以换成自己公司脱敏后的理赔记录。目标不是写出一份唯一答案而是把“如何用 Python 把医疗花费预测从数据处理做到可上线接口”这条路径讲透。2. 数据清洗与特征工程先让医疗数据变成能喂给算法的矩阵2.1 读取数据、识别缺失和异常不要一上来就 dropna医疗花费原始数据几乎不会是干净的。常见字段包括age年龄、sex性别、bmi体质指数、children子女数、smoker是否吸烟、region地区和charges实际花费。第一步是用 pandas 读进来同时观察每列的类型、缺失比例和分布形状。import pandas as pd import numpy as np df pd.read_csv(medical_cost.csv) print(df.info()) print(df.describe(includeall)) print(df.isnull().sum())info()能快速暴露类型问题比如age被读成 float、smoker只有 object 却没有枚举值。describe(includeall)会列出每列的计数、唯一值和分位数特别要看bmi和charges的最大值是否离谱。医疗花费里charges通常右偏最大值可能比 75% 分位数高出一个数量级这不算异常而是后面需要做对数变换的信号。isnull().sum()则能告诉你哪些列缺失比较严重。对于缺失值我的常见做法是分字段处理age和bmi用中位数填充children用众数因为这类字段分布偏态明显smoker、region这类分类字段如果缺失率低于 5%直接删掉该行通常问题不大。医疗场景里不要用均值填充age因为年龄和后续的 BMI、花费都有叠加交互效应均值会抹掉这种关系。另外注意检查是否存在age 0或bmi超出 10~60 这种物理上不合理的记录这类数据应先过滤而不是填充。2.2 类别特征编码区分有序和无序不要一刀切 LabelEncoder分类字段里sex和region是无序类别smoker虽然只有两个值但在建模时更建议保留 0/1 编码而不是字符串。children虽然看起来是数字但它的取值 0~5 在语义上是“有几个子女”它和花费之间的关联并不线性可以先用直方图确认后再决定是当作数值还是分类。df[smoker] df[smoker].map({yes: 1, no: 0}) df[sex] df[sex].map({male: 1, female: 0}) df pd.get_dummies(df, columns[region], prefixregion)这里把二分类变量手动映射为 0/1是因为后续树模型和线性模型对二值特征的处理一致而且能直接用于特征重要性解释。region有 4 个取值用get_dummies生成 4 列 0/1 编码。注意此时不要drop_firstTrue虽然会引入多重共线性但树模型不受影响如果你之后要做线性回归并看系数解释再考虑去掉一个冗余列。一个常见误区是把children直接当作数值特征尤其在树模型里会给出不合理的分裂点。我一般先跑一版把children当数值特征再跑一版把它转成字符串后做 one-hot对比验证集指标来决定。在医疗花费数据里children3和children4的边际效应并不平滑多数情况下 one-hot 会更稳。2.3 训练集测试集划分与数值特征标准化先划分再处理划分数据集必须在任何填充和标准化之前完成否则测试集信息会泄漏到训练过程里。正确做法是先切分再用训练集的统计量去转换验证集和测试集。对树模型来说标准化不是必需但如果你要尝试线性回归、SVM 或者神经网络标准化能显著提升收敛速度和系数可比性。from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X df.drop(charges, axis1) y df[charges] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) num_cols [age, bmi, children] scaler StandardScaler() X_train[num_cols] scaler.fit_transform(X_train[num_cols]) X_test[num_cols] scaler.transform(X_test[num_cols])train_test_split里的random_state42是固定随机种子便于你复现实验test_size0.2表示 20% 样本留作最终验证。StandardScaler会计算每列均值和标准差fit_transform在训练集上拟合并转换transform只使用训练集的参数去处理测试集。这一步之后再画age和bmi的分布会看到它们变成均值近似 0、标准差近似 1 的分布。需要注意charges本身没有标准化因为它是目标变量它的偏态问题应该通过对数变换处理而不是标准化。如果charges分布右偏严重我通常会在划分后立即做y_train np.log1p(y_train)预测完再np.expm1还原。对数变换能让模型更关注比例的偏离而不是被几个超高花费的样本牵着走。这个技巧在后面评估时能明显降低 RMSE。3. 从线性回归到梯度提升医疗花费预测的模型选型和参数调优3.1 线性回归做基线看懂残差才能知道往哪个方向加复杂度很多医疗花费预测的入门代码直接用LinearRegression跑一遍然后打印 R2。但这一步的真正目的不是拿到一个好分数而是通过残差判断数据的非线性程度。线性回归对smoker和age这种加法效应描述得还可以但bmi与smoker的交互作用、age在高龄段加速上涨的趋势都是典型的非线性信号。from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error, r2_score lr LinearRegression() lr.fit(X_train, y_train) pred_lr lr.predict(X_test) print(R2:, r2_score(y_test, pred_lr)) print(RMSE:, mean_squared_error(y_test, pred_lr, squaredFalse)) residuals y_test - pred_lr print(残差均值:, residuals.mean()) print(残差标准差:, residuals.std())LinearRegression默认用最小二乘法求解没有额外超参数所以它能作为最稳定的基线。squaredFalse让mean_squared_error返回 RMSE单位是美元更直观。残差均值接近 0 说明没有系统性偏差但残差标准差如果远大于训练集上的误差说明模型在某些子群体上系统性预测偏低。通常你会看到预测值在高花费区间被严重低估这就是数据右偏和特征交互的残留信号。这个基线的意义在于给后续模型定一个基准线。如果随机森林只比线性回归高 0.02说明数据主要还是线性关系没有必要上复杂模型如果 R2 提升超过 0.1说明非线性收益明显值得继续做特征交叉和调参。3.2 树模型和集成模型为什么适合医疗花费数据医疗花费的特征维度通常不高十来列但特征间交互很强。例如吸烟者的 BMI 升高会显著拉高花费而非吸烟者 BMI 的边际影响就平缓得多。线性模型想捕捉这种交互需要手动构造bmi * smoker特征而树模型天然能通过分裂路径产生交互。随机森林和梯度提升树是这里的主力。随机森林对异常值和噪声更鲁棒训练速度快适合先做一轮快速验证梯度提升如 XGBoost、LightGBM通常精度更高但对参数更敏感需要配合早停避免过拟合。from sklearn.ensemble import RandomForestRegressor rf RandomForestRegressor( n_estimators300, max_depth10, min_samples_leaf4, random_state42 ) rf.fit(X_train, y_train) pred_rf rf.predict(X_test) print(RF R2:, r2_score(y_test, pred_rf)) print(RF RMSE:, mean_squared_error(y_test, pred_rf, squaredFalse))n_estimators300是树的数量足够多时模型趋于稳定max_depth10限制单棵树的深度防止每棵树学得太细min_samples_leaf4让叶子节点至少包含 4 个样本对医疗数据这种分布不平滑的目标特别有效。如果数据集只有几千行这些参数通常比默认值表现更好。如果发现训练集 R2 接近 1 而测试集明显下降优先调大min_samples_leaf而不是减少n_estimators。3.3 网格搜索找最佳参数用验证集而不是测试集反复试探调参最忌讳直接拿测试集试。常见做法是把训练集再切出一部分作为验证集或者用交叉验证。GridSearchCV会自动执行交叉验证并返回最优参数组合。对于医疗花费预测我一般只对三个参数做网格搜索树模型里的学习率、树深度、叶子节点的最小样本数其他参数保持稳健值。from sklearn.model_selection import GridSearchCV from sklearn.ensemble import GradientBoostingRegressor param_grid { learning_rate: [0.05, 0.1, 0.15], max_depth: [3, 5, 7], min_samples_leaf: [5, 10, 20] } gbr GradientBoostingRegressor(random_state42) grid GridSearchCV( gbr, param_grid, cv5, scoringneg_mean_squared_error, n_jobs-1 ) grid.fit(X_train, y_train) print(best params:, grid.best_params_) print(best cv RMSE:, (-grid.best_score_) ** 0.5)param_grid里每个参数给 2~3 个候选值cv5表示 5 折交叉验证。scoringneg_mean_squared_error是因为 GridSearchCV 默认按最大化分数方向搜索所以取负数最后再开方得到 RMSE。n_jobs-1让所有 CPU 核心参与搜索。这里的候选组合有 27 种5 折交叉验证意味着训练 135 次模型数据量不大时一分钟内就能跑完。搜索结果里learning_rate0.1和max_depth3往往是稳健组合。如果你用 LightGBM还可以加early_stopping_rounds这时网格搜索就不合适了建议改用Optuna或手动序列调参。调完之后用grid.best_estimator_重新在完整训练集上训练再到最初切出来的测试集上评估才是正确顺序。4. 医疗花费预测的模型评估R2、RMSE、MAE 和残差诊断一个都不能少4.1 评估指标选择RMSE 惩罚大额误差MAE 更贴近预算视角医疗花费预测的指标选择取决于业务目标。如果你是给保险公司做定价高估和低估的代价不一样低估会让公司亏钱高估会让客户流失。RMSE 会对大额误差给予平方级惩罚所以它更契合“避免极端低估”的场景。MAE 则是绝对误差的平均更容易向非技术人员解释为“平均每个样本预测偏差 XX 美元”。指标公式含义医疗花费场景适用性R2模型解释的方差比例用来看整体拟合度但不反映误差绝对大小RMSE误差平方均值的平方根对超大额花费敏感适合定价风控MAE绝对误差的平均值对异常值不敏感适合做费用预估MAPE相对误差百分比适合对比不同量级样本但会遇到零值问题在代码里MAE 可以直接用mean_absolute_error计算MAPE 需要手写。如果你的charges做了对数变换那么所有指标都应在还原后的数值上计算而不是在 log 空间。曾经有人在 log 空间里算 RMSE得到一个很小的数值还原后误差其实非常大——这个坑要记住。4.2 特征重要性分析找到医疗花费背后的主要驱动因素树模型可以从训练结果中直接提取特征重要性。重要性反映每个特征在分裂中带来的不纯度下降总和数值相对大小比绝对值更有意义。对于医疗花费数据通常smoker的重要性最高age其次bmi紧随其后region和sex相对较低。这个结论应该和业务常识互相印证。importance pd.DataFrame({ feature: X_train.columns, importance: grid.best_estimator_.feature_importances_ }) importance.sort_values(importance, ascendingFalse, inplaceTrue) print(importance.head(10)) from sklearn.inspection import permutation_importance perm permutation_importance( grid.best_estimator_, X_test, y_test, n_repeats10, random_state42 )feature_importances_是模型自带属性基于训练时的分裂增益计算。permutation_importance则是另一种思路打乱某个特征后重新预测看指标下降多少下降越多说明该特征越重要。两者结合能避免单一方法失衡。比如当两个特征高度相关时树模型可能把重要性都分给其中一个而排列重要性会告诉你另一个也不是可有可无。在医疗费用数据里如果你把bmi和smoker同时放进模型bmi的排列重要性会明显高于它的基尼重要性这就是交互效应的体现。4.3 残差诊断用一张图找出模型在哪批人身上失效评估指标只能给一个总体分数残差图才是定位问题人群的工具。把预测值放在横轴残差放在纵轴理想情况下残差应该随机分布在零线附近。如果出现喇叭形分布——预测值越大残差越分散说明模型对高花费人群的估计不够稳定。import matplotlib.pyplot as plt final_model grid.best_estimator_ pred_test final_model.predict(X_test) resid y_test - pred_test plt.figure(figsize(8, 5)) plt.scatter(pred_test, resid, alpha0.6) plt.axhline(y0, colorred, linestyle--) plt.xlabel(Predicted Charges) plt.ylabel(Residuals (y - pred)) plt.show()这段代码配合matplotlib直接出图不需要额外统计库。如果残差图右上角明显更稀疏或更发散可以按smoker和bmi分组再画一次通常会发现吸烟且高 BMI 的群体残差方差最高。另一种常用分组是age 60的人群因为老年人医疗花费的个体差异极大。定位到这些群体后下一步动作可以是增加他们的专属特征或者单独建一个子模型。5. 模型落盘与快速 API 化用 joblib 和 Flask 把预测变成可用服务模型调好之后不能永远活在 Jupyter Notebook 里。最常见做法是joblib保存模型再用 Flask 写一个 JSON 接口让业务系统或前端页面可以把一条人员信息传进来立刻返回预测花费。import joblib # 保存模型和标准化器 joblib.dump(final_model, medical_cost_model.joblib) joblib.dump(scaler, medical_cost_scaler.joblib)这里joblib.dump比pickle更适合 sklearn 模型因为它对大 numpy 数组的序列化效率更高。保存模型时一定要记住把训练时的scaler一起保存因为 API 接收的原始数据必须经过同一个scaler.transform才能送到模型里。如果漏掉了这一步接口传进来的age50会被模型当成标准化后的50来用预测结果会偏差到离谱。接下来用 Flask 写一个最小的 POST 接口from flask import Flask, request, jsonify import pandas as pd import joblib app Flask(__name__) model joblib.load(medical_cost_model.joblib) scaler joblib.load(medical_cost_scaler.joblib) num_cols [age, bmi, children] app.route(/predict, methods[POST]) def predict(): data request.get_json() df_input pd.DataFrame([data]) # 对分类字段做与训练时一致的编码 df_input[smoker] df_input[smoker].map({yes: 1, no: 0}) df_input[sex] df_input[sex].map({male: 1, female: 0}) df_input pd.get_dummies(df_input, columns[region]) # 补齐训练时出现的 region 列 for col in [region_northeast, region_northwest, region_southeast, region_southwest]: if col not in df_input.columns: df_input[col] 0 df_input df_input[[age, sex, bmi, children, smoker, region_northeast, region_northwest, region_southeast, region_southwest]] df_input[num_cols] scaler.transform(df_input[num_cols]) pred model.predict(df_input)[0] return jsonify({predicted_charges: round(pred, 2)}) if __name__ __main__: app.run(host0.0.0.0, port5000)这段代码有几个关键点请求体内的 JSON 必须包含训练时用到的全部字段map函数要兼容大小写region编码时可能漏掉某些类别所以要先用pd.get_dummies生成实际存在的分类列再补齐缺失的虚拟列。特征列的顺序也必须与训练时完全一致否则scaler.transform和model.predict会因为列位置错乱而静默出错。host0.0.0.0让服务可以被局域网访问本地调试时直接python app.py即可。验证接口是否正常不要用浏览器打开 GET 请求而是用命令行发送 POSTcurl -X POST http://127.0.0.1:5000/predict \ -H Content-Type: application/json \ -d {age:45,sex:male,bmi:28.5,children:1,smoker:yes,region:southeast}curl返回的 JSON 里就能看到预测花费。如果返回 500 错误优先检查字段是否缺、region值是否在训练集合里、以及scaler加载后是否成功 transform。还可以在predict函数开头打印收到的data用 Flask 的调试模式快速定位字段映射问题。对于更正式的部署可以把模型封装成一个类放进predictor.py再在 Flask 路由中调用避免每次请求都重新加载模型。生产环境建议用 gunicorn 启动多进程每个进程持有独立的模型副本接口吞吐量会远高于 Flask 自带 dev server。模型更新时只需要替换 joblib 文件并平滑重启进程不需要改动接口代码。这一步做到位一个基于 Python 的医疗花费预测模型才算真正交付给了业务方。本文还有配套的精品资源点击获取