☰
LSTM城市人口预测:从数据预处理到滚动回测避坑指南
2026/10/18 9:41:10 网站建设 项目流程

简介:一套基于MATLAB的LSTM城市人口预测完整代码包,面向需要处理时间序列预测与神经网络建模的本科及以上学生或研究人员。城市人口数据具有明显时序特性,长短期记忆网络能有效捕捉长期依赖关系,相比普通神经网络更契合此类预测任务。压缩包内共38个文件,包含main1.m、main2.m两个主程序,以及MSE_RMSE_MBE_MAE.m、R_2.m等误差与拟合优度评估脚本,配套人口数据xlsx表格和多张运行结果图(jpg/png),共553KB,结构清晰、便于对照与迁移。代码带有详细注释,数据完整,可帮助读者快速跑通LSTM训练-验证-预测流程,并进一步扩展至其他城市或相近时间序列场景。目前已有525人学习浏览,适合作为课程设计、毕设或论文仿真基线。

1. 基于 LSTM 的城市人口预测:为什么是 LSTM,以及谁能直接上手

基于 LSTM 的城市人口预测,本质是把一个城市过去几十年的常住人口序列当作带记忆的时间序列,用循环神经网络去学习人口惯性、出生潮的滞后效应和城市化拐点,再给出未来 3 到 5 年的预测值。比起 ARIMA 这类线性模型,LSTM 不强制要求序列差分后平稳,能自动保留“十几年前的出生高峰会影响今天的劳动年龄人口”这类长距离依赖,这是人口预测里最值钱的能力。

这套方案适合两类人:一类是城市规划、交通、公共设施领域的技术人员,需要把人口结果放进业务决策;另一类是刚接触时序预测、手里有多年人口年鉴但不知道从哪一步开始写的开发者。它不需要 GPU,单机 CPU 就能训练完,真正的门槛不在模型结构,而在数据切分、窗口设置和指标定义上。下面按一条完整落地链路讲:先想清楚数据,再写代码,最后用滚动验证检验模型有没有学偏。

2. 人口数据预处理与评价指标:决定模型学的是趋势还是噪声

2.1 人口序列的三个结构性特征:惯性、滞后效应与政策断点

城市人口序列和股票、天气这类高频时序最大的区别,是低频、强惯性和弱周期性。常住人口每一年变化相对平缓,除非发生行政区划调整或者大规模产业迁入迁出,否则相邻年份的差值通常控制在百分之几以内。这种强惯性对预测是双刃剑:它让短期预测容易做,但也容易让模型偷懒——直接复制上一年数值就能拿到很低的误差,根本没有学到趋势。

滞后效应是人口预测里最需要窗口去覆盖的部分。一个城市在某一年的生育高峰,会在十几年后变成劳动力供给上的一个凸起;一次大规模人口导入,会在入学率、购房需求上滞后显现。LSTM 的优势在于能通过循环结构保留这些历史信息,但前提是输入窗口够长。如果只看过去 3 年,模型永远不知道 15 年前的生育高峰正在进入劳动年龄,这比任何网络结构问题都致命。

政策断点则是人口预测里最难处理的部分。撤县设区、新区成立、重大产业项目落地,都会让历史趋势被瞬间改写。LSTM 不能预测“还没发生的政策”,但可以通过多情景输入把断点变成条件预测:预测时把未来政策影响作为外生变量拼进特征,比让模型自己硬猜靠谱得多。实际项目中,我一般把“无重大政策扰动”作为基准情景,再叠加一个“政策落地”情景做区间。

数据处理上还要注意年鉴数据的质量。很多城市公开的常住人口数据存在缺年、口径调整、前后不一致的问题。缺一两年可以用线性插值补;如果中间断了五年以上,插值就是在编数据,不如把断点前后拆成分段模型,分别训练分别预测。另外,人口口径要统一,常住人口和户籍人口混用会让模型学到错误的跳变。

2.2 训练集/测试集划分与归一化:测试集绝不能提前参与统计

时序预测最忌讳随机打乱划分。人口数据样本量本身就不大,一年一条记录,几十年才几十条,如果随机抽取训练集和测试集,模型等于看到了未来的数据,测试集的误差会漂亮得很虚假。正确做法是按时间顺序切分:前 70% 到 80% 做训练,后 20% 到 30% 做测试,测试周期通常对应最近 10 到 15 年。

归一化也同样要按训练集来 fit,再对测试集只做 transform,而不是先把全部数据 MinMaxScaler 完再切分。原因在于,测试集最大值参与了缩放映射,相当于把未来的人口的规模信息提前告诉模型,这属于数据泄漏。一旦往后做滚动预测,这套泄漏会导致预测区间系统性偏离真实分布。

我习惯先切分,再在训练集上fit_transform,然后在测试集上transform,每一步都保持训练信息不向前渗透。

from sklearn.preprocessing import MinMaxScaler split = int(len(population) * 0.8) train_raw = population[:split] test_raw = population[split:] scaler = MinMaxScaler() train_scaled = scaler.fit_transform(train_raw.reshape(-1, 1)) test_scaled = scaler.transform(test_raw.reshape(-1, 1))

这段代码的关键在于test_raw只调用transform,没有调用fit_transform。如果写成scaler.fit_transform(全体数据),测试集的最大最小值就已经参与训练样本的映射了,这一点在人口这类小样本问题上影响尤其明显。

2.3 指标用 MAPE、RMSE 还是 SMAPE:人口预测中的选择

人口预测常用的三个指标各有各的盲区。RMSE 的单位是“人”,能直观告诉你平均差了多少人,但它对极端年份比较敏感,一个行政区划调整造成的异常跳变,会把整体误差拉高。MAPE 是相对误差,适合向业务方解释“预测偏差百分之几”,但当真实人口值很小、基数低的时候,分母小了,误差会被放大成看似很糟糕的结果。

更稳妥的是同时看两个层面:整体误差看 RMSE 或 MAPE,单点极端误差看最大偏差出现在哪一年,并用 SMAPE 做对称化处理。SMAPE 把真实值和预测值都放进分母,避免 MAPE 在低基数年份被放大。人口预测项目里,我一般以最近 5 年的 MAPE 作为主指标,因为远期误差必然累积,只报全周期平均会掩盖“越预测越偏”的趋势。

十次有八次,模型调参半天不如检查指标怎么算的来得有效。固定一个 80/20 的时间顺序切分,然后用 MAPE 对比 LSTM 和“未来五年均值”这种基线,如果 LSTM 连均值基线都没跑赢,说明网络根本没学到人口变化的增量信息。

3. 基于 LSTM 的城市人口预测代码实现:从 CSV 到未来五年预测曲线

3.1 读取人口数据并补齐缺失年份

先准备一份干净的 CSV,常见格式是两列:year和population,年份按升序排列。我先用 pandas 读进来,转成时间索引,再检查缺失值和排序,这一步比后续调参重要得多。

import numpy as np import pandas as pd df = pd.read_csv('city_population.csv') df['year'] = pd.to_datetime(df['year'], format='%Y') df.set_index('year', inplace=True) df = df.sort_index() print(df.isnull().sum()) print(df.shape) # 缺一两年用线性插值补齐,缺太多则考虑分段建模 if df['population'].isnull().any(): df['population'] = df['population'].interpolate(method='linear')

年份转成 datetime 索引是为了后续画图、切分都方便。interpolate只适合缺失一两年且前后趋势连续的情况;中间断档超过三年,插值出来的数据就是编故事,不如把序列在断点处拆开。人口序列一般是逐年记录,样本量不大,处理完这一步,把population转成 numpy 数组准备构造样本。

3.2 构造监督学习样本:look_back 窗口怎么定

LSTM 不能直接吃一整条序列,需要把时序变成“用过去 N 年预测接下来 1 年”的监督学习样本。这里的 N 就是look_back窗口。窗口值直接决定模型能看到多远的过去,比 LSTM 的units还值得先定。

def build_samples(series, look_back=10): X, y = [], [] for i in range(len(series) - look_back): X.append(series[i:i + look_back]) y.append(series[i + look_back]) return np.array(X), np.array(y)

窗口太短,比如 3 到 5,模型只能看到最近的波动,学不到人口滞后效应;窗口太长,比如 30,样本数量会大幅缩水。一般 40 到 60 多年的数据,取 10 到 15 是比较均衡的选择。函数返回的 X 是 (样本数, look_back, 1) 之前的二维数组,接下来需要 reshape 成 LSTM 要求的三维输入。

look_back = 10 train_scaled = train_scaled.reshape(-1) X_train, y_train = build_samples(train_scaled, look_back) X_test, y_test = build_samples(test_scaled, look_back) X_train = X_train.reshape((-1, look_back, 1)) X_test = X_test.reshape((-1, look_back, 1))

注意,test_scaled在进入build_samples之前已经是独立缩放过的数据,长度比原始测试集短look_back年,因为前look_back年只能作为输入窗口、没有对应标签。训练集同理,最终参与训练的有效样本数是“总年数减去 look_back 再做切分”,这个数据量心里要有数。

3.3 搭建两层 LSTM 网络:结构、dropout 与早停参数

人口数据量不大,网络不需要堆得很深。两层 LSTM 加 dropout 已经能覆盖大多数情况,第一层返回完整序列,让第二层继续提炼时间依赖,最后一层接 Dense 输出单一数值。

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping model = Sequential([ LSTM(64, return_sequences=True, input_shape=(look_back, 1)), Dropout(0.2), LSTM(32), Dropout(0.2), Dense(1) ]) model.compile(optimizer='adam', loss='mse')

第一层return_sequences=True是两层 LSTM 正确堆叠的前提,写成 False 第二层只会拿到单个输出向量,维度对不上。dropout 在人口小样本上必须开,否则很容易过拟合到训练集的曲线细节上,验证集误差会明显高于训练集。batch_size设小一点,比如 8 到 16,因为年数据本质上样本数很少,大批量会让梯度更新过大、训练不稳定。

训练时用早停卡验证集损失,防止模型在训练集上反复磨细节:

early_stop = EarlyStopping( monitor='val_loss', patience=50, restore_best_weights=True ) history = model.fit( X_train, y_train, validation_split=0.15, epochs=500, batch_size=8, callbacks=[early_stop], verbose=1 )

validation_split=0.15是在训练集末尾再切一段做验证,严格按时间顺序,不随机打乱。patience=50意味着验证损失连续 50 轮不下降就停,配合restore_best_weights把最优权重找回。人口预测特征少,模型训练很快,早停参数不需要过于激进,给足 50 轮耐心,反而更容易学到趋势而不是噪声。

3.4 预测未来五年并反归一化:递归输出与误差观察

模型训练完之后,要做的是把训练集最后look_back年作为种子,一步步往下预测。每一步把预测值拼回窗口尾部,丢掉最前面的旧年份,形成递归预测。

future_steps = 5 seed = train_scaled[-look_back:].reshape(1, look_back, 1) future_scaled = [] for _ in range(future_steps): next_value = model.predict(seed, verbose=0)[0, 0] future_scaled.append(next_value) seed = np.concatenate( [seed[:, 1:, :], np.array([[[next_value]]])], axis=1 ) future_values = scaler.inverse_transform( np.array(future_scaled).reshape(-1, 1) ).reshape(-1) print(future_values)

这段代码的关键在seed更新的写法:seed[:, 1:, :]丢掉第一个时间步,然后拼接上刚预测出的值,窗口长度始终保持look_back。反归一化用训练时的scaler做inverse_transform,此时预测值已经回到“人”这个量纲,才能和真实年鉴数据对比。

评估时不要只画曲线,要把测试期真实值和预测值逐个对应算误差:

from sklearn.metrics import mean_absolute_error pred_scaled = [] seed = train_scaled[-look_back:].reshape(1, look_back, 1) for real_value in test_scaled: p = model.predict(seed, verbose=0)[0, 0] pred_scaled.append(p) seed = np.concatenate( [seed[:, 1:, :], real_value.reshape(1, 1, 1)], axis=1 ) pred_values = scaler.inverse_transform( np.array(pred_scaled).reshape(-1, 1) ).reshape(-1) real_values = scaler.inverse_transform(test_scaled).reshape(-1) print("MAE:", mean_absolute_error(real_values, pred_values))

这一段测试时用的是真实值做回填,而不是把模型自己的预测继续喂回去。这种方式叫单步滚动评估,能反映出模型“每一年看到真实人口后修正一次”的表现,适合检验模型本身有没有学到规律。真正预测未来时没有真实值可回填,才用上一小节的纯递归模式。

4. LSTM 人口预测避坑指南:5 个反复出现的翻车现场

4.1 预测曲线整体右移一个时间步

现象:画出来的预测曲线和真实曲线形状几乎一致,但整体错开了一年,看起来像把真实曲线往右推了一格。很多新手以为这是预测提前或滞后,实际是模型在专门偷懒。

原因:单步回归问题里,上一年的数值本身就是一个极强的输入特征。人口变化平缓时,复制上一年值就能把损失降到很低,模型学到的最优策略就是恒等映射,根本没去学趋势和拐点。这种情况在人口这类强惯性序列上发生率极高。

解决:不要只看曲线趋势,去看预测值之间的一阶差分。如果模型输出的差分值几乎接近零,说明它在复制输入而非预测变化。更直接的办法是用一阶差分序列作为训练目标,也就是把连续两年人口的差值当作标签,LSTM 只负责学“变化量”,最后再把差值累加回真实值,这样模型没法靠复制输入蒙混过关。

4.2 在全量数据上做归一化导致数据泄漏

现象:单独看训练集误差不大,但等到预测未来年份时,输出一直落在历史数据的量级范围内,尤其当未来人口突破了历史最大值时,预测值会被压制在归一化上限以下。

原因:把整条人口序列放进MinMaxScaler再切分训练集和测试集,测试集的最大值已经写进归一化器的scale_和min_里。模型预测未来时,反归一化出来的值永远不可能超过当年历史最大值,等于提前把未来上限暴露给了模型。

解决:严格按时间切分,先train_raw做fit_transform,再对test_raw和未来输入只做transform。如果发现未来预测值被顶在某个边界上,第一反应就是去检查scaler是何时 fit 的,这几乎是人口预测里最常见的隐性错误。

4.3 预测结果是一条平直线

现象:训练过程正常,损失也在下降,但预测未来五年输出是一条水平线,数值接近训练集均值,完全看不出趋势。

原因:模型学到了一个“输出均值”的安全策略。人口曲线变化平缓时,预测均值比预测任何波动都更不容易出错;LSTM 的激活函数把输出压在一个区间内,如果信号本身不强,网络倾向于收敛到一个常数上。学习率过大、dropout 过高也会加剧这个问题,但根本原因还是增量信息太弱。

解决:先用未来五年均值作为基线,如果 LSTM 连基线都没超过,就说明模型结构没有学到趋势信息。调整方向有三个:把训练目标从绝对人口改成差分变化量、降低units减少模型自由度、把epochs给足并设置较小的learning_rate。另外检查一下归一化后的训练集方差,如果所有值都在 0.4 到 0.6 之间,模型很难从这么窄的区间提取有效规律,这时差分处理比调结构更优先。

4.4 多步预测后期误差急速放大

现象:第 1 年预测误差很小,第 3 年开始明显跑偏,到第 5 年已经偏离真实趋势,误差呈现递增放大的喇叭状。

原因:递归预测时,每一步预测误差都会作为下一步的输入,误差沿着时间方向累积。早期预测偏高一点,后面年份就会一直带着这个偏高记忆,越滚越大。这不是 LSTM 特有的问题,任何递归式多步预测都存在误差漂移。

解决:限制递归步数,单次最多预测 3 到 5 年,更长的预测用滚动回测不断修正。另一个做法是改用多步直接预测,把模型的输出维度设计成未来 5 年的五个值,用Dense(5)一次性输出,这样误差不会逐年传递。实际项目里,我很少做超过五年的递归预测,因为城市规划通常以五年为一个规划周期,三五年足够支撑业务决策。

4.5 用打乱顺序的交叉验证评估时序模型

现象:使用 K 折交叉验证、随机抽取测试集后,模型在验证集上表现不错,但一放到真实滚动预测里就偏离明显。

原因:人口序列的时间顺序本身就是信息。随机打乱后,训练集里混入了未来年份,模型提前看过答案;预测时真正面对的未来年份不在历史分布内,自然全线翻车。

解决:评估时序模型只能用时间顺序切分,不能随机 K 折。常见做法是扩展窗口回测:固定训练起始点,逐步加入新数据,每次只预测下一个年份,得到一组连续评估误差。这种方法比单次切分更能反映模型在真实场景中的稳定性,对人口这类低频序列尤其友好。

5. 让预测结果更可信的进阶验证:滚动回测与多步输出

5.1 滚动回测:每来一年真实数据就重训一次

单次 80/20 切分只能得到一条误差曲线,不能证明模型在不同时期都稳定。我一般会再做一轮滚动回测:从历史 70% 的位置开始,逐年扩展训练集,每扩展一年重训一次模型,预测下一年真实值。这个循环在人口数据上成本很低,样本小、训练快,却能暴露模型在趋势拐点上的真实表现。

def build_lstm_model(look_back=10): model = Sequential([ LSTM(64, return_sequences=True, input_shape=(look_back, 1)), Dropout(0.2), LSTM(32), Dropout(0.2), Dense(1) ]) model.compile(optimizer='adam', loss='mse') return model actuals, predictions = [], [] start_idx = int(len(population) * 0.7) for t in range(start_idx, len(population)): train_block = population[:t].reshape(-1, 1) scaler = MinMaxScaler() train_block_scaled = scaler.fit_transform(train_block).reshape(-1) X, y = build_samples(train_block_scaled, look_back=10) model = build_lstm_model(look_back=10) model.fit(X, y, epochs=100, batch_size=8, verbose=0) seed = train_block_scaled[-10:].reshape(1, 10, 1) p = model.predict(seed, verbose=0)[0, 0] predictions.append(scaler.inverse_transform([[p]])[0, 0]) actuals.append(population[t])

这个循环里每个t都是一个新的训练起点,scaler也要重新 fit,保证任何一步都不会用到t之后的数据。最终得到一条逐年预测误差序列,比单次切分更能说明模型是否值得上线。缺点是一次运行要训练几十次模型,但人口样本量小,训练速度完全能接受。

对比单次切分和滚动回测的误差曲线后,如果后者误差明显更大,说明模型对训练集尾部的数据段特别敏感,这时要回到数据预处理环节重新检查,而不是继续调网络结构。滚动回测的价值就是逼你面对真实场景中的预测难度。

5.2 多步预测输出与业务使用边界

滚动回测确认模型稳定之后,再用递归方式预测未来五年。我会把输出结果分成两列:基准值和一个合理波动区间,区间宽度参考最近五年的绝对误差来设。人口预测不该给出一个精确的单一数字,因为行政区划调整、产业政策这些外生因素无法从历史序列中学到。

我习惯把 LSTM 的结果当作“无重大政策扰动”下的自然增长曲线,再结合规划部门给出的新城区人口导入目标做一高一低两个情景。业务决策里,基础设施容量按照区间上沿来预留,财政投入按照基准值来测算,这样既不为极小概率事件过度投资,也不至于漏掉明显的增长空间。

用这套滚动回测加多步输出的流程做过几个不同城市的人口预测模拟之后,我再看到任何“效果惊艳”的单次预测曲线,都会先问一句它的评估方式是不是时间顺序切分。这个习惯帮我避开了不少不靠谱的模型方案。数据量小、趋势强、突变多,是城市人口预测的三个天然难点,LSTM 能解决的是前两个,第三个要靠业务经验去修正。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询