做数据分析这些年,我经常被问到一个问题:“数据表里的每条记录,怎么变成能直接用的分析结果?”最常见的场景之一,就是手头有一张包含出发地和目的地的人口流动明细表,想快速把它重组成一张矩阵——行是出发地,列是目的地,交叉点填上流动人次。这个看似简单的“变形”需求,恰恰是数据科学里“数据重组”的核心价值所在。
这篇文章就围绕“人口流动矩阵”这个具体场景,讲清楚如何用Pandas把长表结构的原始数据,一步步重组成标准的N x N矩阵。我会从原始数据的常见形态讲起,拆解pivot_table、crosstab、groupby + unstack三种重组方式各自的适用边界,再补上真实项目中绕不开的清洗、标准化、性能优化细节。无论你刚开始接触Pandas,还是已经用过一段时间但总在“变形”环节卡壳,这篇文章都值得收藏。
1. 先搞清楚:人口流动矩阵到底是个什么东西
人口流动矩阵,本质上就是一张二维表,行代表出发地(Origin),列代表目的地(Destination),行列交叉的单元格代表从某个出发地去往某个目的地的人数或次数。它还有一个更学术的名字叫“OD矩阵”,在城市规划、交通调度、商业选址、传染病传播模型中都有广泛应用。
1.1 一张矩阵能回答哪些问题
假设你手上有一座城市的出行数据,每条记录是“某个人某天从A地到了B地”。如果只盯着原始明细表,你很难看出全局规律。但一旦整理成矩阵,很多问题立刻有了答案:
- 哪个区域是最大的客流输出地?——看行合计,行和越大说明该区域“出去的人”越多。
- 哪个区域是最大的客流吸纳地?——看列合计,列和越大说明该区域“进来的人”越多。
- 哪些区域之间的往来最频繁?——找矩阵中数值最大的非对角线单元格。
- 区域内流动和跨区域流动的比例如何?——比较对角线与非对角线的占比。
这些结论直接指导着城市公交线路规划、商圈选址、应急疏散方案设计。可以说,矩阵不是最终目的,它是后续一切分析决策的“地基”。
1.2 为什么偏偏要用“矩阵”而不是继续用明细表
很多人会问:明细表不是信息更全吗?为什么非要转成矩阵?
答案是:矩阵本质上是一种“降维后的聚合视图”。在明细表里,每个出发地-目的地组合会对应成千上万条记录,肉眼无法直接比较。而矩阵把这类组合压缩成单一数值,让“结构”凸显出来。后续无论是算距离衰减、做图论分析,还是训练预测模型,矩阵都是最标准、最省空间的输入格式。
对数据科学项目来说,重构数据的过程往往比建模本身更耗时。熟练地完成“明细表 → 矩阵”的转换,是数据科学家最基础的功夫之一。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 原始数据的真实处境:长表字段与脏数据
在做任何矩阵构建之前,第一步永远是回答一个问题:我手头的数据到底是什么样?
人口流动的原始数据最常见的形态是“长表”(Long Format),也叫“流水表”。每一行代表一次独立的流动记录,而不是已经聚合好的结果。这种结构符合数据库存储规范,但不利于直接阅读和分析。
2.1 一张典型的人口流动长表有哪些字段
| 字段名 | 含义 | 示例 |
|---|---|---|
| user_id | 流动者标识 | U10001 |
| origin | 出发地编码 | A区 |
| destination | 目的地编码 | B区 |
| trip_date | 流动发生日期 | 2024-06-01 |
| trip_time | 出发时间 | 08:32:15 |
| transport_mode | 交通方式 | subway / bus / car |
注意,这里我没有使用真实的地理名称,而是用“A区”“B区”这样的脱敏代号。实际项目中,如果涉及个人隐私数据,必须在处理前完成彻底的匿名化和聚合,只保留分析所需的粒度。数据安全这条红线,任何时候都不能碰。
2.2 数据清洗:列类型统一是第一步
构建矩阵之前,有几种脏数据是必须处理的:
- 出发地为空或目的地为空。这类记录无法定位到矩阵的行或列,直接剔除或单独标记。
- origin和destination的大小写不统一。例如有的记录是“A区”,有的是“a区”,Pandas会当成两个不同的区域,导致矩阵多出奇怪的“幽灵行列”。
- 数值型字段误读为字符串。比如user_id原本是数字,但读入时被识别成object类型,虽然不影响计数聚合,但会拖慢后续处理。
我自己踩过一次坑:数据里同一个区域有的写成“北京”,有的写成“北京市”,crosstab之后矩阵里出现了两个本该合并的“区域”,不仅对角线数据虚高,还干扰了后续所有分析。从那以后,我养成了一个习惯:在构建矩阵之前,先对所有“区域类”字段做一轮去空格、去首尾空白、统一命名规则的操作。
2.3 一个可复现的测试数据集
为了让后面的示例能够直接复现,我先造一批模拟数据。假设我们有四个区域:A区、B区、C区、D区,记录了一个月内部分居民的跨区域流动:
python复制import pandas as pd
import numpy as np
np.random.seed(42)
regions = ["A区", "B区", "C区", "D区"]
n = 5000
data = {
"user_id": [f"U{int(i):05d}" for i in np.random.randint(0, 3000, n)],
"origin": np.random.choice(regions, n, p=[0.4, 0.3, 0.2, 0.1]),
"destination": np.random.choice(regions, n, p=[0.2, 0.3, 0.3, 0.2]),
}
df = pd.DataFrame(data)
# 故意制造一些脏数据
df.loc[10, "origin"] = " A区 " # 带空格
df.loc[20, "destination"] = "b区" # 小写
df.loc[30, "origin"] = None # 缺失
这份数据里有5000条记录,包含了带空格、大小写不一致、缺失值三种常见脏数据情况。后面的所有示例都基于这个DataFrame展开。
3. 核心操作:三种Pandas重组手法与选型逻辑
矩阵构建的核心逻辑,说白了只有一句话:统计每个(origin, destination)组合的记录条数,再按“行 = origin、列 = destination”展开成二维表。
Pandas提供了几种等价的实现方式,但适用场景略有不同。
3.1 方法一:pivot_table——最灵活,适用有聚合需求
pivot_table是Pandas里的“透视表”函数,第一选择就是它来做交叉统计。核心参数是index、columns、values和aggfunc:
python复制# 先处理脏数据:去空格、统一大小写、剔除缺失
df_clean = df.copy()
df_clean["origin"] = df_clean["origin"].str.strip().str.upper()
df_clean["destination"] = df_clean["destination"].str.strip().str.upper()
df_clean = df_clean.dropna(subset=["origin", "destination"])
flow_matrix = df_clean.pivot_table(
index="origin",
columns="destination",
values="user_id",
aggfunc="count",
fill_value=0
)
flow_matrix
执行结果是一个以D区、C区、B区、A区为行列标签的4x4矩阵,单元格的数值表示对应的流动记录条数。
为什么要指定values="user_id"?因为aggfunc="count"需要一个列来计数,选哪个列不重要,但必须存在。如果你手头的数据结构更复杂,比如除了次数还想统计“去重人数”——不同用户的数量,可以把aggfunc换成"nunique":
python复制flow_matrix_distinct = df_clean.pivot_table(
index="origin",
columns="destination",
values="user_id",
aggfunc="nunique",
fill_value=0
)
这是pivot_table最大的优势:aggfunc可以替换成任意聚合函数,包括count、sum、nunique、mean等。如果你不仅要“有多少条流动”,还要“有多少个不同的人”,pivot_table是唯一能一行搞定的方案。
3.2 方法二:crosstab——交叉表专用,最简洁
如果只是统计频数,不需要额外的聚合逻辑,那么crosstab是最简洁的选择。它本质上就是“专门做交叉频数统计”的函数:
python复制flow_matrix_cross = pd.crosstab(
index=df_clean["origin"],
columns=df_clean["destination"]
)
相比pivot_table,crosstab不需要指定values和aggfunc,默认行为就是计数。在代码阅读性上更直观,其他人一看就知道你在做频数交叉统计。
crosstab还有一个优势是支持normalize参数,可以直接输出比例矩阵:
python复制# 按行归一化,即每个出发地流向各目的地的占比
flow_matrix_row_pct = pd.crosstab(
index=df_clean["origin"],
columns=df_clean["destination"],
normalize="index"
)
normalize="index"会让每行之和等于1,非常适合观察“从某地出去的人,都去了哪里”的分布结构。normalize="columns"则是按列归一化,每列之和等于1,适合观察“某地接收的人,都是从哪里来的”。
3.3 方法三:groupby + unstack——最直观,适合二次加工
如果你的分析流程中需要先做更复杂的groupby计算,再转为矩阵,那么“先groupby、再unstack”的方式是最容易扩张的:
python复制flow_matrix_group = df_clean.groupby(["origin", "destination"]).size().unstack(fill_value=0)
这条逻辑拆解开来:groupby按出发地和目的地分组,size()统计每组行数,unstack将destination从行索引转到列,fill_value=0把没有记录的组合填充为0。
相比前两种方法,groupby + unstack的优势在于可以在groupby阶段同时计算多个指标:
python复制agg_result = df_clean.groupby(["origin", "destination"])["user_id"].agg(["count", "nunique"])
flow_matrix_group = agg_result["count"].unstack(fill_value=0)
flow_matrix_group_distinct = agg_result["nunique"].unstack(fill_value=0)
一次groupby,同时得到“流动记录数”和“去重人数”两张矩阵,这种写法在调研报告中尤其实用。
3.4 三种方式的对比与选择
| 方法 | 核心参数 | 最适合场景 | 注意事项 |
|---|---|---|---|
| pivot_table | index, columns, values, aggfunc | 需要灵活聚合,如同时统计平均出行时间 | 必须指定values列 |
| crosstab | index, columns, normalize | 纯频数统计,需要比例矩阵 | 不能直接聚合多个指标 |
| groupby + unstack | 无固定参数 | 需要先groupby做多指标聚合 | 需要手动fill_value |
我的建议是:日常快速探索用crosstab;正式分析且指标不复杂的用pivot_table;需要一次性计算多个衍生指标时,用groupby + unstack。三者没有绝对的优劣,只有适不适合当前分析任务。
4. 构建过程中的高发问题:缺行缺列与幽灵行列
矩阵构建的语法很简单,但实际项目中“构建出来的矩阵不对”的情况比比皆是。以下三个问题,我几乎在每个项目里都遇到过。
4.1 行列缺失:矩阵不是方阵
假设你的数据里,C区只作为出发地出现,但从没有记录去过C区。那么直接pivot_table的结果,index里有C区,columns里却没有C区,矩阵变成3列4行,不再是一个方阵。
这种“非方阵”在计算某些指标时会出问题。例如后续要计算“某区域的净流出量”,你需要用行合计减去列合计,如果行和列不是同一套区域列表,减法就会报错或出现NaN。
解决办法是手动补齐行列索引。先定义好完整的区域列表,再通过reindex强制对齐:
python复制all_regions = ["A区", "B区", "C区", "D区"]
flow_matrix_complete = flow_matrix.reindex(index=all_regions, columns=all_regions, fill_value=0)
这里保证所有区域同时出现在行和列中,并且缺失的部分自动填0,矩阵恢复为标准的方阵。
4.2 空值与NaN混入矩阵
pivot_table在默认情况下,如果某个(origin, destination)组合没有任何记录,对应的单元格不会出现,转成矩阵时会变成NaN。如果后续要做矩阵运算,NaN会像病毒一样扩散——和别的矩阵相加,结果是NaN;取对数,结果是NaN;画热力图,颜色直接空白。
所以构建矩阵时强烈建议养成一个习惯:显式指定fill_value=0。在pivot_table和unstack中都已经演示过,这里再补充一点:如果你不确定数据里是否还有空值,构建之后可以用isna()检查:
python复制print(flow_matrix.isna().any().any())
如果返回True,说明矩阵里仍有NaN,需要进一步处理。我通常会直接在构建时填0,因为人口流动矩阵里“没有流动记录”和“流动次数为0”在数学意义上是等价的。
4.3 幽灵行列:脏数据制造的假区域
这个问题我在第2.2节里简单提过,这里再展开。假设原始数据里既有“A区”又有“ A区 ”(带空格),或者有“A区”和“a区”,Pandas会认为它们是四个不同的区域,最终矩阵里出现两个本应合并的行。
解决方法是构建之前做一次区域字段的标准化:
python复制def normalize_region(s):
return s.str.strip().str.upper()
df_clean["origin"] = normalize_region(df_clean["origin"])
df_clean["destination"] = normalize_region(df_clean["destination"])
标准化之后再构建矩阵,就可以避免绝大多数“幽灵行列”问题。如果你处理的区域是真实的行政区划编码,还可以进一步校验所有取值是否在合法编码列表内,不在列表里的记录要么修正、要么剔除。
5. 矩阵的标准化与方向选择:从“次数”到“概率”
构建出原始计数矩阵只是第一步。在大多数分析项目中,原始计数矩阵无法直接用于模型或可视化,因为不同区域的基数差异太大。比如人口最多的A区出行10000次,人口较少的D区只出行100次,直接用原始数值比较,会得出“D区流动很少”的结论,但这可能是人口基数造成的假象。
这时候需要做矩阵标准化。
5.1 三种最常用的标准化方式
行归一化(行百分比):每行除以行合计,得到“从某地出发的人,流向各目的地的比例”。这适合回答“某个区域的流出结构是什么样”。
python复制row_pct_matrix = flow_matrix.div(flow_matrix.sum(axis=1), axis=0)
列归一化(列百分比):每列除以列合计,得到“到达某地的人,分别来自哪些出发地的比例”。这适合回答“某个区域的流入结构是什么样”。
python复制col_pct_matrix = flow_matrix.div(flow_matrix.sum(axis=0), axis=1)
全局归一化:整个矩阵除以全部流动次数,得到“所有流动中,某个OD组合的占比”。适合用于整体结构对比。
python复制global_pct_matrix = flow_matrix / flow_matrix.values.sum()
使用哪种标准化,取决于你的分析目标。做“区域吸引力”分析时用列归一化;做“区域辐射力”分析时用行归一化;做整体结构对比时用全局归一化。没有哪一种绝对正确,只有合适和不合适。
5.2 有向矩阵与无向矩阵:要不要对称化
人口流动天然是有方向的:从A到B的流动和从B到A的流动,含义完全不同。所以默认构建出的原始矩阵就是有向矩阵,对角线之外并不对称。
但在某些分析场景里,我们只关心“区域之间的连接强度”,不关心方向。比如研究社交网络中的联系紧密程度、城市群之间的联系强度,这时候可以把矩阵对称化,转换成无向矩阵:
python复制undirected_matrix = flow_matrix + flow_matrix.T
正对角线上的值加了一倍,代表区域内流动。去掉对角线、只保留上下三角,则能得到纯粹的“区间连接强度”:
python复制np.fill_diagonal(undirected_matrix.values, 0)
对称化之后,A到B和B到A的流量被合并成一个值,矩阵变成对称阵。后续做社区发现、聚类等图算法时,这种形式更符合算法输入的预期。
5.3 结合总量看矩阵:单看占比是不够的
我想特别提醒一点:标准化之后别忘了回看总量。
我见过不少新手,把矩阵做行归一化之后就开始分析“哪个区域的流出比例更高”,却忽略了原始总量。举个例子:A区流出10000人,其中去B区比例是10%,也就是1000人;D区流出100人,去B区比例是50%,也就是50人。单看比例,D区“更依赖B区”,但从绝对流量看,A区对B区的影响远大于D区。
所以在标准化之前,先保留好原始计数矩阵,分析时同时参考“总量”和“比例”两个维度,结论才不会被单一视角带偏。
6. 从矩阵到结论:数据校验与可视化输出
矩阵构建并标准化之后,别急着交差。在正式输出前,还需要做最后的穿行验证,确保矩阵的质量没有问题。
6.1 矩阵质量的三项体检
体检一:行合计等于原始分组计数
python复制assert flow_matrix.sum(axis=1).sum() == len(df_clean)
如果矩阵里每个单元格之和等于清洗后的总记录数,说明没有任何记录在重组过程中“丢失”。这是最基础但最容易出错的一环。
体检二:对角线占比是否异常
对角线数值代表“区域内流动”。如果对角线占比突然极高,比如超过90%,很可能是区域划分粒度太粗,或数据存在重复计数。反过来如果对角线占比极低,也要检查是不是区域编码有问题。
python复制diag_ratio = np.trace(flow_matrix.values) / flow_matrix.values.sum()
print(f"对角线流动占比: {diag_ratio:.2%}")
体检三:矩阵是否对称(如需要)
对于无向矩阵,验证是否对称:
python复制assert (flow_matrix.values == flow_matrix.values.T).all()
6.2 矩阵的可视化:热力图是最直观的表达
矩阵构建完成后,热力图是最常用的可视化方式。Seaborn的heatmap可以直接接受DataFrame输入:
python复制import seaborn as sns
import matplotlib.pyplot as plt
plt.figure(figsize=(8, 6))
sns.heatmap(
flow_matrix,
annot=True, # 在色块上标注数值
fmt="d", # 整数格式
cmap="YlOrRd", # 黄色到红色的渐变
linewidths=0.5,
linecolor="white"
)
plt.title("OD Flow Matrix")
plt.xlabel("Destination")
plt.ylabel("Origin")
plt.show()
如果矩阵数值跨度极大,比如最大流量是几万、最小是几十,建议对数值取对数后再画热力图,否则色阶会被极值压垮,小数值区域全部显示为同一颜色:
python复制import numpy as np
sns.heatmap(
np.log1p(flow_matrix),
annot=False,
cmap="YlOrRd"
)
6.3 高效输出:CSV与Parquet的取舍
矩阵构建完成后的输出环节,我一般分两种场景:
如果是交给业务方查看,输出CSV最稳妥,Excel直接能打开:
python复制flow_matrix.to_csv("flow_matrix.csv", encoding="utf-8-sig")
注意:如果要在Windows上用Excel打开,编码一定选择utf-8-sig而不是utf-8,否则表头会出现中文乱码。这是我替无数同事填过的坑。
如果是后续还要用Python继续处理,或矩阵规模很大(比如几千个区域,几十万单元格),建议保存为parquet格式:
python复制flow_matrix.to_parquet("flow_matrix.parquet")
parquet是列式存储格式,读写速度远快于CSV,而且保留了数据的数据类型和索引信息。这也是为什么我看到热搜词里有“pandas与numpy实现spark在格式parquet及语言feather等上的案例操作”时特别有共鸣——实际项目中,大到几个GB的人口流动数据,CSV的读写已经很难忍受了。换成parquet或feather之后,处理时间能缩短一个数量级。
7. 面向更大规模数据的性能优化思路
人口流动数据很容易变得很大。一个全国范围、按小时粒度的出行数据集,动辄上亿条记录。如果直接用pivot_table处理这么大的数据,内存可能直接爆掉。这里分享几个我自己验证过的性能优化思路。
7.1 用category类型压缩内存
对于区域编码这种取值有限的字符串列,使用category类型能大幅降低内存占用:
python复制df_clean["origin"] = df_clean["origin"].astype("category")
df_clean["destination"] = df_clean["destination"].astype("category")
在我的实测中,把区域名从object类型转为category后,内存占用能减少60%以上,groupby和crosstab的速度也有明显提升。
7.2 读取数据时指定dtype
读入数据时就指定列类型,避免Pandas先自动推断一遍再转换:
python复制df = pd.read_csv(
"flow_data.csv",
dtype={"origin": "category", "destination": "category", "transport_mode": "category"}
)
这里再提一个版本相关的小问题:热门搜索里有一条是“python3.10与哪个pandas版本适配”。其实Pandas对Python版本的兼容性官方文档里写得很清楚,但我个人的建议是:如果你用Python 3.10,至少安装pandas 1.5.0以上版本,推荐直接上2.0及以上。老版本在3.10上偶尔会出现编译警告或奇怪的C扩展错误,升级到2.x之后基本都消停了。
7.3 分组规模过大时改用稀疏矩阵
如果区域数量非常多,比如几千个县区,那么完整的N x N矩阵会非常稀疏,绝大多数单元格是0。此时用Pandas DataFrame保存会造成极大的内存浪费,更好的选择是SciPy的稀疏矩阵(COO或CSR格式),压缩后几乎不占空间。
Pandas和SciPy的衔接并不复杂:先用groupby统计非零单元格,再转为稀疏矩阵:
python复制from scipy.sparse import coo_matrix
# 先给区域编码
origin_codes, origin_uniques = pd.factorize(df_clean["origin"])
dest_codes, dest_uniques = pd.factorize(df_clean["destination"])
# 统计每个组合的频次
agg = df_clean.groupby([origin_codes, dest_codes]).size()
rows, cols = agg.index.to_list()[0], agg.index.to_list()[1]
data = agg.values
# 构建COO稀疏矩阵
sparse_od = coo_matrix((data, (rows, cols)), shape=(len(origin_uniques), len(dest_uniques)))
这样即使区域数量上万,矩阵也能轻松载入内存,后续做矩阵分解、社区发现等算法的输入都符合格式要求。
8. 从单一矩阵到完整的数据科学工作流
构建人口流动矩阵这件事,单看是“数据重组”的一个练习,放进完整的数据科学链路里,它是整个分析流程的地基。矩阵建得对不对,直接影响后续交通需求预测、疾病传播建模、商业选址评估等一系列模型的质量。
8.1 矩阵是很多模型的直接输入
- 在交通预测中,OD矩阵结合路网数据,可以估计各路段的车流量。
- 在传染病模型中,OD矩阵反映了人群在不同区域间的移动强度,是计算传播概率的关键输入。
- 在商业选址中,矩阵中“高输出区域→高收入区域”的流向,直接指向潜在的消费动线。
矩阵构建如果不严谨,后续模型再精巧也是建立在流沙之上。所以我在前文花了大量篇幅讲“行列补全”“清洗幽灵行列”“校验对角线占比”,这些都直接决定矩阵是否可靠。
8.2 从“静态矩阵”走向“动态序列”
真实项目中,单一时间段的静态矩阵往往不够用。人口流动是有节律的:工作日早高峰和晚高峰的流动模式完全不同,节假日的跨区域流动又呈现另一种特征。
所以我在实践中常做的操作是:先按时间段拆分数据,分别构建多个矩阵,再上下堆叠成一个张量,或按“起点-终点-时间段”展开成三维结构:
python复制flow_tensor = df_clean.pivot_table(
index="time_slot",
columns=["origin", "destination"],
values="user_id",
aggfunc="count",
fill_value=0
)
这样每一行是一个时间段,每一列是一个OD组合,后续做时间序列聚类、流量预测,输入格式一目了然。
8.3 一个值一万块钱的提醒:记录单位要提前对齐
最后分享一个让我印象深刻的教训。有一次我在处理多个来源拼接的数据时,一份数据的记录单位是“人次”,另一份是“人数”。直接合并后构建矩阵,得到的数值完全不可比——一份是“某天A到B有500人次”(同一人一天可能来回两次),另一份是“某天A到B有300人”(去掉重复后的去重人数)。
这两个单位一旦混用,矩阵对角线会发生异常,行和列合计也解释不通。从那以后,我在任何数据合并之前,都会先确认“记录单位”到底是什么意思,并在代码里用变量名或注释显式标注。
要说明的是,这个看起来不起眼的数据语义问题,比任何代码语法问题都更容易毁掉一个分析项目。因为语法错误会直接爆红报错,而单位错误只会产出“看起来正确、实则完全错误”的结论——这种错误最难被察觉。
构建人口流动矩阵,技术难度不算高,但涉及的数据处理细节非常多,任何一个环节不小心,都可能让最终结果失真。用Pandas进行数据重组,最核心的能力不是记住某个函数,而是能在面对一堆原始数据时,快速判断出“数据该怎么清洗、用哪种方式重组、矩阵该怎么校验”。这种判断力,正是数据科学区别于单纯编程的地方。
