简介:基于Python的医疗花费预测项目提供了一套完整的课程设计解决方案,面向机器学习初学者和有回归预测任务需求的高校学生,覆盖数据读取检查、模型构建调参与融合评估全流程,适合作为毕业设计或课程设计参考。资源共五个文件,主要包括两个Python源码脚本、一份设计报告Word文档及说明文件,压缩包大小仅1.29MB,结构精简便于快速上手。目前已有六百八十四人学习浏览。实现上采用全手写随机森林与线性回归,并结合机器学习库构建梯度提升树、支持向量回归、套索回归和决策树模型;调参环节同时使用随机搜索、网格搜索和手动调参三种策略,模型融合尝试直接平均、加权平均与堆叠法,配合K折交叉验证和留一法进行效果评估。读者可获得完整可运行代码与详细设计报告,对理解回归建模、参数优化和集成学习方法很有帮助。
1. 医疗花费预测是什么:一个回归问题,值钱的不是模型而是特征
如果你拿到一份医疗账单数据,想根据患者的年龄、BMI、吸烟史、区域等信息,提前估算出他下一年的医疗花费,这在机器学习里就是一个典型的回归任务。标题里的“基于Python的医疗花费预测”做的正是这件事:输入结构化表格数据,输出一个连续的金额数值。这个方向最常出现在健康险定价、医院费用审核、个人健康管理三个场景里,学生拿来练手也合适,因为数据量不大、特征维度不高,但要做对并不简单——真正的门槛在数据清洗、类别变量编码和特征构造上,模型反而是最不挑的部分。
适合看这篇的人有两类:一类是刚学完Python基础、想拿一个完整项目练手的数据分析初学者,另一类是已经在做保险或医疗数据分析、想把“拍脑袋估费用”升级成“用模型估费用”的从业者。我会按数据准备、建模训练、参数调优、踩坑复盘、模型解释的顺序,把整个落地路径讲透。
2. 数据准备:用 pandas 把原始账单清洗成可建模的特征
2.1 先看数据长什么样:缺失值、分布和单位
不管是哪个来源的医疗花费数据集,只要你拿到手的是一个 CSV 文件,第一件事永远是加载后用info()和describe()看结构,而不是急着建模。常见做法是先跑这段代码:
import pandas as pd # 读入数据,路径换成你本地的实际路径 df = pd.read_csv('insurance.csv') # 查看字段类型、非空数量,确认到底有没有缺失值 print(df.info()) # 查看数值列的分布,重点是 charges 是否严重右偏 print(df.describe()) # 看一眼类别字段都有哪些取值 for col in ['sex', 'smoker', 'region']: print(col, df[col].unique())逻辑说明:info()告诉你每一列的类型和缺失情况,如果某列非空数量少于总行数,说明有缺失。describe()能暴露一个很关键的问题——charges(医疗花费)的均值如果远大于中位数(50% 分位),说明数据右偏严重,后面需要做对数变换。
参数说明:这里不需要调任何参数,关键是观察。如果发现charges最大值是几万甚至几十万而中位数只有几千,就说明少数高额账单在主导整个分布,直接用原始值训练会让模型把所有注意力放在那几个大单上。
2.2 处理charges的右偏分布:为什么回归前先取对数
医疗花费数据几乎必然是右偏的——大多数人每年只花几千块,但少数重症患者会花掉几十万。如果不做处理,线性回归会被这些极端值拉着走,树模型也会被迫在分裂时过度关注大额样本。我一般会先画一个直方图或直接对charges取对数,看分布是否接近正态。
import numpy as np # 对目标变量做对数变换 df['charges_log'] = np.log1p(df['charges']) # 对比变换前后的偏度:变换前通常 > 5,变换后接近 0 print('变换前偏度:', df['charges'].skew()) print('变换后偏度:', df['charges_log'].skew())逻辑说明:np.log1p是log(1+x),比直接np.log(x)更稳,因为当x=0时log会报错,log1p不会。训练时用charges_log作为目标变量,预测时再做expm1反变换得到真实的金额。
参数说明:偏度(skewness)是判断是否需要变换的量化指标,绝对值大于 1 就该考虑变换;这里从 5.x 降到接近 0,效果立竿见影。注意反变换是np.expm1(pred),对应log1p。
2.3 类别变量编码:sex、smoker、region 不是随便 map 就完事
性别、是否吸烟、地区这三个字段在原始数据里是字符串,不能直接喂给 sklearn。新手最常见的做法是把sex映射成 0/1、smoker映射成 0/1,然后region也顺手 map 成 0/1/2/3——这个操作在smoker和sex上问题不大,但在region上就埋了雷:地区之间没有大小关系,region=3不代表比region=1“大”三倍。正确做法是独热编码。
# 对地区做独热编码,drop_first=True 避免共线性 df = pd.get_dummies(df, columns=['region'], drop_first=True) # 对二分类字段做标签编码 df['sex'] = df['sex'].map({'female': 1, 'male': 0}) df['smoker'] = df['smoker'].map({'yes': 1, 'no': 0}) print(df.head())逻辑说明:get_dummies会把region拆成region_northwest、region_southeast、region_southwest三列(因为drop_first=True去掉了第一个取值作为基准)。线性模型最怕 dummy 变量之间完全共线,drop_first就是干这个的。sex和smoker本身就是二分类,用map做标签编码足够。
参数说明:如果你用的是sklearn里的OneHotEncoder,记得设sparse_output=False(旧版本是sparse=False),否则返回的是稀疏矩阵,后续和 DataFrame 合并会绕手。这里drop_first=True之后,三列地区特征进入模型,每一列的含义都是“是否是某个地区”,解释性比一个编码数字强得多。
3. 建模与训练:线性回归、随机森林、XGBoost 三条路,先跑通再调参
3.1 划分数据集:不能 random_split 了事,要盯住 smoker 的分布
医疗花费数据集有一个特点:smoker是最强特征,对花费的影响远大于其他字段。如果划分训练集和测试集时让smoker的比例在两边失衡,会出现线下评估指标好看、线上预测一塌糊涂的“假阳性”。所以划分之后要检查分布。
from sklearn.model_selection import train_test_split # 特征列,注意把原始 charges 和变换后的 charges_log 都排除 feature_cols = [c for c in df.columns if c not in ['charges', 'charges_log']] X = df[feature_cols] y = df['charges_log'] # 分层抽样,按 smoker 列来保证训练/测试里吸烟者比例一致 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=df['smoker'] ) print('训练集 smoker 比例:', X_train['smoker'].mean()) print('测试集 smoker 比例:', X_test['smoker'].mean())逻辑说明:stratify=df['smoker']是这里的灵魂参数。它让切分后的训练集和测试集里smoker=1的比例和整个数据集一致。如果你不传这个参数,每次运行结果可能都差一点,遇到小数据集时差异更明显。
参数说明:random_state=42固定随机种子,保证复现。test_size=0.2是常规选择,数据只有一千多条时,8:2 划分比较合适;如果你有上万条数据,可以考虑test_size=0.3给测试集更多样本。划分完先打印比例确认,这一步是后面所有评估可信度的前提。
3.2 先把线性回归跑通当 baseline:预测的是对数,评估要反变换
很多教程一上来就上 XGBoost,但我建议先用线性回归跑一个基线。原因很朴素:线性回归结果最容易解释,如果连它都能得到还可以的分数,说明特征工程做得不错;如果它很差,正好暴露了哪些特征之间关系是非线性的,后面该往哪个方向使力。
from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error, r2_score import numpy as np # 训练线性回归 lr = LinearRegression() lr.fit(X_train, y_train) # 预测对数花费,再反变换回真实金额 pred_log = lr.predict(X_test) pred_charges = np.expm1(pred_log) # 用真实金额计算误差 rmse = np.sqrt(mean_squared_error(np.expm1(y_test), pred_charges)) r2 = r2_score(np.expm1(y_test), pred_charges) print(f'线性回归 RMSE: {rmse:.2f}, R2: {r2:.4f}')逻辑说明:训练目标y_train是取了对数的值,所以lr.predict出来的也是对数。评估时必须用np.expm1把两边都还原成真实金额,否则 RMSE 是在对数空间算的,数值看起来小得诱人,但没有任何实际意义。这是新手最容易翻车的地方。
参数说明:LinearRegression没有需要手工调的参数,核心是把目标变量处理好。如果这个 R² 低于 0.7,大概率是smoker与 BMI 之间存在交互效应——吸烟者的花费受 BMI 影响更大,而线性模型默认每个特征是独立起作用的,不会自动捕捉交互。
3.3 随机森林和 XGBoost:树模型怎么接住非线性关系
线性回归跑完后,上树模型。随机森林能自动捕捉特征交互,对异常值也不那么敏感,是医疗数据的稳妥选择。这里直接给一个用随机森林替换线性回归的最小流程:
from sklearn.ensemble import RandomForestRegressor rf = RandomForestRegressor(n_estimators=300, max_depth=8, min_samples_leaf=5, random_state=42) rf.fit(X_train, y_train) pred_log = rf.predict(X_test) pred_charges = np.expm1(pred_log) rmse = np.sqrt(mean_squared_error(np.expm1(y_test), pred_charges)) r2 = r2_score(np.expm1(y_test), pred_charges) print(f'随机森林 RMSE: {rmse:.2f}, R2: {r2:.4f}')逻辑说明:n_estimators=300表示 300 棵子树,max_depth=8限制树深防止过拟合,min_samples_leaf=5要求叶子节点至少 5 个样本,这三个参数配合起来,在大约一千条样本的数据集上比默认参数更稳。默认参数下树可以无限长,训练集分数接近满分,测试集却开始飘。
参数说明:max_depth是最值得调的参数,建议在 4 到 12 之间尝试;min_samples_leaf在 3 到 10 之间调。随机森林在这样规模的数据上训练极快,你可以放心跑多组组合做对比,不用心疼时间。
# 如果不确定参数,用网格搜索帮你跑组合 from sklearn.model_selection import GridSearchCV param_grid = { 'n_estimators': [200, 300], 'max_depth': [6, 8, 10], 'min_samples_leaf': [3, 5, 8] } gs = GridSearchCV(RandomForestRegressor(random_state=42), param_grid, cv=5, scoring='neg_root_mean_squared_error') gs.fit(X_train, y_train) print('最优参数:', gs.best_params_)逻辑说明:scoring='neg_root_mean_squared_error'是 GridSearchCV 里少数能直接显示“越小越好”的指标——sklearn 的 scorer 默认是“越大越好”,所以 RMSE 被取了负号,越接近 0 越好。cv=5在这么小的数据上是合理选择,折数太少评估不稳,太多则每折样本太少。
参数说明:如果你上 XGBoost,核心参数是learning_rate=0.05、max_depth=4、reg_lambda=1.0,其中学习率要往小调,深度要比随机森林更浅,因为 XGBoost 的迭代机制容易过拟合小数据集。
4. 必调参数与特征工程细节:模型效果差距全在这些地方
4.1 年龄分箱:把连续年龄变成有业务含义的区间
原始数据里age是连续值,但医疗花费和年龄的关系并不是线性的——儿童时期花费低,中年稳步上升,老年猛增。直接让模型去拟合这种曲线关系,线性模型做不到,树模型虽然能做,但会消耗更多深度。常见的做法是分箱,把年龄切成几段,彻底把非线性问题变成线性问题。
# 年龄分箱:18-30 是低花费段,30-50 是中段,50-65 是高风险段 df['age_group'] = pd.cut(df['age'], bins=[18, 30, 50, 65], labels=['young', 'mid', 'senior']) # 独热编码后塞回特征集 df = pd.get_dummies(df, columns=['age_group'], drop_first=True) # 注意:age 原始列可以选择保留或去掉,我建议保留,给模型多一点信息 print(df.columns.tolist())逻辑说明:pd.cut的bins定义了区间边界,[18, 30, 50, 65]表示三个区间:18-30、30-50、50-65。labels给区间起名,方便后面看特征重要性。分箱之后,age可以用原始值保留,也可以只保留分箱变量,我倾向两个都留,让模型自己决定怎么用——如果age本身没帮助,树模型会自动降低它的权重。
参数说明:区间边界你可以根据数据的实际分布调整,关键是不要切得太碎,比如每 5 岁一切,会造出八九个哑变量,在样本量不大的时候反而稀释了模型的泛化能力。三到四个区间在医疗数据上通常是够用的。
4.2 BMI 的交互特征:吸烟者与 BMI 的化学反应
医疗花费预测里有一个经典结论:同样 BMI 高的人,吸烟者的花费远高于不吸烟者。这不是两个特征独立起作用,而是它们“联手”起作用。线性回归对这种交互是无能为力的,除非你手动构造。
# 构造交互特征:吸烟与高 BMI 同时成立时,费用风险剧增 df['smoker_high_bmi'] = df['smoker'] * (df['bmi'] > 30).astype(int) # 对比构造前后,smoker_high_bmi 与 charges 的相关性 print('smoker 与 charges 相关系数:', df['smoker'].corr(df['charges'])) print('smoker_high_bmi 与 charges 相关系数:', df['smoker_high_bmi'].corr(df['charges']))逻辑说明:df['smoker']是 0/1,(df['bmi'] > 30).astype(int)也是 0/1,两者相乘得到一个新的 0/1 特征,只有“吸烟且 BMI 大于 30”时为 1。这一步把领域常识翻译成了模型能读的格式。
参数说明:BMI=30 是临床上定义的肥胖线,但你可以试 28、32,看模型分数的变化。如果相关性从 0.6 提升到 0.7,说明这个交互特征有效;如果几乎没变化,说明在你这份数据里两者并不联动,果断去掉,别硬留。
4.3 用 Lasso 做特征选择:砍掉冗余列,守住稳定性
当特征数量变多之后,一个容易被忽略的问题是特征之间的相关性——比如你同时保留了age和age_group_*多个变体,它们之间有信息重叠。线性模型会因此变得不稳定,换个训练集结果飘得厉害。Lasso 回归自带 L1 正则化,能把不重要的特征系数直接压成 0,是最省事的特征筛选工具。
from sklearn.linear_model import Lasso # 构造一个 Lasso,alpha 是正则强度,需要调 lasso = Lasso(alpha=0.05, max_iter=10000, random_state=42) lasso.fit(X_train, y_train) # 打印系数,接近 0 的特征基本可以扔掉 coef_df = pd.DataFrame({ 'feature': X_train.columns, 'coef': lasso.coef_ }) print(coef_df[abs(coef_df['coef']) > 0.01])逻辑说明:alpha=0.05是正则强度,值越大,被压成 0 的特征越多。跑完这个,你可以看到哪些列幸存下来、哪些列系数很小可以去掉。注意 Lasso 对特征之间的尺度敏感,跑之前最好对数值特征做标准化(StandardScaler),否则 BMI 数值大、smoker 数值小,惩罚会不公平地落在小数值特征上。
参数说明:alpha要从 0.001 开始按 10 倍往上试,找到“系数大部分非零但小特征已经被压掉”的那个值。max_iter=10000是收敛保护,数据只有一千多条时很少触发,但写上能避免警告。
5. 避坑清单:医疗花费预测里最容易翻车的 5 个细节
5.1 反变换翻车:预测结果差了一个量级
现象:模型训练时 RMSE 很好看,但预测出来的金额全是几十、几百美元,而实际账单均值是几千美元。
原因:训练目标用了np.log1p取对数,预测时直接用了lr.predict(X_test)的结果,没有做np.expm1反变换。对数空间里误差是“小”,还原到真实金额后自然对不上。
解决:所有评估都必须写成np.expm1(model.predict(X_test)),并且要在同一个脚本里测试反变换后与np.expm1(y_test)的 RMSE,不要只看对数空间的分数。
5.2 独热编码的共线性:region 三列全进去了
现象:线性回归的系数非常大,且不同随机种子下系数符号都在翻转。
原因:pd.get_dummies没有加drop_first=True,生成了全部四个地区的 0/1 列,它们之间存在完全共线性,导致线性模型无法稳定求解系数。
解决:统一使用drop_first=True。如果你用OneHotEncoder,对应参数是drop='first'。这一步不会影响树模型,但对线性回归是必须的。
5.3 smoker 比例失衡:测试集评估分数虚高
现象:交叉验证的分数和最终测试分数差很多,有时候测试分数反而更高。
原因:train_test_split没有传stratify,由于数据量小,随机切分后测试集里恰好都是不吸烟者——不吸烟者花费本来就低,预测“低费用”比预测“高费用”容易得多,分数自然偏高。
解决:划分数据时stratify=df['smoker']。更稳妥的做法是同时检查sex、region的比例,任何一列分布漂移超过 2 个百分点,就说明切分有问题。
5.4 缺失值盲目均值填充:BMI 分布被压扁
现象:模型训练完,特征重要性里bmi几乎为 0,但常识里 BMI 应该很重要。
原因:缺失的 BMI 被用均值填充,填充后大部分样本都集中在均值附近,方差大幅减小,模型当然学不到信息。
解决:先看缺失比例。如果低于 5%,用中位数填充问题不大;高于 10%,考虑用其他特征(age、smoker)做分组填充,或者干脆把缺失当成一个单独状态,不填充而是加一列“是否缺失”。在医疗数据里,缺失本身常常就是信息。
5.5 RMSE 被大额账单主导:指标选择不当
现象:换一个模型,RMSE 降了 2000,但画出来真实值 vs 预测值的散点图,中低费用段拟合得很差。
原因:RMSE 对误差取平方后,大误差样本的权重大幅增加,少数几十万的账单会让模型牺牲大量普通样本的精度来迁就它们。
解决:同时报告 MAE 和 RMSLE(Root Mean Squared Logarithmic Error)。RMSLE 在目标取对数后计算,天然降权大额账单。评估时用mean_squared_log_error加上自定义开方,或者直接比较 R² 和 MAE 两个指标——如果 RMSE 好但 MAE 差,说明模型只在少数大单上准,多数普通账单其实不准。
6. 进阶技巧:用 SHAP 解释模型,让预测结果不再像黑匣子
模型跑通只是第一步,医疗花费预测的实际价值在“解释”——保险定价时要跟业务方解释为什么这个人保费高,医院审核时要说明为什么这笔费用异常。这个方向我最后的建议是:花半小时把 SHAP 用起来,它能告诉你在某一笔具体预测里,每个特征分别推了多少数值。
import shap # 用训练好的随机森林计算 SHAP 值 explainer = shap.TreeExplainer(rf) shap_values = explainer.shap_values(X_test) # 画 summary plot:看所有样本里每个特征的影响力方向 shap.summary_plot(shap_values, X_test, plot_type='bar') # 看某一个具体样本的解释 shap.force_plot(explainer.expected_value, shap_values[0, :], X_test.iloc[0, :])逻辑说明:TreeExplainer是专门针对树模型的高效实现,计算速度快。summary_plot的 bar 图告诉你全局哪个特征最重要——通常结果里smoker排第一,age和bmi紧随其后。force_plot告诉你单个样本里每个特征的贡献方向,红色的推高费用,蓝色的压低费用,这比任何特征重要性矩阵都直观。
参数说明:shap_values[0, :]是第一个样本的解释结果,X_test.iloc[0, :]是该样本的特征值。如果你发现某条预测里smoker=1把费用推高了 3 万美元,这个结论可以直接写进报告。
我一直有这个习惯:模型训练完先不急着调参,而是先跑一遍 SHAP。因为解释结果能反过来告诉我特征工程哪里做得不够——如果bmi的贡献集中在中低区间,说明高 BMI 段的样本量不足;如果age_group_senior贡献最高,说明年龄段划分还不够细。它是我验证特征工程质量的第二双眼睛,比单看分数可靠得多。最后收个尾:用 Python 做医疗花费预测并不难,难的是从数据里挖出真正影响费用的业务因素。这个方向即使你只是练手,也建议把流程走完整——数据清洗、对数变换、交互特征、模型对比、误差分析、模型解释一个不落。真正投入生产时,你缺的从来不是模型,而是这套工程习惯。希望帮到你。
本文还有配套的精品资源,点击获取