在数据清洗和特征工程中,我们经常会遇到用父子关系表存储的层级分类数据,例如商品类目、组织架构、地区划分等。一条记录代表一个父子关系,多张表拼接才能得到完整路径。当需要让每一条完整路径独占一行、并带上每一层的等级信息时,就需要将这种"竖着存"的树形数据"横着铺开"。本文通过一个直观的示例,讲解如何用 Python 的 pandas 和原生数据结构,将树形分类表平铺成多级列宽表。

一、问题场景
假设我们有一张表示分类关系的 Excel 表 source_classification.xlsx,记录了主分类、子分类以及各自的层级等级:
| 主分类 | 子分类 | 主分类等级 | 子分类等级 |
|---|---|---|---|
| 车 | 汽车 | 0 | 1 |
| 车 | 火车 | 0 | 1 |
| 车 | 单车 | 0 | 1 |
| 汽车 | 大众 | 1 | 2 |
| 汽车 | 日产 | 1 | 2 |
| ... | ... | ... | ... |
这张表实际上定义了一棵树:根节点是"车",它的子节点是"汽车""火车""单车";"汽车"又有子节点"大众""日产""丰田";"大众"下还有"电动""汽油""混动"。所有叶子节点深度并不完全相同,例如"日产"没有下一级,而"电动"已经是第三级。
我们的目标是将其展开为如下形式:
| 主分类1 | 子分类1 | 主分类等级1 | 子分类等级1 | 主分类2 | 子分类2 | 主分类等级2 | 子分类等级2 | 主分类3 | 子分类3 | 主分类等级3 | 子分类等级3 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 车 | 汽车 | 0 | 1 | 汽车 | 大众 | 1 | 2 | 大众 | 电动 | 2 | 3 |
| 车 | 汽车 | 0 | 1 | 汽车 | 大众 | 1 | 2 | 大众 | 汽油 | 2 | 3 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
每一行是一条从根到叶子节点的完整路径,每一步(即一条父子边)被拆成4列:该步的主分类、子分类、主分类等级、子分类等级。深度较浅的路径在缺少的位置填上空字符串。这样每一行就包含了该叶子节点的全部上游信息,极大方便了下游的查询、筛选和建模。
二、解决思路
整个过程可以拆分为三个核心步骤:
- 树重建:从关系表中提取父子连接,构建一个"父亲 → 孩子列表"的映射,并锁定根节点。
- 路径收集 :从根节点开始进行深度优先遍历(DFS),记录每一条从根到叶子的完整路径,路径中的每一步保存四元组
(主, 子, 主等级, 子等级)。 - 平铺对齐:计算所有路径的最大深度,按最大深度构造 DataFrame,深度不足的路径用空字符串填充,最后输出。
我们完全使用 Python 标准库和 pandas 实现,无需额外安装树结构库。
三、代码实现详解
首先引入依赖并读取数据:
python
import pandas as pd
from collections import defaultdict
source_file = 'source_classification.xlsx'
df = pd.read_excel(source_file)
1. 构建父子关系字典
我们使用 defaultdict(list) 来存储树的结构:键为父节点名称,值为一个列表,每个元素是 (子节点, 父等级, 子等级) 的元组。
python
children = defaultdict(list)
for _, row in df.iterrows():
parent = row['主分类']
child = row['子分类']
p_level = row['主分类等级']
c_level = row['子分类等级']
children[parent].append((child, p_level, c_level))
这样 children 就形成了一棵多叉树的邻接表表示。
2. 定位根节点
根据业务逻辑,根节点是"主分类等级"为 0 且唯一的那个主分类。我们可以从原表中直接筛选出来:
python
roots = df[df['主分类等级'] == 0]['主分类'].unique()
if len(roots) != 1:
raise ValueError("根节点不唯一或不存在")
root = roots[0] # 在本例中为 '车'
这样做可以避免硬编码根节点名称,让代码适应不同的数据。
3. DFS 收集所有路径
定义空列表 all_paths 用于存放每一条完整路径。这里路径的含义是从根到当前节点所经过的所有"边" ,而不是节点本身。每条路径是一个列表,其中的元素为单条边的四元组 (主分类, 子分类, 主等级, 子等级)。
python
all_paths = []
def dfs(node, path):
if node not in children: # 叶子节点,保存当前路径
all_paths.append(path.copy())
return
for child, p_lv, c_lv in children[node]:
path.append((node, child, p_lv, c_lv))
dfs(child, path)
path.pop() # 回溯
dfs(root, [])
注意两点:
- 只有当节点没有子节点(即
children中不存在该键)时,才将路径存入all_paths。这样得到的是所有叶子路径。 - 使用
path.copy()是为了保存路径的副本,避免后续回溯修改影响已存储的结果。
4. 计算最大深度并平铺
每条路径的长度(边数)可能不同,最大深度决定了最终表格有多少组列(每组4列)。
python
max_depth = max(len(p) for p in all_paths) if all_paths else 0
接下来遍历所有路径,对每条路径构造一行数据。对于第 i 层(0 <= i < max_depth),如果路径有该层数据则取出四元组,否则填充四个空字符串。
python
rows = []
for path in all_paths:
row = []
for i in range(max_depth):
if i < len(path):
parent, child, plv, clv = path[i]
row.extend([parent, child, plv, clv])
else:
row.extend(['', '', '', ''])
rows.append(row)
最后生成列名,格式为 "主分类1, 子分类1, 主分类等级1, 子分类等级1, 主分类2, ...":
python
columns = []
for i in range(1, max_depth + 1):
columns += [f'主分类{i}', f'子分类{i}', f'主分类等级{i}', f'子分类等级{i}']
df_flat = pd.DataFrame(rows, columns=columns)
5. 输出结果
将平铺后的 DataFrame 保存为新的 Excel 文件,并打印前几行以供预览。
python
output_file = 'flattened_classification.xlsx'
df_flat.to_excel(output_file, index=False)
print(f"平铺结果已保存至 {output_file}")
print("\n预览前5行:")
print(df_flat.head(5).to_string(index=False))
运行后会生成 flattened_classification.xlsx,内容完全符合预期。
四、运行结果展示
基于文章开头的示例数据,输出表格的前 5 行大致如下(列名省略):
| 车 | 汽车 | 0 | 1 | 汽车 | 大众 | 1 | 2 | 大众 | 电动 | 2 | 3 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 车 | 汽车 | 0 | 1 | 汽车 | 大众 | 1 | 2 | 大众 | 汽油 | 2 | 3 |
| 车 | 汽车 | 0 | 1 | 汽车 | 大众 | 1 | 2 | 大众 | 混动 | 2 | 3 |
| 车 | 汽车 | 0 | 1 | 汽车 | 日产 | 1 | 2 | ||||
| 车 | 汽车 | 0 | 1 | 汽车 | 丰田 | 1 | 2 |
可以看到,叶子"日产"和"丰田"没有第三级,所以对应的第 3 组列全部为空。树形结构被完整、整齐地展开为二维表格。
本文展示了一种纯 Python 解决方案,将父子关系表转换为全路径宽表。核心思想是通过 DFS 提取所有根到叶子的完整路径,再利用最大深度进行对齐平铺。该方法具有以下优点:
- 无需递归限制:Python 默认递归深度足够应对常见层级(通常数百层以内),若层级极深可以改用迭代栈。
- 列数自动适配:根据实际数据的最大深度动态生成列,不会冗余也不会缺失。
- 灵活可调:只需修改四元组的内容和列名生成逻辑,就可以适应不同字段的层级数据。
希望这篇博客能帮助你理解树形数据的平铺技巧,并在遇到类似场景时快速套用。完整代码已包含在文中,欢迎直接取用并根据自己的数据结构调整列名与层级字段。
全部源代码:
python
import pandas as pd
from collections import defaultdict
source_file = 'source_classification.xlsx'
# ========================
# 读取 Excel 并构建树
# ========================
df = pd.read_excel(source_file)
# 建立父子关系:children[parent] = [(child, parent_level, child_level), ...]
children = defaultdict(list)
for _, row in df.iterrows():
parent = row['主分类']
child = row['子分类']
p_level = row['主分类等级']
c_level = row['子分类等级']
children[parent].append((child, p_level, c_level))
# 查找根节点(主分类等级为 0 的主分类)
roots = df[df['主分类等级'] == 0]['主分类'].unique()
if len(roots) != 1:
raise ValueError("根节点不唯一或不存在")
root = roots[0]
# ========================
# DFS 收集所有路径
# ========================
all_paths = [] # 每条路径是一个列表,元素为 (主分类, 子分类, 主分类等级, 子分类等级)
def dfs(node, path):
if node not in children: # 叶子节点,保存路径
all_paths.append(path.copy())
return
for child, p_lv, c_lv in children[node]:
path.append((node, child, p_lv, c_lv))
dfs(child, path)
path.pop()
dfs(root, [])
# 计算最大深度(路径中边的数量)
max_depth = max(len(p) for p in all_paths) if all_paths else 0
# ========================
# 平铺成多级列
# ========================
rows = []
for path in all_paths:
row = []
for i in range(max_depth):
if i < len(path):
parent, child, plv, clv = path[i]
row.extend([parent, child, plv, clv])
else:
row.extend(['', '', '', '']) # 空白填充
rows.append(row)
# 构造列名:第1级:主分类1, 子分类1, 主分类等级1, 子分类等级1 ...
columns = []
for i in range(1, max_depth + 1):
columns += [f'主分类{i}', f'子分类{i}', f'主分类等级{i}', f'子分类等级{i}']
df_flat = pd.DataFrame(rows, columns=columns)
# ========================
# 输出结果 Excel
# ========================
output_file = 'flattened_classification.xlsx'
df_flat.to_excel(output_file, index=False)
print(f"平铺结果已保存至 {output_file}")
print("\n预览前5行:")
print(df_flat.head(5).to_string(index=False))