去年整理移动硬盘里的旧代码时,我翻到一份早年做的人均预期寿命分析项目,数据是从公开渠道下载的WHO面板数据,代码写得比较青涩,但整个“数据清洗—探索性分析—建模—时间趋势解读”的链路相当完整。我用现在的思路重新跑了一遍,顺便把踩过的坑都记了下来。这篇文章就是一个完整的复盘:基于Python和数据挖掘方法,分析人均预期寿命的变化情况,重点放在怎么从原始表格数据里得到可解释、可信赖的结论,而不是机械地堆模型分数。
如果你是刚学Python数据分析、准备做课程设计,或者想用公开数据练手数据挖掘全流程,这篇应该能给你省不少事。文章不会只贴代码,我会把每个环节“为什么这么做”也讲清楚,毕竟这个项目真正的难度不在跑通代码,而在特征处理和数据理解。
1. 从“想分析寿命”到选定公开数据:项目目标、数据集与第一次翻车
1.1 项目诉求与数据来源
这个项目的目标很直白:分析人均预期寿命的变化趋势,同时找出哪些可量化的指标与寿命高低关系最密切。很多人一听到“变化”两个字就想着做时间序列预测,但真实的数据挖掘流程里,第一步永远是搞清楚数据形态。
我用的是Kaggle上常见的“Life Expectancy Data”数据集,来源是WHO的年度统计,内含193个国家和地区在2000年到2015年间的健康与经济指标。一条记录代表“某个国家在某一年”的观测,总计2938行、22列,这个结构和很多医疗健康类面板数据很像:有连续特征(成人死亡率、BMI、GDP、教育年限),也有一段时期不太变化的分组变量(国家、年份、国家类型)。
下载之后马上发现一个问题:数据列名包含空格,比如 Life expectancy、Adult Mortality、percentage expenditure,如果直接 df["Life expectancy"] 也能用,但后续一旦写循环或者做特征列表,空格就是定时炸弹。我习惯在加载时直接用 str.strip().replace(" ", "_") 统一列名,后面所有代码都能省心不少。
1.2 环境准备用到的核心库
项目本身不需要重型环境,我用的是 Python 3.9 + pandas + numpy + matplotlib + seaborn + scikit-learn。如果你的机器上还没有这些库,只需要在终端中执行下面这组安装命令:
bash复制pip install pandas numpy matplotlib seaborn scikit-learn
如果下载速度慢,可以加 -i https://pypi.tuna.tsinghua.edu.cn/simple 切换镜像源。这个项目没有用深度学习框架,原因是后文会讲到,几十个特征的面板数据用随机森林、梯度提升这类模型就足够,不需要引入复杂工具链。
数据加载和结构检查的代码如下:
python复制import pandas as pd
import numpy as np
df = pd.read_csv("Life Expectancy Data.csv")
df.columns = [c.strip().replace(" ", "_") for c in df.columns]
print(df.shape)
print(df.dtypes)
print(df.isnull().sum().sort_values(ascending=False))
第一次跑 isnull().sum() 时,我愣了一下:Life_expectancy 这个预测目标本身居然也有缺失行。原因是数据集里某些国家的某些年份没统计到寿命值,另外还有少量记录其实不是国家维度,而是“全球总体”和“区域汇总”之类的行。做单国家分析时必须把这些行剔掉,否则一个“全球平均”会被当成一个叫“Global”的“国家”,直接污染国家维度统计。
1.3 为什么没有直接上LSTM
很多人看到“人均预期寿命变化分析”会本能地想到LSTM、时间序列预测,我也曾经踩过这个惯性思维的坑。问题是:这个数据集里每个国家只有2000到2015年共16个时间点,有些年份还有缺失,用单国家序列去做LSTM,本质上是在用极少量样本拟合一个高复杂度模型,结果基本不具备泛化意义。
正确的切法有两类:一是把问题当成回归任务,用当年各项指标预测当年寿命,看变量关系;二是把时间作为分组维度,用面板数据的年份切片看均值变化和改善幅度。这个项目两种都做了,但都没有依赖LSTM。前者用树模型,后者用描述统计和简单趋势外推,既稳定又容易向非技术背景的人解释。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 清洗阶段最关键的几个动作:缺失值不能靠“一句中位数填充”带过
2.1 缺失值分布背后的含义
这组数据的缺失模式比一般教材里的示例要复杂。表面上看只是某些列的NaN数量偏多,但拆开细看会发现三条规律:第一,Income_composition_of_resources和Schooling缺失比例相对偏高,而且不是随机缺失,往往是低收入国家或早期年份更容易没上报数据;第二,Hepatitis_B、Polio、Diphtheria等免疫覆盖率指标也有缺失,但范围不算大;第三,GDP、Population、BMI有少量零星缺口。
如果直接对全表做一次 df.fillna(df.median()),确实能让代码跑通,却会在分析里埋雷——当某国某年的关键变量是伪造的中位数时,模型会误以为这个国家当年的医疗或经济特征处于普通水平,从而拉近它与典型样本的距离。
我在这个项目里采用的分层处理策略是:
Life_expectancy为空的行直接删除,因为它是监督学习的目标值;Status缺失的极少,删除或补齐都影响不大;- 对
Schooling、Income_composition_of_resources这类缺失偏高但又对寿命预测很有解释力的变量,优先按“国家分组内的中位数”填充; - 实在没有同国家历史值的,再退回到全样本中位数。
分组填充的核心代码大概长这样:
python复制# 先用国家分组中位数填充,避免用全局信息掩盖国家差异
for col in ["Schooling", "Income_composition_of_resources"]:
df[col] = df.groupby("Country")[col].transform(
lambda s: s.fillna(s.median())
)
# 个别国家没有任何非缺失记录时,再用全局中位数兜底
df[col] = df[col].fillna(df[col].median())
这里的逻辑是:同一个人均寿命水平相近的国家之间,教育年限或资源分配结构往往更有可比性,直接用全球中位数去填一个非洲国家和一个欧洲国家,会把真实地区差异抹平。当然,transform和直接fillna的差别新手很容易忽略,建议自己动手打印填充前后的分组均值确认一下。
2.2 冗余特征和多重共线性问题
清洗阶段我注意到一对高度相关的特征:infant_deaths(婴儿死亡数)和under_five_deaths(五岁以下死亡数)。从医学常识看,五岁以下死亡数几乎包含了婴儿死亡数,所以这两个变量的相关系数超过0.95并不奇怪。
如果做线性回归,这种多重共线性会让系数的标准误变大,甚至出现符号反直觉的情况;但随机森林这类树模型对共线性不那么敏感,顶多是重要性在两个特征之间被分摊。考虑到项目最终会同时用线性模型和树模型做对照,我把under_five_deaths从建模特征中剔除,保留infant_deaths,这样既降低冗余,又不损失太多信息。
2.3 处理类别变量与构造派生字段
数据里的Status是文本类型,取值为Developed和Developing,中文可以理解为“发达国家和发展中地区分组”。模型不能直接消费文本,我把它映射成0和1。Country列虽然有193个取值,但我没有把它直接做成哑变量放进回归模型,因为那会让线性回归的特征矩阵变得非常稀疏,而且容易过拟合。
为了做“变化分析”,我额外构造了两个派生字段:把年份映射成相对年份,即 2000年=0、2001年=1……,方便观察线性趋势;同时计算每个国家在数据期内寿命极差,形成一个“是否出现显著拐点”的辅助判断列。后者主要用于探索性分析,并不进入预测模型。
3. EDA阶段看到的几个结论:相关不等于因果,但别忽略数据里的“反常识”
3.1 寿命整体分布与年份位移
清洗后我用一行代码画了分布对比:
python复制import matplotlib.pyplot as plt
import seaborn as sns
plt.figure(figsize=(8, 5))
sns.kdeplot(df[df["Year"] == 2000]["Life_expectancy"], label="2000")
sns.kdeplot(df[df["Year"] == 2015]["Life_expectancy"], label="2015")
plt.legend()
plt.show()
两条密度曲线对比非常直观:2015年的曲线整体向右移动,峰值也从72岁附近挪到75岁附近,低寿命端的“长尾巴”明显变短。这说明2000年到2015年间,人均预期寿命的改善不是某个区域的孤例,而是广泛的系统性变化。计算各年份均值后发现,全样本平均寿命从66.8岁附近上升到71.4岁左右,十几年间提高了近5岁。这个数字本身可以作为后续报告里的核心摘要。
3.2 几个容易误导人的相关性
我最初看相关系数矩阵时,差点得出“酒精摄入越多寿命越长”的荒谬结论。细想之后才意识到,Alcohol人均消费较高的国家往往同时是收入较高的国家,而收入、教育、医疗可及性才是延长寿命的主因。简单相关系数把间接路径也混在了一起,这就是为什么后来一定要用多变量模型来联合控制。
真正在单变量分析与模型中表现都很稳定的特征有三个:Adult_Mortality(成人死亡率)、HIV/AIDS、Schooling。成人死亡率与寿命的相关系数达到-0.69左右,意思非常直白;HIV/AIDS特征反映的是每10万人中因艾滋病相关原因死亡的人数,在部分高负担国家里寿命出现明显凹坑,这个变量对解释区域差异贡献很大。Schooling与寿命正相关,说明教育水平通过健康意识、收入等路径间接影响寿命,背后的数据支持较强。
3.3 对“国家类型”分组的观察
如果只按Status分组画箱线图,会看到两类国家在中位数上相差约8到10岁,但这不是什么新鲜结论。新鲜的是按年份看差距变化:在2000年,两类国家平均寿命差距大约在9岁左右;到2015年,这个差缩小到了7岁上下。主要驱动力是发展中地区组的提升幅度更大。这一结果后来也被时间维度分析再次验证。
需要提醒的是,这类分组描述只能展示“平均水平”,不能直接说成政策评价,因为每类国家内部差异极大,比如同一组里的不同地区在医疗水平、传染病负担上可能相差悬殊。在做项目报告时,我会把这类结论严格限定在“数据所描述的平均趋势”上,避免过度解读。
4. 建模阶段关键教训:同一国家的不同年份会泄露信息,模型对比要看场景
4.1 一个很容易被忽略的“数据泄漏”问题
很多人做这类面板数据建模时,直接 train_test_split(df, test_size=0.2, random_state=42),然后拿到很高的R²就非常开心。我在最初版本里也这么干过,测试集R²一度达到0.96,但总感觉哪里不对。
问题出在随机切分把同一个国家的不同年份同时分到了训练集和测试集。比如中国2005年的数据和2010年的数据本来就很相似,模型在训练时已经见过这个国家其他年份的形态,再拿2010年来测,等于开卷考试,分数自然虚高。
正确的做法是按国家划分样本:训练集里出现的国家,测试集里绝不能出现。这样模型面对的是“没见过的国家”,才更接近真实的应用场景。我用 GroupShuffleSplit 实现:
python复制from sklearn.model_selection import GroupShuffleSplit
gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
train_idx, test_idx = next(gss.split(df, groups=df["Country"]))
train = df.iloc[train_idx]
test = df.iloc[test_idx]
改完以后,模型测试集R²从0.96降到0.93左右,但我觉得这个数字更诚实。做项目报告时如果能主动讲清楚这种划分带来的差异,会显得你对数据挖掘的理解明显高一个层次。
4.2 线性回归与随机森林的表现对比
我选了四类模型做对比:普通线性回归、Ridge回归、随机森林回归、XGBoost回归。统一使用的特征包括成人死亡率、HIV/AIDS、教育年限、BMI、GDP取对数、人口取对数、酒精、免疫覆盖率、婴儿死亡数、国家类型、相对年份等,目标变量是Life_expectancy。
最终结果大致如下:
| 模型 | 测试集R² | MAE | 说明 |
|---|---|---|---|
| 线性回归 | 0.82 | 3.4 | 可解释性强,适合报告系数 |
| Ridge回归 | 0.83 | 3.3 | 略微改善多重共线性影响 |
| 随机森林 | 0.93 | 2.1 | 默认参数效果已不错 |
| XGBoost | 0.94 | 2.0 | 比随机森林略好但耗时更高 |
需要注意,这里的MAE单位是“年”,2.1年的平均绝对误差意味着模型预测某国寿命时,平均偏差在2年左右。对于一个国家层面的宏观指标来说,这个精度已经足够用来做因素分析,但别指望它精确到个体。
4.3 特征重要性与线性系数的交叉验证
我特意同时打印了随机森林的特征重要性和线性回归的标准系数,而不是只看其中一种。因为树模型的重要性容易偏向数值型特征和高基数特征,线性模型的标准化系数则能提供方向信息。两者结论交叉印证时,证据更可信。
随机森林这边,排名前三的是Adult_Mortality、HIV/AIDS、Schooling;线性回归的标准化系数显示,成人死亡率系数显著为负、教育年限显著为正,方向完全一致。这个一致性让整个项目最核心的结论变得很扎实:十几年的寿命变化背后,压死骆驼的是成人死亡率和重大传染病负担,而教育资源的增加是最明显的积极力量。
5. 从“预测寿命”回到“变化分析”:时间维度怎么看才不空泛
5.1 模型讲关系,趋势讲过程
如果项目只停留在“随机森林R²=0.93”,那更像机器学习练习,而不是“人均预期寿命变化分析”。人均寿命变化的完整叙事应该包括:整体提升了多少、哪些群体的提升大、影响提升的因素在时间序列上是如何变化的。因此建模之后,我把视角切回时间维度。
把数据按年份聚合后,算每年的平均寿命、中位数以及25%分位数和75%分位数,能明显看到整体曲线呈上升趋势。2000年到2006年之间的曲线上行相对平缓,2006年之后上升斜率变大,这和几个大规模健康干预项目在时间上的重叠基本一致。
在项目正文里,我会这样表述:全样本平均寿命由约66.8岁增至约71.4岁;其中“国家类型”中的发展中分组贡献了主要增量;成人死亡率在时间轴上的下降趋势与寿命上升趋势呈明显镜像关系。这种表述既客观又不空洞。
5.2 分国家的改善幅度排序
只看全球均值,容易掩盖区域差异。我把每个国家2000年与2015年的寿命做差,生成一个change列,然后按这个差值排序,查看改善最快和最慢的国家。这里要提醒自己:起点低的国家本身有更大的上升空间,所以最好同时展示起点值和变化值,防止读者误读排名。
可视化上,我选择了散点图:横轴是2000年的寿命值,纵轴是2015年相对于2000年的提升值。这样能看到一种“负相关”的形态:起点越低的国家普遍提升越大,起点已经很高的国家反而提升空间有限。这是典型的“追赶效应”在数据上的体现,写在分析结论里会是不错的洞察。
5.3 如何把原始表格变成报告里的可视化
对于最终报告,我保留了三种图:一是分年份的核密度图,表现整体右移;二是分国家类型的箱线图,表现水平差距和收敛趋势;三是关键特征与寿命的分组折线图,比如把教育年限分成低、中、高三档后画寿命均值变化,会发现高教育组寿命曲线始终在上方且波动更小。每一张图背后都对应一个可操作的pandas分组操作,而不是随便画几张看不出信息的图。
6. 直接可复现的流程与避坑清单:这个项目还能怎么改
6.1 完整流程的浓缩版
如果你不是想一步步跟着读,而是希望拿着思路直接动手,可以按这个流程走:
- 加载数据,统一列名,查看缺失、类型与重复行;
- 删除预测目标为空的样本,剔除“全球/区域合计”类行;
- 对高缺失特征按国家分组中位数填充,剩余缺失用全局中位数兜底;
- 删除明显冗余特征,比如五岁以下死亡数;
- 映射类别变量,构造相对年份;
- 做探索性分析,重点看寿命分布变化、关键变量相关性与分组差异;
- 按国家分组划分训练集与测试集;
- 训练线性回归、Ridge、随机森林、XGBoost并对比;
- 用特征重要性与线性系数交叉验证结论;
- 回到时间维度,计算年份均值和各国提升幅度,输出可视化与报告。
6.2 实际项目中踩过的几个坑
这个项目里最值钱的不是代码,而是几个隐藏坑。第一,不要把数据里的“全球”行当成一个国家,否则按国家聚合时会多出一个奇怪的样本;第二,处理缺失值的时候,先分组再填充比直接全局中位数填充科学得多;第三,如果报告里要写变量影响方向,必须在线性模型里看标准化系数,而不是看随机森林的特征重要性,因为树模型只给强度、不给方向。
另外,如果要把模型用于预测未来年份,比如预测2025年,需要格外小心:这个数据集的自变量本身也是未来需要估计的量,不可能先知道未来的HIV/AIDS死亡率和教育年限。现实项目中更多是用于复盘和因素分析,而不是做严格意义上的未来预测。把这些边界条件写清楚,才是负责任的数据分析。
6.3 还能怎么扩展
拿这个项目做毕设或课程设计的话,可以考虑扩展三个方向:引入更多年份形成长面板后做差分分析;对区域做聚类,再分别建模,看看不同区域的寿命影响因素是否不同;或者把单年截面数据与地理可视化结合,画一张世界地图上的寿命分布变化。这些扩展没有改变核心方法,却能让整个项目显得更有层次。
我个人在实际操作中的体会是,这种公开数据项目的核心价值不在于把某个指标预测到小数点后两位,而在于通过一条完整的数据挖掘流水线,讲清楚一个社会现象背后的量化逻辑。如果你也想练手,建议拿到数据后先别急着写模型,多花时间在缺失值分布和分组对比上,这些不起眼的细节才是决定项目质量上限的地方。
