
做城市面板数据分析这两年一个很深的体会是数据来源越来越杂指标越来越多但真正能落地的结论反而不好找。你手上可能有几十个城市、十多年、上百个经济和宜居指标但如果只是跑一个线性回归或者画几张折线图领导不买账论文也不够看。所以我把这套完整链路整理成了一个项目核心是“算法簇智能交互”用PGSA-Kmeans做城市分型用CNN做宜居度综合打分用ARIMA和GM(1,1)分工做趋势预测最后用DeepSeek把一堆表格结果翻译成能对话的分析结论。这篇文章就是完整复盘适合正在做城市数据分析、面板数据建模或者想把传统统计模型和生成式AI串成产品的研究生、数据分析师来参考。1. 项目缘起与整体设计1.1 为什么要做“城市分型打分预测”三位一体城市经济与宜居度分析本质上是一个“先分类、再评价、后预测”的问题。经济指标和宜居指标不是一回事GDP高的城市不一定宜居空气质量好的城市可能产业单薄。如果把它们放在一个模型里硬拟合结果往往四不像。所以项目的第一层设计就是通过聚类把城市分成几个类型类型内部的样本共性更强后续建模才更有意义。第二层是打分。聚类能告诉你“这城市是哪一类”但不能告诉你“这一类里谁的宜居度更高”更不能解释为什么高。这里引入CNN做宜居度预测本质上是在挖掘特征之间的非线性关系。经济指标、人口结构、环境指标之间的交互效应传统线性模型很难捕捉而CNN通过卷积核的局部感知能力可以把这种交互模式提炼出来。第三层是预测。城市规划、政策评估都需要看趋势但经济类指标和宜居类指标的时序特征完全不同。GDP这类有长期趋势、季节波动明显的指标ARIMA很擅长而那些样本短、更新慢、颗粒度粗的宜居指标比如建成区绿化覆盖率、万人拥有公共交通车辆数GM(1,1)灰色预测反而更合适。两个模型搭配才能覆盖全场景。1.2 技术选型背后的三次取舍第一次取舍是聚类算法。普通K-means最大的问题是对初始质心敏感跑十次可能出十个不同的结果而且容易陷入局部最优。我采用了PGSA植物生长模拟算法去优化K-means的初始质心和聚类中心相当于用仿生优化算法代替随机初始化迭代稳定性明显提升。后面细讲。第二次取舍是预测模型。ARIMA需要序列足够长且平稳处理长期趋势要先差分面对短序列就是废柴GM(1,1)只需要4期以上数据就能建模但它的指数外推特性决定了它不适合长期预测。所以我的分工原则是长度超过36期、趋势明显的经济指标走ARIMA长度在4到15期、变化平缓的宜居指标走GM(1,1)。第三次取舍是交互层。最开始我只做了静态图表后来发现业务方只看结论不关心代码。所以我加了Gradio界面再接入DeepSeek API。本地模型负责计算DeepSeek负责把计算结果组织成自然语言。真正适合落地的架构不是让大模型去算而是让大模型去“说”——计算归计算表达归表达。2. 数据底座的搭建高维面板数据的清洗与特征工程2.1 面板数据怎么组织才不乱城市经济与宜居度数据是典型的面板数据三维结构城市、时间、指标。我在项目里统一处理成两张表。一张是宽表每行是一个城市每列是一个指标适合聚类和CNN输入。另一张是长表字段包含city、year、indicator、value适合时间序列建模。需要注意宽表和长表之间要能互相转换。我建议做数据清洗时统一用长表因为缺失值插补、异常值替换对长表更友好处理完再用pivot变成宽表。实际代码只有一行wide_df long_df.pivot_table(index[city], columns[indicator], valuesvalue)宽表的行是城市列是经济指标和宜居度指标缺失值用NaN表示。项目里的指标约40个其中经济类包括GDP、人均GDP、第二三产业占比、财政收入、固定资产投资增速、进出口总额等宜居类包括PM2.5年均浓度、空气质量优良天数、人均公园绿地面积、每万人医疗机构床位数、每万人中小学教师数、房价收入比、城市拥堵指数等。指标维度不算夸张但处理不好照样跑偏。2.2 缺失值与异常值的处理方案城市数据最头疼的就是缺失值。地级市的统计年鉴经常缺数据有些城市某一年份整列为空。直接删行太浪费均值填充又容易抹掉城市间的真实差异。我用的方案是指标值连续5年以上缺失的直接在特征集中剔除个别年份缺失的优先用同一城市前后两年的均值做线性插值实在没有前后值的用同类型城市的当年均值填充。异常值处理我用的是IQR四分位距方法。以房价收入比为例正常城市在5到25之间但个别城市可能突然蹦到40多这种明显异常的样本会严重干扰聚类和CNN训练。具体处理是把超过Q31.5倍IQR的值替换为边界值而不是直接删除保留城市的相对位置信息。2.3 无量纲化与特征压缩经济类指标和宜居类指标的单位完全不同有亿元、有百分比、有微克每立方米直接丢进模型会让数量级大的指标主导距离计算。我统一采用Z-score归一化from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_scaled scaler.fit_transform(X)归一化之后我又做了一步特征压缩。40多个指标之间存在明显相关性比如GDP和第二产业产值几乎线性相关PM2.5与空气质量优良天数高度负相关。如果全部保留不仅计算量大聚类时还会重复加权。我用的是PCA主成分分析降维保留累计方差贡献率达到85%的主成分实际跑下来40个指标压缩到12到15个主成分。这一步对后续CNN训练也有帮助能减少过拟合风险。提示聚类和CNN输入用的都是降维后的特征但ARIMA和GM(1,1)建模时必须用原始指标因为预测要输出真实量纲的值比如下一年GDP预测几万亿就是几万亿不能是归一化后的抽象数字。3. PGSA-Kmeans让聚类不再吃“初始化”的亏3.1 普通K-means的痛点以及PGSA怎么补位K-means的思路很直白把样本分成K组让组内平方和最小。但它有个老毛病——对初始质心敏感。初始质心选不好迭代就会收敛到局部最优。比如城市聚类你明明知道“一线城市”和“资源型城市”应该是两个簇但如果初始质心都落在中西部迭代半天可能把上海和某资源型城市分到一类。PGSAPlant Growth Simulation Algorithm植物生长模拟算法解决的就是这个问题。它的思想是模拟植物茎生长点向光生长的过程把优化问题的解空间比作植物生长的环境用一个“形态素浓度”来决定下一次迭代在哪个方向搜索。浓度高的位置生长的概率大对应到聚类问题就是在类内误差较大的区域周围优先搜索新的候选质心。这样算法在前期会做一次全局搜索大概率跳出局部最优再逐步收敛到全局最优解。数学上它比粒子群算法PSO实现简单比遗传算法GA参数少实际迭代效果很稳。3.2 PGSA-Kmeans核心代码实现我封装了一个PGSA优化K-means的类核心部分如下import numpy as np from sklearn.cluster import KMeans class PGSAKMeans: def __init__(self, n_clusters5, pop_size30, max_iter50, random_state42): self.n_clusters n_clusters self.pop_size pop_size self.max_iter max_iter self.random_state random_state def _init_population(self, X): 随机生成多个候选质心集合作为初始种群 n_samples X.shape[0] population [] for _ in range(self.pop_size): indices np.random.choice(n_samples, self.n_clusters, replaceFalse) population.append(X[indices]) return population def _compute_fitness(self, X, centers): 适应度函数类内距离平方和的倒数值越大越好 kmeans KMeans(n_clustersself.n_clusters, initcenters, n_init1, max_iter100) kmeans.fit(X) return -kmeans.inertia_, kmeans.labels_ def _morphogen_concentration(self, fitness_values): 模拟植物形态素浓度相对适应度越高浓度越高 min_fit np.min(fitness_values) if np.max(fitness_values) min_fit: return np.ones_like(fitness_values) / len(fitness_values) concentrations (fitness_values - min_fit) / (np.max(fitness_values) - min_fit) return concentrations / np.sum(concentrations) def fit(self, X): rng np.random.RandomState(self.random_state) population self._init_population(X) best_centers None best_fitness -np.inf for _ in range(self.max_iter): fitness_list [] centers_list [] for centers in population: fitness, _ self._compute_fitness(X, centers) fitness_list.append(fitness) centers_list.append(centers) if fitness best_fitness: best_fitness fitness best_centers centers.copy() concentrations self._morphogen_concentration(np.array(fitness_list)) # 根据浓度选择父代并围绕其邻域生成新候选 new_population [] for i in range(self.pop_size): selected_idx rng.choice(len(population), pconcentrations) base_centers centers_list[selected_idx] noise rng.normal(0, 0.05, sizebase_centers.shape) new_centers base_centers noise new_population.append(new_centers) population new_population self.cluster_centers_ best_centers final_kmeans KMeans(n_clustersself.n_clusters, initbest_centers, n_init1, max_iter300) final_kmeans.fit(X) self.labels_ final_kmeans.labels_ self.inertia_ final_kmeans.inertia_ return self实际使用时只需要先对标准化后的特征降维然后调用pgsa_kmeans PGSAKMeans(n_clusters5, pop_size30, max_iter50) pgsa_kmeans.fit(X_pca) df[cluster] pgsa_kmeans.labels_K值的选择我用了轮廓系数和Calinski-Harabasz指数结合判断。在模拟数据和真实城市数据上试跑K5时两个指标都相对较优。少于4类会把差异化很大的城市强行归在一起多于6类又会出现只有1到2个城市的碎片簇没有分析意义。3.3 聚类结果怎么看把K5的城市分型结果以雷达图方式展开大致能归纳出五类类型一经济规模大、第三产业占比高、科教医疗资源密集、房价偏高的一线及强二线簇类型二制造业基础雄厚、工业增加值占比高、PM2.5浓度偏高、财政自给率中等的制造业主导簇类型三GDP体量小、环境质量好、人均公园绿地面积高、消费活力一般的生态宜居簇类型四文旅资源丰富、产业结构相对单一、季节性人口波动明显的文旅特色簇类型五经济总量中上、生活质量指标中等的综合发展簇。这个分型结果和预期基本一致但和直接用K-means跑出来的结果比PGSA版本不会因为换随机种子而让个别城市在类型二和类型五之间反复横跳稳定性好很多。4. CNN在高维面板数据上做宜居度打分4.1 CNN为什么能用于表格类面板数据很多做城市分析的人一听CNN就想到图像识别觉得和城市数据不搭。其实CNN的本质是“用卷积核在局部窗口内提取模式”这个局部可以是图像的像素区域也可以是一个城市不同指标之间的局部相关关系。城市宜居度不是单一指标决定的。居民对宜居度的感受往往是“收入水平、空气质量、医疗资源、教育配套”四个维度的综合作用而且这些维度之间有交互。比如医疗资源对宜居度的贡献在中老年人口占比高的城市会更突出空气质量的贡献在经济发展水平高的城市更被重视。这种交互关系如果用手工构造交叉项来做40个指标会产生几百个组合根本做不完。CNN通过卷积核在特征维度上滑动自动学习邻近特征的组合模式相当于把特征交互的筛选交给模型自己去完成。4.2 把面板数据构造成特征图输入CNN之前要把二维表格变成三维张量。我把每个城市的指标组织成一个“时间×特征”的二维矩阵形式类似灰度图像。具体做法是取每个城市过去N年的数据横向是年份纵向是指标矩阵里的值就是标准化后的指标读数。假设每个城市有10年历史、15个主成分特征那么一个城市就是一个10×15的矩阵。把这个矩阵扩展一个通道维度就是一个10×15×1的特征图。这样设计的好处是卷积核同时捕捉两个方向的信息沿着时间轴可以提取趋势变化模式沿着特征轴可以捕捉指标之间的相关性。import numpy as np def build_feature_maps(data, time_steps10): cities data[city].unique() feature_maps [] labels [] for city in cities: city_data data[data[city] city].sort_values(year) # 提取最近time_steps年的数据 recent city_data.tail(time_steps) if len(recent) time_steps: continue matrix recent[feature_columns].values # (time_steps, n_features) matrix matrix.T # 转成 (n_features, time_steps) feature_maps.append(matrix[..., np.newaxis]) labels.append(city_data[living_score].iloc[-1]) return np.array(feature_maps), np.array(labels)4.3 模型结构与训练细节模型我用的是一个轻量级CNN网络不深避免城市数量不足时过拟合。import tensorflow as tf from tensorflow.keras import layers, models model models.Sequential([ layers.Input(shape(n_features, time_steps, 1)), layers.Conv2D(32, kernel_size(3, 3), activationrelu, paddingsame), layers.BatchNormalization(), layers.MaxPooling2D(pool_size(2, 1)), layers.Conv2D(64, kernel_size(3, 3), activationrelu, paddingsame), layers.GlobalAveragePooling2D(), layers.Dense(64, activationrelu), layers.Dropout(0.3), layers.Dense(1, activationlinear) ]) model.compile(optimizeradam, lossmse, metrics[mae])这里有几个关键调参点。第一MaxPooling的池化核选用了(2,1)意思是只在时间维度上降采样尽量保留特征维度信息因为特征维度一旦池化过度会丢失指标之间的区分度。第二用GlobalAveragePooling代替Flatten参数量立刻降下来而且对空间位置不敏感的特性很适合城市这种样本量不大的场景。第三Dropout只加了0.3城市数据样本量不大正则化太强会导致欠拟合。训练集和验证集按7比3划分城市粒度上切分不能按行随机切否则同一个城市的数据会同时出现在训练集和验证集里造成信息泄露验证集分数虚高。整个训练过程我跑了60轮early stopping触发后模型在验证集上的MAE大约在0.18左右相对误差约9%对于宜居度评分预测来说在可接受范围。注意CNN输出的宜居度是连续分模型训练用的标签是归一化后的居住满意度得分。如果想用百分制展示需要对预测结果做逆归一化不能直接用Raw预测值对外汇报。4.4 CNN结果与DeepSeek的协同方式CNN训练完之后我没有让它独自输出一个孤立数字而是把每个城市的预测得分、Top3影响特征、近5年变化趋势一起打包生成一个结构化结果。这些结果会传入后续的智能交互层。DeepSeek在回答用户问题时不需要自己重新计算宜居度只需要拿到CNN的结果文本按用户的语言习惯重新组织。比如用户问“哪个城市更适合定居”DeepSeek会把CNN得分前三的城市列出来并解释它们的优势维度依据就是CNN特征归因分析的结果。5. ARIMA与GM(1,1)联合预测长短兵器的搭配5.1 ARIMA建模全流程经济指标里的GDP、财政收入这类数据特点是历史周期长、趋势明显、有年度或季度规律。ARIMA模型是这类数据的经典选择。我的建模流程分四步走。第一步平稳性检验。对原始序列做ADF检验如果p值大于0.05说明序列不平稳。接着做一阶差分再次检验直到平稳。GDP序列通常一阶差分后就能通过平稳性检验。第二步定阶。通过ACF和PACF图确定p和q的初步范围然后用AIC准则比较候选模型的AIC值选最小值对应的阶数。这里我没有完全依赖自动定阶因为自动定阶偶尔会给出p或q过大的模型出现过拟合。我的做法是限制p、q都在0到5之间手动比较AIC变化曲线后确定最终组合。第三步残差检验。模型拟合后对残差做Ljung-Box检验。如果残差仍然存在自相关说明模型没有提取完数据中的信息需要重新调整阶数。第四步预测与反变换。差分拟合的模型预测结果需要做累积求和还原成原始量纲。这一步最容易被忽略很多人预测出来发现数值越跑越偏就是因为忘了反差分。from statsmodels.tsa.stattools import adfuller from statsmodels.graphics.tsaplots import plot_acf, plot_pacf from statsmodels.tsa.arima.model import ARIMA def adf_check(series): result adfuller(series, autolagAIC) return result[0], result[1] def fit_arima(series, order): model ARIMA(series, orderorder) result model.fit() return result # 一阶差分示例 diff_series series.diff().dropna() # 通过ACF/PACF找到order为(1, 1, 1)或(2, 1, 2), 以AIC最小为准实际项目中我用某城市近12年GDP数据做测试ARIMA(1,1,1)的AIC最低。预测未来两年GDP置信区间会明显变宽这是正常现象。任何预测模型对未来远期的估计都有不确定性ARIMA的置信区间能把这个风险直观表达出来。5.2 GM(1,1)对短序列宜居度指标的预测宜居度里有几个指标真是“巧妇难为无米之炊”比如每年才更新一次的万人医疗机构床位数很多城市只公布了近六七年数据。这种短序列上ARIMA直接投降而GM(1,1)只需至少4期数据就能建模。GM(1,1)的核心思想是用累加生成的方式弱化原始序列的随机波动然后拟合一个一阶线性微分方程再用累减还原得到预测值。代码实现比想象中短import numpy as np def gm11_predict(x0, n_pred3): x0 np.asarray(x0, dtypefloat) n len(x0) # 一次累加生成序列 x1 np.cumsum(x0) # 紧邻均值生成 z1 (x1[:-1] x1[1:]) / 2.0 # 构造B矩阵和Y向量 B np.column_stack([-z1, np.ones(n - 1)]) Y x0[1:] # 最小二乘法估计参数a(发展系数)和b(灰作用量) u np.linalg.inv(B.T B) B.T Y a, b u[0], u[1] # 累加序列的预测公式 def x1_hat(k): return (x0[0] - b / a) * np.exp(-a * (k - 1)) b / a # 还原为原始序列预测值 result [x0[0]] for k in range(2, n n_pred 1): result.append(x1_hat(k) - x1_hat(k - 1)) return result调用的格式是gm11_predict([3.2, 3.6, 4.1, 4.7, 5.0], 3)返回的就是原始历史值和未来3期预测值拼接的列表。GM(1,1)有个关键的参数门限发展系数a的绝对值要小于0.3模型才适合中长期外推如果a在0.3到0.5之间只适合短期预测如果超过0.8直接放弃这个模型。我一般会在代码里打印出a的值根据a的绝对值决定给业务方展示未来1年还是未来3年的预测。这个细节很多教程都不会写但实际非常重要a太大意味着序列波动剧烈硬外推很容易翻车。5.3 组合预测的逻辑ARIMA和GM(1,1)不是竞争关系而是按“数据条件”分工。我的选择规则如下数据类型序列长度趋势特征推荐模型GDP、财政收入大于36期长期趋势平稳ARIMA固定资产投资大于24期季节波动明显ARIMA万人医疗床位数4到15期平缓上升GM(1,1)人均公园绿地面积4到15期变化幅度小GM(1,1)房价收入比周期波动有明显均值回归ARIMA或均值回归两种模型的预测结果会统一落到结果表里作为智能交互层的“事实材料”。我特意没有做复杂加权组合预测因为对于城市面板数据解释性比微小的精度提升更重要。业务方更关心的是“这个预测是怎么来的”而不是“预测误差降低了0.1个百分点”。6. 智能交互层把模型结果变成能“聊天”的分析师6.1 为什么需要接入DeepSeek跑完聚类、CNN、ARIMA、GM(1,1)产出的是一堆csv和图片但因为使用者不一定是建模人员所以需要交互层把这些结果翻译成人话。接入DeepSeek的原因有三点一是它的中文理解能力在同类模型里表现稳定对新手的提示词输入不挑剔二是支持API调用可以本地编程封装适合嵌入自建的分析工具三是部署成本低不需要自己维护大模型基础设施把精力留给前面的数据计算。交互层的架构是“本地计算LLM表达”这不是让LLM去猜数据而是把所有确定性计算都放在本地完成LLM只负责根据预设的上下文生成回答。这样做的好处是大幅减少幻觉风险。数据答案是计算出来的不是编出来的。6.2 Gradio界面与DeepSeek API调用交互界面我用Gradio搭建一个对话框、几个筛选组件就能跑起来。核心是构造一个函数把用户问题、本地分析结果拼接成Prompt再调用DeepSeek API。import gradio as gr from openai import OpenAI client OpenAI( api_keyyour-api-key, base_urlhttps://api.deepseek.com ) def chat_answer(question, city_name): # 1. 读取本地分析结果 cluster_result get_cluster_info(city_name) cnn_score get_living_score(city_name) forecast_result get_forecast(city_name) # 2. 拼接上下文 context f 当前城市{city_name} 聚类类型{cluster_result[type]} 宜居度得分{cnn_score[score]} GDP未来三年预测{forecast_result[gdp]} 万人医疗床位数未来三年预测{forecast_result[medical]} 请基于以上事实用通俗语言回答用户的问题{question} response client.chat.completions.create( modeldeepseek-chat, messages[ {role: system, content: 你是资深城市数据分析师回答必须基于给定事实不能编造数据。}, {role: user, content: context} ], temperature0.3 ) return response.choices[0].message.content demo gr.ChatInterface( fnchat_answer, title城市经济与宜居度智能分析助手, description选择城市后直接提问例如这个城市未来经济走势如何适合居住吗 ) demo.launch()这个Prompt构造里有几个关键点。System层的提示词必须强调“基于给定事实不能编造数据”temperature设成0.3控制回答的创造性。如果设成默认1.0模型可能发挥过头出现“某某城市未来GDP会超过上海”这种不存在的结论。context里必须包含明确的数据来源和结果并注明城市名让模型容易定位。6.3 交互层的实际效果启动之后业务方可以这样提问“这个城市属于哪个类型跟哪些城市相似”“它的宜居度得分是多少短板在哪几个指标”“如果继续按当前节奏发展未来三年经济指标会是什么走向”“这个城市的空气质量趋势是变好还是变差”对于这些问题DeepSeek直接读取本地结果生成回答速度快、内容稳定。我在实际测试中回答准确率明显高于只让大模型直接推理的方案因为模型不需要记忆任何数字所有数字都写在上下文里它只需要做文本组织和逻辑串联。7. 踩坑记录与复盘7.1 常见问题速查表问题现象排查方向解决方案K-means聚类结果每次运行都不同初始质心随机性换PGSA-Kmeans或固定random_state并多次验证CNN验证集分数虚高数据切分用了随机行分割改为按城市粒度切分防止同一城市泄漏ARIMA预测值越来越平差分还原步骤遗漏检查是否做了cumsum反差分GM(1,1)预测结果出现负值原始序列波动过大检查发展系数a绝对值大于0.5时放弃模型DeepSeek回答中数据与本地结果不一致上下文拼接遗漏确保context中包含最新计算结果且强调“给定事实”PCA降维后聚类解释性差主成分太多或太少累计方差贡献率控制在85%左右适当结合旋转因子载荷7.2 几条值得分享的经验第一聚类分析不是跑完就结束。不要只看轮廓系数一定要把聚类结果转回原始指标维度看每个簇的中位数。数字上说两个簇离得远但回到原始指标一看可能只是归一化后的数学距离业务上毫无差异。我在项目里额外做了每个簇的指标雷达图这个看似笨拙的步骤反而是最终汇报时最有力的材料。第二CNN模型不是越深越好。城市数据不管怎么扩充样本量都有限。我一开始堆了五层卷积验证集波动非常大后面砍到两层卷积加全局平均池化效果反而稳定不少。做这类小样本表格数据轻量级模型是更务实的选择。第三ARIMA和GM(1,1)的预测周期必须保守。模型预测未来5年没问题但业务方拿着5年预测去做决策就很危险。我在结果表里明确标注了每个预测的置信区间并且约定对于GM(1,1)只在发展系数绝对值小于0.3时展示未来3年预测否则只展示未来1年。这个约定帮项目挡掉了不少“预测不准”的投诉。第四接入大模型做交互最关键的工作不是调API而是写Prompt和数据约束。一个优秀的分析助手它的回答质量上限其实由上下文质量决定而不是模型本身的推理能力。本地计算越扎实大模型发挥就越稳定。个人体会是这套链路真正值钱的部分不是某个单独的模型而是“传统算法负责算计、大模型负责沟通”的分工思路。城市数据这种复杂场景不存在一个模型从输入到输出通吃的银弹把合适的模型放在合适的位置上再用交互层把结果包装成人能直接看懂的结论整体效果会远超单模型堆叠。后续如果再扩展这个项目我倾向于把实时数据接口接进来让预测结果随最新季度数据滚动更新那样整个智能助手的实用价值还会再上一个台阶。