生育率下降是被反复讨论的人口话题,马斯克关于“生育率崩溃比黑死病更致命”的说法更是把这一话题推到公共视野。先不评价这个历史比较是否准确,单从技术角度来看,这句话背后的核心变量是“总和生育率”。对数据分析师和技术开发人员来说,比起争论观点,更容易推进的方式是:把生育率数据下载下来,清洗成可用样本,按时序和区域做趋势分析,再用可视化表达结果。这篇文章会围绕这条主线,使用 Python、Pandas 和 Matplotlib 完成一个可复现的生育率数据分析小项目。
读者可以结合自己的实际需求,把本文的操作扩展成人口统计看板、报告自动刷新脚本,或者教学用的数据分析案例。学完后,你会得到一套从数据获取到图表展示的完整链路,同时也能理解总和生育率、更替水平、数据缺失和趋势解读这些关键概念。
1. 先理解“生育率崩溃”真正对应的指标是什么
1.1 总和生育率不是“某年生了多少孩子”
总和生育率(Total Fertility Rate,简称 TFR)指的是:如果一批女性按照某个年份的年龄别生育率走完整个生育周期,平均每位女性会生育多少个孩子。它是一个时期指标,反映的是“当前这一年各年龄段女性的生育强度”,并不是真实某一批人最终生育的子女数。
这个区别非常重要。某个年份的 TFR 特别低,不一定等于未来人口一定会快速减少。因为人口变化还受年龄结构、预期寿命、迁移和生育年龄推迟等因素影响。但 TFR 仍然是判断生育水平最常用、最适合跨地区比较的指标。
更替水平决定人口长期稳定所需的最低生育水平。在低死亡率环境下,普遍认为总和生育率大约需要达到 2.1,也就是平均每位女性生育 2.1 个孩子,才能保证下一代人口规模不出现长期自然减少。这个 2.1 并不是物理常数,而是由出生性别比和死亡率共同决定的。
1.2 数据能验证什么,不能验证什么
用公开数据可以验证下面几种问题:
- 全球范围内低生育率地区是否在扩大。
- 不同国家总和生育率在过去几十年里的下降速度。
- 当前低于更替水平的国家数量和人口覆盖范围。
- 区域之间的差异是否明显。
但数据不能直接验证“生育率崩溃比黑死病更致命”这种历史比较。因为黑死病时期的死亡数据、人口基数和统计口径与现代完全不同,直接比较会引入大量方法学问题。更合理的做法是先把“生育率是否在长期走低”这个可计算的部分分析清楚,再谈政策和社会影响。
1.3 本文分析主线
后面所有内容都围绕一条分析流程展开:
- 从开放数据源获取总和生育率时间序列。
- 将宽表结构整理成适合分析的长表结构。
- 检查缺失值、异常值和区域编码。
- 按年份计算低于更替水平的国家数量。
- 绘制时间趋势图和区域对比图。
- 对结果做谨慎解读,并说明数据边界。
这条流程可以复用到出生率、死亡率、预期寿命、GDP 等任何具有“国家-年份-指标”结构的公开数据上。
2. 准备分析环境:Python 数据分析的最小组合
2.1 环境要求
建议使用 Python 3.10 或更高版本。核心依赖只有三个:
| 依赖 | 用途 |
|---|---|
| pandas | 数据读取、清洗、透视和分组计算 |
| requests | 请求开放数据接口,下载 JSON 或 CSV |
| matplotlib | 绘制趋势折线图和分布图 |
安装依赖:
pip install pandas requests matplotlib如果网络环境访问外网受限,可以将数据文件下载后放到本地目录,把后面的代码改成读取本地 CSV 即可。
2.2 数据源选择
世界银行开放数据提供了大量人口指标,其中总和生育率对应的指标代码是SP.DYN.TFRT.IN。该指标返回各国家和地区的年度总和生育率,时间跨度通常从 1960 年开始,更新周期以世界银行数据库发布节奏为准。
除了世界银行,联合国《世界人口展望》也提供历史估计和预测数据,更适合做长期人口预测。对本文演示来说,世界银行接口足够,因为它返回结构简单,适合用 requests 直接处理。
字段含义:
| 字段 | 含义 |
|---|---|
| country_code | 国家或地区 ISO3 编码 |
| country_name | 国家或地区名称 |
| year | 年份 |
| fertility_rate | 当年总和生育率 |
2.3 推荐项目结构
建议把脚本拆成模块,方便后续扩展:
fertility_analysis/ ├── data/ │ └── raw/ │ └── fertility_raw.csv ├── output/ │ └── fertility_trend.png ├── download_data.py ├── analyze_fertility.py └── requirements.txt第一次运行下载脚本时,把原始数据保存到data/raw/,之后不要再重复请求接口。这既方便离线分析,也能避免频繁访问外部服务。
2.4 环境检查清单
开始写代码前,可以按下面的顺序检查一次环境:
python --version能正常输出 Python 版本。pip --version能正常输出 pip 版本。- 上面三个依赖都已安装。
- 当前目录有
data/raw文件夹。 - 网络可以访问世界银行开放数据接口。
这项检查看起来基础,但很多人是在跑代码报ModuleNotFoundError之后才发现环境没有对齐。
3. 下载并加载生育率数据
3.1 使用 requests 拉取世界银行接口
世界银行接口支持 JSON 格式返回。下面的脚本会请求全量数据,并把核心字段整理成 DataFrame:
import requests import pandas as pd WORLD_BANK_URL = ( "https://api.worldbank.org/v2/country/all/indicator/SP.DYN.TFRT.IN" "?format=json&per_page=20000" ) def download_fertility_data(url=WORLD_BANK_URL): response = requests.get(url, timeout=30) response.raise_for_status() payload = response.json() records = [] # World Bank JSON 返回结构: [元信息, 数据列表] for item in payload[1]: records.append({ "country_code": item.get("countryiso3code"), "country_name": item.get("country", {}).get("value"), "year": item.get("date"), "fertility_rate": item.get("value"), }) df = pd.DataFrame(records) df["year"] = pd.to_numeric(df["year"], errors="coerce") df["fertility_rate"] = pd.to_numeric(df["fertility_rate"], errors="coerce") return df.dropna(subset=["year"]) if __name__ == "__main__": df = download_fertility_data() df.to_csv("data/raw/fertility_raw.csv", index=False, encoding="utf-8") print(df.sample(5))这段代码做了三件事:
- 请求全量数据,
per_page=20000是为了避免分页截断。 - 从嵌套 JSON 中提取国家、年份和数值。
- 把年份和数值转成数值类型,方便后续排序和计算。
注意,fertility_rate为空是常见情况,不一定表示缺失,有些年份数据没有发布或估计值。清洗时不要把NaN当成 0。
3.2 如果接口不可用,使用本地 CSV 替代
如果暂时不能访问接口,也可以准备一份结构相同的最小 CSV:
country_code,country_name,year,fertility_rate KOR,South Korea,2020,0.84 JPN,Japan,2020,1.33 USA,United States,2020,1.64 SWE,Sweden,2020,1.66 NGA,Nigeria,2020,5.29读取方式与上面保持一致:
df = pd.read_csv("data/raw/fertility_raw.csv")上面数值只用于演示分析流程,不代表最新官方数据。实际分析时,要以你下载到的原始文件为准。
3.3 保存原始数据后立即检查行数
下载完成后,不要急着画图。先执行一次基础检查:
print("共 {} 行记录".format(len(df))) print("年份范围: {} - {}".format(df["year"].min(), df["year"].max())) print("国家数量: {}".format(df["country_code"].nunique()))预期结果应是一个 1960 年到最近年份的完整时间序列。如果年份范围异常,比如只有最近两年,很可能是请求参数或接口返回结构出了问题。
4. 数据清洗与质量检查:先解决缺失值和区域编码
4.1 删除空值并确认时间列是数值类型
世界银行返回的数据中,某些国家在某些年份没有数值。清理时适合只保留有fertility_rate的行:
df = df.dropna(subset=["fertility_rate"]) df["year"] = df["year"].astype(int)把年份转成整数后,排序和分组会更快。如果后续要画时间轴,直接使用整数年份最省事。
4.2 区分主权国家与区域汇总
世界银行数据里既包含“China”这样的国家,也包含“East Asia & Pacific”这样的区域汇总,还包含“World”“High income”这类经济分组。分析低生育率国家数量时,如果不过滤区域汇总,会把“World”这样的实体也当成一个国家,影响统计结果。
可以先用一个列表排除常见汇总编码:
aggregate_codes = { "ARB", "CEB", "CSS", "EAP", "EAS", "ECS", "EMU", "EUU", "FCS", "HIC", "HPC", "IBD", "IBT", "IDA", "IDB", "IDX", "LAC", "LCN", "LDC", "LIC", "LMC", "LMY", "LTE", "MEA", "MIC", "MNA", "NAC", "OED", "OSS", "PRE", "PSS", "SAS", "SSA", "SSF", "SST", "TEA", "TES", "TLA", "TMN", "TSA", "TSS", "UMC", "WLD", } df_country = df[~df["country_code"].isin(aggregate_codes)].copy()这个列表并不一定完整,后续一定要用df_country["country_name"].unique()人工检查一遍,确认没有把“World”“Euro area”之类的汇总实体混进来。
4.3 保留一个同时包含区域汇总的版本
如果想在图中展示区域整体趋势,不要直接丢掉汇总编码。可以保留两个 DataFrame:
df_all = df.copy() df_country = df[~df["country_code"].isin(aggregate_codes)].copy()df_all用于观察区域和全球整体,df_country用于统计国家数量。这样能避免“宏观趋势”和“国家分布”互相污染。
4.4 常见数据质量问题
| 问题现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 缺失年份很多 | 部分国家数据发布滞后 | 按国家查看年份范围 | 不强行插值,图上标出缺失区间 |
| 某些年份数值异常小 | 国家规模小或数据估计不足 | 查看该国全时间序列 | 结合人口权重观察,不单独判断 |
| 出现多个区域汇总 | 接口返回包含经济分组 | 输出编码去重 | 保留国家样本时过滤汇总编码 |
| 年份显示为字符串 | JSON 中 date 是文本 | 检查 dtype | 使用pd.to_numeric转换 |
这里的经验是:不要只关注“能不能出图”,要关注“每一个点的含义是否清楚”。人口数据是宏观数据,一个异常值可能不是误差,而是一个小国或特殊年份的真实表现。
5. 从时间维度计算生育率趋势
5.1 计算每一年低于更替水平的国家数量
先把总和生育率与更替水平 2.1 进行比较,生成一个布尔列:
replacement_level = 2.1 df_country["below_replacement"] = df_country["fertility_rate"] < replacement_level yearly = ( df_country[df_country["year"] >= 1980] .groupby("year") .agg( total_countries=("country_code", "nunique"), below_countries=("below_replacement", "sum"), ) .reset_index() ) yearly["below_ratio"] = yearly["below_countries"] / yearly["total_countries"] print(yearly.tail(10))这里有一个细节:below_replacement是 bool 列,sum()会把True按 1 求和,所以below_countries表示当年低于更替水平的国家数量。分母使用nunique,避免同一个国家在同一年出现多行记录。
5.2 找出下降幅度最大的国家或地区
想要看哪些国家的生育率下降最明显,需要把最早年份和最近年份对齐:
first_year = df_country["year"].min() last_year = df_country["year"].max() df_first = df_country[df_country["year"] == first_year][["country_code", "fertility_rate"]] df_last = df_country[df_country["year"] == last_year][["country_code", "fertility_rate"]] merged = df_first.merge(df_last, on="country_code", suffixes=("_first", "_last")) merged["change"] = merged["fertility_rate_last"] - merged["fertility_rate_first"] print(merged.sort_values("change").head(10))这里的change是负数时表示下降。结果里会出现变动很大的国家,原因可能包括统计口径变化、冲突导致的人口迁移、数据缺失等。所以看到“某个国家生育率下降 5 个点”时,要先检查该国在两个年份的样本量,不要直接写结论。
5.3 区分不同区域的平均水平
如果需要看区域趋势,可以基于区域汇总数据做平均:
region_cols = ["East Asia & Pacific", "Europe & Central Asia", "World"] region_df = df_all[df_all["country_name"].isin(region_cols)].copy() region_pivot = region_df.pivot_table( index="year", columns="country_name", values="fertility_rate" ) print(region_pivot.tail(5))pivot_table会把“年份”放在行、把“区域名称”放在列。这样直接生成的数据结构非常适合下一步画图。
5.4 输出一份可审查的结果表
分析脚本最好输出一份 CSV,方便人工复核:
yearly.to_csv("output/yearly_below_replacement.csv", index=False, encoding="utf-8-sig")使用utf-8-sig编码,可以让 Excel 直接打开时不出现中文乱码。这是数据导出时非常实用的小技巧。
6. 可视化:用三张图说明生育率下降是否普遍
6.1 第一张图:全球国家“低于更替水平”比例
下面的折线图展示 1980 年以来低于更替水平的国家占比变化:
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] # Windows 常见中文字体 plt.rcParams["axes.unicode_minus"] = False # 解决负号显示问题 fig, ax = plt.subplots(figsize=(10, 5)) ax.plot(yearly["year"], yearly["below_ratio"], linewidth=2) ax.axhline(0.5, linestyle="--", color="gray", linewidth=1) ax.set_title("Yearly Share of Countries with TFR below 2.1") ax.set_xlabel("Year") ax.set_ylabel("Share of Countries") ax.grid(True, linestyle=":", alpha=0.6) fig.tight_layout() fig.savefig("output/below_replacement_share.png", dpi=150) plt.show()ax.axhline(0.5)的作用是画一条 50% 参考线。它可以快速看出“超过一半国家低于更替水平”的时间点,比单纯看数据表格更直观。
6.2 第二张图:典型国家的时间序列对比
选择若干有代表性的国家,画在同一条横轴上:
focus_countries = ["South Korea", "Japan", "United States", "Sweden", "Nigeria"] focus_df = df_country[df_country["country_name"].isin(focus_countries)] fig, ax = plt.subplots(figsize=(10, 5)) for country_name, group in focus_df.groupby("country_name"): ax.plot(group["year"], group["fertility_rate"], label=country_name, linewidth=2) ax.axhline(replacement_level, linestyle="--", color="gray", linewidth=1) ax.text(1960, replacement_level + 0.1, "replacement level 2.1") ax.set_title("Total Fertility Rate Trends for Selected Countries") ax.set_xlabel("Year") ax.set_ylabel("Total Fertility Rate") ax.legend() ax.grid(True, linestyle=":", alpha=0.6) fig.tight_layout() fig.savefig("output/selected_country_trends.png", dpi=150) plt.show()从图中能直观看出:低生育率并不是最近一两年才出现的现象,而是长期趋势。同时也能看到各国下降速度不一样,不是所有国家都处于同一个阶段。
6.3 第三张图:最新年份的国家分布
要判断“现在有多少国家处于低生育率”,可以把最新年份的数据按数值区间分组:
latest = df_country[df_country["year"] == last_year].copy() bins = [0, 1.3, 2.1, 3.0, 10] labels = ["very low (<1.3)", "below replacement (1.3-2.1)", "near 2.1-3.0", "above 3.0"] latest["level_group"] = pd.cut(latest["fertility_rate"], bins=bins, labels=labels) level_counts = latest["level_group"].value_counts().sort_index() fig, ax = plt.subplots(figsize=(8, 5)) level_counts.plot(kind="bar", ax=ax, color=["#d62728", "#ff7f0e", "#2ca02c", "#1f77b4"]) ax.set_title("Distribution of Countries by TFR in Latest Year") ax.set_xlabel("Fertility Level Group") ax.set_ylabel("Number of Countries") ax.grid(True, axis="y", linestyle=":", alpha=0.6) fig.tight_layout() fig.savefig("output/fertility_level_distribution.png", dpi=150) plt.show()pd.cut是最常用的连续变量分箱函数。这里的分组阈值是演示用的,实际分析中可以根据研究目的调整。例如把生育率低于 1.3 定义为“极低生育率”,这是人口学中经常提到的概念之一。
6.4 如何解读图形
如果低于更替水平的国家占比长期上升,说明“低生育率普遍化”确实是一个可观察的数据现象。但要注意,图形展示的是“国家数量占比”,不是“人口数量占比”。
一个小国占国家总数的一半,其人口可能只占全球人口很小一部分。因此,如果需要回答“全球多少人生活在低生育率地区”,要按人口加权计算,不能只看国家数量。这一步是很多分析出错的地方。
7. 常见坑与排查路径
7.1 接口返回结构变化或超时
现象:请求世界银行接口时超时,或返回的 JSON 不是预期的两层结构。
可能原因:
- 网络不稳定。
- 请求参数写错,比如
per_page拼写错误。 - 接口返回错误信息而不是数据。
检查方式:
print(response.status_code) print(response.text[:500])解决方案:先打印原始响应,确认返回是否正常。如果网络受限,建议直接在浏览器中下载 CSV,改为本地读取。不要在脚本里反复重试接口,应设置超时和重试上限。
7.2 同一国家同一年出现多行
现象:groupby后的国家数量明显比实际国家数多。
可能原因:世界银行返回的数据中包含区域汇总,或者原始 CSV 中预留了多级表头。
检查方式:
duplicated = df_country.duplicated(subset=["country_code", "year"]).sum() print(duplicated)解决方案:先去除汇总编码,再对重复行去重。
7.3 中文标签在图片上显示为方框
现象:图形标题中文变成方框或乱码。
可能原因:matplotlib 默认字体不含中文字符。
检查方式:
import matplotlib.font_manager as fm fonts = [f.name for f in fm.fontManager.ttflist if "Hei" in f.name] print(fonts[:10])解决方案:
- Windows 设置
SimHei。 - macOS 设置
Arial Unicode MS或PingFang SC。 - Linux 安装中文字体后重新设置字体。
另一个更稳妥的方案是图表全部使用英文标题,避免字体问题。这样代码在不同系统上运行结果更一致。
7.4 数据到底应不应该插值
人口数据中会出现国家某年缺失的情况。不要把缺失值随便用前后平均值填上,因为生育率趋势可能是非线性的,插值会产生并不存在的“平滑数据”。如果一定要补,至少要标注“该值是插值得到的”,并在报告中说明插值方法。
7.5 排查路径速查表
| 问题现象 | 优先检查 | 下一步 |
|---|---|---|
| 数据集为空 | 接口返回状态 | 打印响应文本 |
| 年份不连续 | 数据源覆盖范围 | 查看原始文档 |
| 国家数量异常 | 汇总编码混入 | 打印编码去重列表 |
| 出图中文乱码 | 中文字体未设置 | 改用英文字体或安装字体 |
| 结论与国家报告不一致 | 统计口径不同 | 确认年份和区域口径 |
8. 从分析回到判断:数据边界与扩展方向
8.1 现有分析能支持什么结论
通过上面的流程,能支持这类结论:在某些年份区间内,全球低于更替水平的国家数量明显增加,不同区域之间的总和生育率差异也很大。这类结论是描述性的,不涉及因果关系。
不能直接得出的结论包括:
- 生育率下降一定会导致人口崩溃。
- 某一国家生育率低就一定会影响长期经济增长。
- 历史时期的人口死亡事件与现代生育率可以直接比较。
因此,写分析报告时,措辞要落在具体的统计结果上,例如“在最新年份,本数据集中有多少国家低于 2.1”,而不是“生育率已经崩溃”。
8.2 生产环境中的数据任务还要补充什么
如果这是一次性的学习分析,上面脚本已经够用。但如果要长期监控生育率变化,还需要考虑:
- 原始数据落盘:每次下载都保留带时间戳的原始文件。
- 定时任务:使用 cron 或 Airflow 定期拉取。
- 日志与告警:记录请求失败、数据缺失率和异常波动。
- 版本管理:固定依赖版本,避免 pandas 或 matplotlib 升级后结果变化。
- 权重字段:如果要看人口覆盖比例,需要导入各国人口数据。
下面是一个简单的调度示例(Linux cron):
0 2 1 * * cd /path/to/fertility_analysis && /usr/bin/python download_data.py这样每月 1 日自动拉取一次数据,再执行分析。
8.3 下一步扩展方向
如果想把分析做得更深入,可以按下面的顺序扩展:
- 加入人口权重,计算“全球人口中生活在低生育率国家的比例”。
- 加入年龄结构数据,绘制人口金字塔。
- 使用联合国《世界人口展望》的预测数据,观察低生育率的长期影响。
- 对比出生率、死亡率、净迁移率,构建更完整的人口变化模型。
- 把分析结果接入 Web 框架,做成一个小型数据看板。
8.4 对新手最重要的练习建议
不要把目光只停留在画图上。真正值得反复练习的是:拿到一份陌生数据后,先描述它是什么结构,再清洗,再验证,最后才画图。每一步都要问“这个字段为什么会为空”“这个异常值说明什么”“这个分组是否合理”。
生育率数据只是其中一个合适的练习对象。用同样的流程去分析其他人口指标,可以更快掌握数据分析的基本功。回到开头那句“生育率崩溃比黑死病更致命”,与其急着站队,不如先把数据拿到手里,用统计结果去检验它背后的长期趋势。这种行为模式,才是做技术分析最有价值的地方。