☰
Python实战:城内出行强度指数计算与时间序列分析
2026/10/14 16:57:54 网站建设 项目流程

1. 从迁徙数据到城内出行:这个项目到底在做什么

第一次看到“城内出行强度”这个词,很多人会以为是某种交通流量统计,其实它描述的是一个城市内部人员流动的活跃程度。简单说,就是拿一个城市每天的人员出行总量,跟这个城市在某个基准时间段内的出行总量做比值,得到一个相对指数。这个指数越高,说明城里的人越活跃,出门越频繁;指数越低,说明大家要么宅在家里,要么被什么因素限制住了出行。

我最早接触这个概念是在做城市人口流动分析的时候。当时手头已经有了一批迁徙规模数据,能看出一个城市跟外部的人员交换情况,但总觉得缺了点什么——一个城市内部到底活不活跃,光看迁入迁出是不够的。后来发现城内出行强度这个指标正好补上了这块拼图。它跟迁徙规模指数配合使用,一个看外部交换,一个看内部活跃,两个维度叠在一起,对城市的整体人员流动画像就立体多了。

这个项目适合谁呢?如果你正在做城市数据分析、区域经济研究、商业选址评估,或者单纯对用Python处理真实数据感兴趣,那这套东西对你就有用。它不需要你有多深的机器学习背景,核心就是数据获取、清洗、比值计算和可视化这几步。代码量不大,但涉及到的数据处理思路和坑点不少,我会把每个环节都拆开讲清楚。

注意:本文所有代码和思路都是基于公开数据接口的通用实践,具体接口的可用性和返回格式可能随时间变化,建议先跑通流程再根据实际返回结构调整字段映射。

2. 城内出行强度的计算逻辑与数据来源

2.1 为什么用比值而不是绝对值

城内出行强度本质上是一个归一化指标。为什么不直接用出行总量?因为不同城市的人口基数、面积、路网密度差异太大了。一个两千万人口的城市和一个五百万人口的城市,出行总量根本没有可比性。就算同一个城市,工作日和周末的出行总量也差很多,直接看绝对值会被这些因素干扰。

用比值就不一样了。选定一个基准时间段——通常是疫情前某个正常月份,或者某年的日均值——把每天的出行量除以这个基准值,得到的就是一个无量纲的指数。这个指数围绕1上下波动,大于1说明比基准期活跃,小于1说明比基准期冷清。这样一来,不同城市之间可以横向比较,同一城市不同日期之间也可以纵向比较。

我个人的经验是,基准期的选择非常关键。如果你选的基准期本身就不正常——比如选了一个有重大活动或者极端天气的月份——那后面所有日期的指数都会失真。一般建议选连续三个月以上的正常时段取日均值,这样能平滑掉单日的偶然波动。

2.2 数据来源与字段说明

这类出行强度数据通常来自大型互联网平台的出行服务。数据一般以日为单位更新,覆盖全国主要城市。返回的字段通常包括日期、城市名称、出行强度指数这几个核心字段。有些接口还会返回排名、较昨日变化等辅助信息。

实际拿到的数据格式可能是JSON,也可能是CSV。JSON的话需要解析嵌套结构,CSV相对直接。我遇到过的情况是,接口返回的JSON里城市名和指数是分开两个数组存的,需要自己按索引对应起来。这种设计在接口文档里往往不会写得很清楚,得自己打印出来看结构。

import requests import pandas as pd import json # 示例:获取某城市城内出行强度数据 # 注意:实际接口地址和参数需要根据当前可用的公开数据源调整 url = "https://example-api.com/travel/incity" params = { "city": "某城市代码", "start_date": "2023-01-01", "end_date": "2023-12-31" } response = requests.get(url, params=params, timeout=10) data = response.json() # 打印结构,先看清楚再解析 print(json.dumps(data, ensure_ascii=False, indent=2)[:2000])

上面这段代码的关键不是接口地址本身,而是那个print语句。我强烈建议你在写任何解析逻辑之前,先把返回的JSON完整打印出来看一遍。很多新手直接照着文档写解析代码,结果文档和实际返回不一致,调半天调不通。先看结构再写代码,能省掉大量返工时间。

2.3 数据清洗的几个关键点

拿到原始数据之后,不能直接拿来算。有几个清洗步骤是必须做的。

第一是日期格式统一。接口返回的日期可能是时间戳、可能是2023/01/01、也可能是2023-01-01。统一转成datetime类型,后面做时间序列分析才方便。

第二是缺失值处理。有些日期可能没有数据,或者返回了空值。这时候不能简单填0,因为0代表“完全没有出行”,跟“数据缺失”是两码事。我的做法是标记为NaN,在计算和绘图时单独处理。

第三是异常值识别。偶尔会出现指数突然飙到几十的情况,这通常是数据采集或传输出了问题。可以用简单的3σ原则或者IQR方法标记出来,人工确认后再决定是否剔除。

df = pd.DataFrame(data["data"]) df["date"] = pd.to_datetime(df["date"]) df["index"] = pd.to_numeric(df["index"], errors="coerce") # 标记缺失 df["is_missing"] = df["index"].isna() # 用IQR识别异常值 Q1 = df["index"].quantile(0.25) Q3 = df["index"].quantile(0.75) IQR = Q3 - Q1 lower = Q1 - 3 * IQR upper = Q3 + 3 * IQR df["is_outlier"] = (df["index"] < lower) | (df["index"] > upper) print(f"缺失值数量: {df['is_missing'].sum()}") print(f"异常值数量: {df['is_outlier'].sum()}")

这段代码里errors="coerce"这个参数很实用,它能把无法转换的值变成NaN而不是直接报错。处理真实数据时,你永远不知道会碰到什么奇怪的字符串,用这个参数能避免程序中途崩溃。

3. 核心代码实现与参数调优

3.1 基准值计算:整个项目的锚点

基准值的计算方式直接决定了后续所有指数的含义。我试过三种方案,各有适用场景。

第一种是固定基准期法。选定一个历史时间段,比如某年全年,算出日均值作为分母。这种方案的好处是简单直观,所有日期的指数都跟同一个标准比。缺点是如果时间跨度很长,城市本身的人口规模发生了变化,早期和晚期的指数可比性会下降。

第二种是滚动基准法。用过去30天或90天的移动平均作为分母。这种方案能适应城市规模的缓慢变化,指数反映的是“相对于近期正常水平”的活跃度。缺点是遇到趋势性变化时,指数会滞后。

第三种是同期基准法。用去年同期的数据作为分母。这种方案能自动消除季节性影响,比如春节前后出行强度本来就会波动,用同期比就能把这种规律性波动排除掉。

我最终采用的是固定基准期法,因为做城市间横向对比时,统一基准最公平。基准期选的是某年3月到5月,避开了春节和极端天气频发的时段。

# 固定基准期法 baseline_period = df[(df["date"] >= "2023-03-01") & (df["date"] <= "2023-05-31")] baseline_value = baseline_period["index"].mean() print(f"基准期日均出行强度: {baseline_value:.4f}") # 计算每日相对指数 df["relative_index"] = df["index"] / baseline_value

这里有个细节:基准期计算时要不要剔除异常值?我的做法是要剔。如果基准期里混进了几个异常高的值,会把分母拉大,导致后面所有指数都偏低。所以先用前面的异常值标记把基准期里的异常点去掉再算均值。

3.2 时间序列分解:看清趋势与周期

算出相对指数之后,直接画折线图能看到波动,但看不清背后的结构。这时候需要做时间序列分解,把趋势项、季节项和残差项拆开。

趋势项反映的是长期变化方向,比如城市出行强度是在缓慢上升还是下降。季节项反映的是周期性规律,比如每周的周末效应、每年的节假日效应。残差项就是去掉趋势和季节之后剩下的随机波动,往往对应着突发事件的影响。

from statsmodels.tsa.seasonal import seasonal_decompose # 需要先设置日期为索引 df_ts = df.set_index("date")["relative_index"].dropna() # 按周为周期分解(period=7) decomposition = seasonal_decompose(df_ts, model="additive", period=7) trend = decomposition.trend seasonal = decomposition.seasonal resid = decomposition.resid

period=7是按周周期分解,适合看周末效应。如果你想看年度周期,可以设period=365,但那样需要至少两年的数据才有意义。我一般先做周分解,因为周内规律最稳定也最明显。

分解完之后,趋势项能告诉你城市出行是在恢复还是收缩,季节项能告诉你一周里哪天最活跃,残差项里那些突然的尖峰或低谷就是值得深挖的异常事件。

3.3 可视化:让数据自己说话

画图这件事,我的原则是一张图只讲一件事。不要试图在一张图里塞进所有信息,那样谁也看不清。

第一张图:每日相对指数折线图。横轴日期,纵轴指数,加一条y=1的参考线。这张图看整体波动和极端值。

第二张图:周内模式箱线图。按星期几分组,看每天的指数分布。这张图看周内规律。

第三张图:趋势与季节分解图。把分解结果画成子图,看长期方向和周期强度。

import matplotlib.pyplot as plt import matplotlib.dates as mdates fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=False) # 图1:每日指数 axes[0].plot(df["date"], df["relative_index"], linewidth=0.8, color="#2c7fb8") axes[0].axhline(y=1, color="red", linestyle="--", linewidth=1) axes[0].set_ylabel("相对出行强度") axes[0].set_title("每日城内出行强度指数") # 图2:周内模式 df["weekday"] = df["date"].dt.dayofweek weekday_labels = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] box_data = [df[df["weekday"] == i]["relative_index"].dropna() for i in range(7)] axes[1].boxplot(box_data, labels=weekday_labels) axes[1].set_ylabel("相对出行强度") axes[1].set_title("周内出行强度分布") # 图3:趋势项 axes[2].plot(trend.index, trend.values, color="#e6550d", linewidth=1.2) axes[2].set_ylabel("趋势项") axes[2].set_title("出行强度趋势分解") plt.tight_layout() plt.savefig("incity_travel_index.png", dpi=150) plt.show()

配色我习惯用蓝色系表示正常数据,红色虚线表示基准线,橙色表示趋势。这样一眼就能区分不同图层。dpi=150是保证输出图片清晰度的最低要求,如果要打印或者放到报告里,建议设到300。

4. 实操中踩过的坑与排查技巧

4.1 接口返回空数据怎么办

这是最常见的问题。你兴冲冲写好代码跑起来,结果返回一个空列表或者null。先别急着改代码,按这个顺序排查:

第一步,检查日期范围。有些接口对单次查询的时间跨度有限制,比如最多只能查30天。如果你传了一年的范围,它可能直接返回空。解决办法是分段查询再拼接。

第二步,检查城市代码。不同接口用的城市编码体系可能不一样,有的是行政区划代码,有的是自定义编码。传错了城市代码,返回空是很正常的。

第三步,检查请求频率。短时间内大量请求可能被限流,返回空或者错误码。加个time.sleep(1)在每次请求之间,能解决大部分限流问题。

第四步,检查接口本身是否还活着。有些公开接口会不定期调整或下线,这个只能通过社区反馈或者自己定期测试来发现。

import time def fetch_with_retry(url, params, max_retries=3, delay=2): for attempt in range(max_retries): try: resp = requests.get(url, params=params, timeout=15) if resp.status_code == 200: data = resp.json() if data and data.get("data"): return data print(f"第{attempt+1}次尝试返回空,{delay}秒后重试") except Exception as e: print(f"第{attempt+1}次尝试异常: {e}") time.sleep(delay) return None

这个重试函数是我每次做数据抓取都会封装的。max_retries=3和delay=2是经验值,大部分临时性故障两次重试就能恢复。如果三次都失败,那大概率不是临时问题,继续重试也没用。

4.2 指数突然跳变怎么判断是真实还是异常

有时候你会看到某天的指数突然比前后几天高出一大截或低出一大截。这时候要判断是真实事件还是数据问题。

我的判断流程是这样的:先看前后几天的数据是否平滑。如果只有单独一天跳变,前后都正常,那大概率是数据问题。如果连续几天都偏高或偏低,那可能是真实事件——比如长假、大型活动、极端天气。

再看同区域其他城市的数据。如果多个城市同时出现类似跳变,那基本可以确认是区域性事件。如果只有这一个城市跳,那要么是本地事件,要么是这个城市的数据采集出了问题。

最后看历史同期。如果去年同一天也出现了类似跳变,那就是周期性规律,不是异常。

# 简单的跳变检测 df["diff"] = df["relative_index"].diff().abs() threshold = df["diff"].mean() + 3 * df["diff"].std() df["is_jump"] = df["diff"] > threshold jump_dates = df[df["is_jump"]]["date"].tolist() print(f"检测到跳变日期: {jump_dates}")

这个检测只是辅助,最终判断还是要结合上面说的多维度交叉验证。纯靠算法自动判断,误报率会比较高。

4.3 常见问题速查表

问题现象可能原因排查方法解决方式
接口返回空列表日期范围超限缩短查询跨度到30天内分段查询后拼接
接口返回空列表城市代码错误对照接口文档核对编码使用正确的城市编码
请求频繁失败触发限流查看返回状态码增加请求间隔,加sleep
指数全部为1基准值计算错误检查基准期筛选条件确认基准期数据非空
折线图锯齿严重未做平滑处理观察原始数据波动加移动平均或周聚合
分解报错数据含缺失值检查NaN数量先插值或剔除再分解
中文显示方框字体未配置查看matplotlib字体设置指定中文字体路径

这个表里最后一条关于中文显示的,是新手最容易卡住的地方。matplotlib默认字体不含中文,你不配置的话所有中文标签都会变成方框。解决办法是在绘图前设置:

plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "Arial Unicode MS"] plt.rcParams["axes.unicode_minus"] = False

SimHei是Windows系统自带的黑体,Microsoft YaHei是微软雅黑,Arial Unicode MS是macOS上常用的备选。把这三个都列上,跨平台基本不会出问题。axes.unicode_minus = False是解决负号显示为方框的问题,虽然出行强度指数一般不会是负数,但加上这个配置养成习惯没坏处。

5. 从指数到洞察:分析思路与延展应用

5.1 城市间横向对比的正确姿势

有了多个城市的出行强度数据之后,很自然会想做横向对比。但直接比指数高低是有问题的,因为每个城市的基准期不同,基准值不同,指数的绝对水平不可直接比较。

正确的做法是比变化模式而不是比绝对水平。比如看哪些城市的指数在某个时间段同步上升,哪些城市出现了分化。这种模式对比才是有意义的。

具体操作上,可以把多个城市的指数序列画在同一张图上,但不要用同一个y轴刻度去比较高低,而是看曲线的形状是否同步。或者计算城市两两之间的相关系数,看哪些城市的出行模式更接近。

# 多城市对比:计算相关系数矩阵 cities = ["城市A", "城市B", "城市C"] city_data = {} for city in cities: # 假设已经获取了每个城市的数据 city_df = fetch_city_data(city) city_data[city] = city_df.set_index("date")["relative_index"] combined = pd.DataFrame(city_data) corr_matrix = combined.corr() print(corr_matrix)

相关系数高于0.8说明两个城市的出行模式高度同步,可能在同一个经济圈或者受相似因素影响。低于0.3说明关联很弱,各自有独立的出行规律。

5.2 跟其他数据源结合能挖出什么

单独看出行强度指数,能得到的结论有限。但把它跟其他数据源结合,价值就大了。

跟天气数据结合,能看出天气对出行的影响。下雨天指数是不是普遍偏低?高温天呢?这种分析对商业选址和活动策划很有参考价值。

跟节假日日历结合,能验证节假日效应。春节前后指数怎么变化?国庆呢?把节假日标注在图上,规律一目了然。

跟经济指标结合,能探索出行活跃度与经济活力的关系。这个需要更严谨的统计方法,但初步的相关性分析就能看出一些端倪。

我做过一个简单的实验:把某城市一年的出行强度指数跟当月的社会消费品零售总额做滞后相关分析,发现出行指数领先消费指标大约两到三周。当然这只是单个城市的观察,不能推广成普遍规律,但至少说明这个指数作为经济活动的先行指标是有潜力的。

5.3 自动化更新的工程化思路

如果你需要长期跟踪这个指标,手动跑脚本就不现实了。需要做自动化。

最简单的方案是用schedule库或者系统自带的定时任务,每天固定时间跑一次数据抓取和计算,把结果追加到数据库或CSV文件里。

import schedule import time def daily_job(): print("开始每日数据更新...") df_new = fetch_latest_data() if df_new is not None: df_new.to_csv("incity_data.csv", mode="a", header=False, index=False) print(f"已追加{len(df_new)}条记录") else: print("数据获取失败,跳过本次更新") schedule.every().day.at("08:00").do(daily_job) while True: schedule.run_pending() time.sleep(60)

这个方案简单但有效。mode="a"是追加模式,header=False避免每次追加都写一遍表头。实际部署时建议加个日志记录,把每次运行的时间、获取条数、是否成功都记下来,方便排查问题。

如果数据量大了,CSV就不合适了,换成SQLite或者PostgreSQL。但那是另一个话题了,对于个人分析项目,CSV撑到几万条完全没问题。

5.4 几个容易忽略的细节

第一个细节:时区问题。如果数据源用的是UTC时间,而你本地是东八区,日期会对不上。处理日期之前先确认时区,该转换就转换。

第二个细节:闰年和平年。做同期对比时,2月29日的数据在平年是没有对应日期的。要么跳过,要么用2月28日和3月1日的均值代替。

第三个细节:城市行政区划调整。有些城市可能在某年发生了区划变更,导致前后数据口径不一致。做长周期分析时要注意这个问题,必要时在变更点做分段处理。

第四个细节:指数为0或接近0的情况。正常来说出行强度指数不会为0,但如果出现了,要么是数据问题,要么是极端事件。不要直接参与比值计算,先确认原因。

提示:做任何跨年对比之前,先画一张全年每日指数的散点图,肉眼扫一遍有没有明显的断层或跳变。很多口径变化在图上是一眼能看出来的,比看数据表快得多。

6. 代码组织与复用建议

6.1 把抓取、清洗、计算、绘图拆成独立函数

我见过很多新手把所有逻辑写在一个脚本里,从头跑到尾。这样跑一次没问题,但想改个参数或者复用某部分逻辑就麻烦了。

更好的做法是按职责拆成几个函数:fetch_data()负责抓取,clean_data()负责清洗,calculate_index()负责计算,plot_results()负责绘图。每个函数只做一件事,输入输出明确。

def fetch_data(city, start_date, end_date): """抓取指定城市指定时间段的出行强度数据""" # 抓取逻辑 return raw_df def clean_data(raw_df): """清洗数据:日期转换、缺失标记、异常识别""" # 清洗逻辑 return cleaned_df def calculate_index(cleaned_df, baseline_start, baseline_end): """计算相对出行强度指数""" # 计算逻辑 return result_df def plot_results(result_df, output_path): """绘制分析图表""" # 绘图逻辑 return None

这样拆完之后,你想换一个城市分析,只需要改fetch_data()的入参。想换基准期,只需要改calculate_index()的入参。每个函数都可以单独测试,出了问题也容易定位。

6.2 配置文件分离

城市代码、日期范围、基准期、输出路径这些参数,不要硬编码在代码里。单独放一个配置文件或者用命令行参数传入。

# config.py CITY_CODE = "某城市代码" START_DATE = "2023-01-01" END_DATE = "2023-12-31" BASELINE_START = "2023-03-01" BASELINE_END = "2023-05-31" OUTPUT_DIR = "./output"

这样你想分析另一个城市,改配置文件就行,不用动代码。团队协作时也避免了对同一个代码文件的不同修改产生冲突。

6.3 异常处理和日志记录

真实环境中跑数据,异常是常态。网络超时、接口变更、数据格式变化,随时可能发生。每个可能出错的环节都要有异常处理,并且把错误信息记录下来。

import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", handlers=[ logging.FileHandler("incity_analysis.log", encoding="utf-8"), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) try: raw_df = fetch_data(CITY_CODE, START_DATE, END_DATE) logger.info(f"成功获取{len(raw_df)}条原始数据") except Exception as e: logger.error(f"数据获取失败: {e}") raise

日志文件用encoding="utf-8"避免中文乱码。同时输出到文件和控制台,方便调试时实时看,也方便事后追溯。

这套代码框架我用了好几年,从最简单的单城市分析到多城市批量处理,都是在这个基础上扩展的。核心逻辑没变过,变的只是配置和输入数据。把基础打牢,后面加功能就是搭积木的事。

最后分享一个我个人的习惯:每次分析完一个城市,把关键结论用一两句话记在一个单独的笔记文件里。比如“某城市2023年出行强度在4月和10月出现双峰,与假期分布吻合”。积累多了之后,回头看这些笔记,能发现很多单次分析时注意不到的跨城市规律。这个习惯看起来笨,但长期价值很高。

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

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

立即咨询