从思维导图到结构化 JSON:使用 Python+pandas 序列化无序数据

287 阅读13分钟

目前在做一个问答类的服务,通过互联网、线下咨询等多种渠道获取大量非结构化的数据。这些数据在原始状态下往往杂乱无章,难以直接利用。为了更好地分析和应用这些数据,我们需要将其整理成结构化的格式。本文将介绍如何将如图所示的非关系型数据整理成对齐的 JSON 格式。

数据结构描述

如图所示,我们的原始数据包含了一个层级结构(没错就是思维导图)。 从顶层的 "Idea" 开始,逐级细化为 "A" 和 "B",再进一步细化为 "A1"、"A2"、"B1" 和 "B2",最终到达 "A1a"、"A1b" 和 "A2a" 等具体条目。为了便于处理,我们将这种层级结构的数据导出成了一个表格形式,其中每一行代表一个完整的层级路径。

例如:

  • "Idea -> A -> A1 -> A1a" 对应表格中的一行,其中每一列分别存储层级中的一个节点。

image.png

经过裁剪,真实数据大概是这个样子

12345
学龄前(0-3周)饮食营养0-3岁儿童需要吃些什么样的食物才能获得足够的营养?
学龄前(0-3周)饮食营养哪些食物是0-3岁儿童的健康成长必不可少的?
学龄前(0-3周)饮食营养儿童在什么年龄段需要添加辅食?
学龄前(0-3周)饮食营养添加辅食的时候应该注意些什么?
学龄前(0-3周)饮食营养有哪些食物会导致婴幼儿过敏?
学龄前(0-3周)饮食营养食品中的铁对于0-3岁儿童来说为何如此重要?
  • 列1固定是学龄段

  • 如果列5不为空一定是问题。

  • 学龄段和问题之间的一定是tag,区别只不过是tag有多少的问题(1-3个)

这有何难?按行转json不就行了吗

要将一个 CSV 文件按照行转换为 JSON 格式,可以使用 pandas 库中的 to_json 方法。我很很快快快搞出来这个:

import pandas as pd

# 读取 CSV 文件
csv_ = pd.read_csv('家长问题收集.csv')

# 将 DataFrame 按行转换为 JSON 格式
json_result = csv_.to_json(orient='records', force_ascii=False)

# 打印或保存 JSON 结果
print(json_result)

# 如果需要保存到文件
with open('家长问题收集.json', 'w', encoding='utf-8') as f:
    f.write(json_result)

在这个代码中:

  1. pd.read_csv('家长问题收集.csv') 读取 CSV 文件并将其存储在 DataFrame 中。
  2. to_json(orient='records', force_ascii=False) 将 DataFrame 按行(记录)转换为 JSON 格式。orient='records' 表示每一行是一个 JSON 对象,force_ascii=False 确保非 ASCII 字符能够正确显示。
  3. 最后,JSON 数据被打印出来并保存到文件 '家长问题收集.json' 中。
python run.py
# 得到的结果
[  {
    "1": "学龄前(0-3周)",
    "2": "饮食营养",
    "3": "0-3岁儿童需要吃些什么样的食物才能获得足够的营养?",
    "4": null,
    "5": null
  },
  。。。。。。
  {
    "1": "学龄前(0-3周)",
    "2": "特殊情况下的护理",
    "3": null,
    "4": null,
    "5": "如何与专业人士合作处理严重的行为问题?"
  },
]

从结果看呢,还是不错的。但是当tag数量大于1的时候问题就来了,《如何与专业人士合作处理严重的行为问题?》这里中间丢了两个tag。看了一下原文,大致明白了。

image.png

这里的合并过的单元格在处理的时候除了第一行之外都会按照null处理。但是我们期望是234都是标签可以放在一个str[]中

向前填充

对于这种合并单元格的情况,我又想到了先将合并的单元格数据进行填充,然后再进行转换。可以使用 pandas 的 ffill(向前填充)方法来补全合并单元格的数据。以下是一个示例代码,展示了如何完成这个任务:

import pandas as pd

# 读取 CSV 文件
csv_ = pd.read_csv('家长问题收集.csv')

# 使用 ffill 方法填充合并单元格的数据
csv_filled = csv_.fillna(method='ffill')

# 将 DataFrame 按行转换为 JSON 格式
json_result = csv_filled.to_json(orient='records', force_ascii=False)

# 打印或保存 JSON 结果
print(json_result)

# 如果需要保存到文件
with open('家长问题收集.json', 'w', encoding='utf-8') as f:
    f.write(json_result)

在这个代码中:

  1. pd.read_csv('家长问题收集.csv') 读取 CSV 文件并将其存储在 DataFrame 中。
  2. fillna(method='ffill') 方法用于向前填充合并单元格的数据(即用前一行的值填充当前行的空值)。
  3. to_json(orient='records', force_ascii=False) 将 DataFrame 按行(记录)转换为 JSON 格式。orient='records' 表示每一行是一个 JSON 对象,force_ascii=False 确保非 ASCII 字符能够正确显示。
  4. 最后,JSON 数据被打印出来并保存到文件 '家长问题收集.json' 中。

这样我们就可以先填充合并单元格的数据,然后将其逐行转换为 JSON 格式了。

又又不行了

运行之后又出现了一个奇怪的问题

[
    。。。。。。
    {
    "1": "大学",
    "2": "社会责任与志愿服务",
    "3": "孩子在志愿服务和社会责任上有疑问时,家长应该如何引导?",
    "4": "孩子在社会责任和法律义务上有疑问时,家长应该如何解释?",
    "5": "财务记录过程中应关注哪些细节和方法?"
  },
  {
    "1": "幼儿园",
    "2": "入园准备",
    "3": "选择合适的幼儿园",
    "4": "如何选择适合宝宝的幼儿园?",
    "5": "如何与专业人士合作处理严重的行为问题?"
  },
  {
    "1": "幼儿园",
    "2": "入园准备",
    "3": "选择合适的幼儿园",
    "4": "幼儿园的教育理念和方法应考虑哪些因素?",
    "5": "如何与专业人士合作处理严重的行为问题?"
  },
]

新出现的问题也很经典,这是把不足3个tag的也补满了。一个问题没有在应该结束的地方结束,这显然也不符合我们的预期。

那我们试试自己遍历每一行

遍历表格

从需求上看列12345中必有三个不是null,或者说是列123不可能是空的,从3开始找到第一个非空值(真正的问题)向上填充,才能得到一个完整的问题。至于的null就可以略过了。

也就是说我们的需求要满足需要确保从列3到列5中找到第一个非空值并将其向上填充,同时避免覆盖其他已经存在的值

import pandas as pd

# 读取 CSV 文件
csv_ = pd.read_csv('家长问题收集.csv')

# 填充列1和列2的空值
csv_filled = csv_.copy()
columns_to_fill = ['1', '2']
for column in columns_to_fill:
    csv_filled[column] = csv_filled[column].fillna(method='ffill')

# 向上填充列3到列5的空值
for col in ['3', '4', '5']:
    for i in range(len(csv_filled) - 1, -1, -1):
        if pd.isna(csv_filled.at[i, col]):
            for j in range(i + 1, len(csv_filled)):
                if pd.notna(csv_filled.at[j, col]):
                    csv_filled.at[i, col] = csv_filled.at[j, col]
                    break

# 将 DataFrame 按行转换为 JSON 格式
json_result = csv_filled.to_json(orient='records', force_ascii=False)

# 打印或保存 JSON 结果
print(json_result)

# 如果需要保存到文件
with open('家长问题收集.json', 'w', encoding='utf-8') as f:
    f.write(json_result)

在这个代码中:

  1. 我们读取 CSV 文件并创建一个副本。
  2. 填充列1和列2的空值。
  3. 从最后一行开始向上遍历 DataFrame,对于每一列(列3到列5),如果当前单元格为空,则向下查找第一个非空值并将其填充到当前单元格。
  4. 将填充后的 DataFrame 按行转换为 JSON 格式并保存到文件中。

这样可以确保列3到列5的空值向上填充,但不会覆盖已有的值。

又又又不行了了

python run.py
{
    "1": "学龄前(0-3周)",
    "2": "特殊情况下的护理",
    "3": "选择合适的幼儿园",
    "4": "如何选择适合宝宝的幼儿园?",
    "5": "如何通过积极的教育方法引导宝宝的行为?"
  },
  {
    "1": "学龄前(0-3周)",
    "2": "特殊情况下的护理",
    "3": "选择合适的幼儿园",
    "4": "如何选择适合宝宝的幼儿园?",
    "5": "如何与专业人士合作处理严重的行为问题?"
  },
  {
    "1": "幼儿园",
    "2": "入园准备",
    "3": "选择合适的幼儿园",
    "4": "如何选择适合宝宝的幼儿园?",
    "5": "如何确保孩子在幼儿园吃到均衡的营养?"
  },

让我大跌眼镜,12列木有问题。3到列5顺序乱麻了,尤其是到了下班点脑袋更是浆糊一片。这思路到底是哪里不行呢?

过程解决不了,那结果呢?

中间无论是用pandas还是自己遍历都搞得乱七八糟,我都要放弃然后自己去手工调整了。突然眼前一亮:我能手动改结果,py就能帮我改啊

回到向前填充的时候,我直接去掉有问题的数据不就行了吗?这些数据可以自己下一个定义——问题不可能重复也不会出现在tag中。那我们可以在生成后遍历一下把异常值去掉。

至于这个定义对不对,等内容部门的人找来再说吧。反正俺要下班

处理过程中,可以创建一个问题集合。如果集合中问题存在单元格内容是NaN基本上可以断定这个问题是无效的(可能就是多填充的)那就指针前挪一位把这个列中的值当做异常值pass掉

上代码

import pandas as pd
import json

# 读取 CSV 文件
csv_ = pd.read_csv('家长问题收集.csv')

# 使用 ffill 方法填充合并单元格的数据
csv_filled = csv_.fillna(method='ffill')

# 创建新的数组格式
new_array = []
existing_titles = set()

for _, row in csv_filled.iterrows():
    level = row['1']
    
    # 找到最后一个非空列作为 title,并调整指针位置
    title = None
    for col in reversed(['5', '4', '3']):
        if pd.notna(row[col]) and row[col] not in existing_titles:
            title = row[col]
            break
    
    # 如果 title 是 NaN 或已经存在于 existing_titles 中,则跳过处理
    if title is None or title in existing_titles:
        continue
    
    # 将 title 添加到 existing_titles 中
    existing_titles.add(title)
    
    # tags 是 level 和 title 之间的列
    tags = []
    for col in ['2', '3', '4']:
        if pd.notna(row[col]) and row[col] != title:
            tags.append(row[col])
        elif row[col] == title:
            break
    
    new_array.append({
        'level': level,
        'tag': tags,
        'title': title
    })

# 将新数组转换为 JSON 格式
json_result = json.dumps(new_array, ensure_ascii=False, indent=4)

# 打印或保存 JSON 结果
print(json_result)

# 如果需要保存到文件
with open('家长问题收集.json', 'w', encoding='utf-8') as f:
    f.write(json_result)

运行一下 python run.py

[
......
  {
    "level": "学龄前(0-3周)",
    "tag": ["特殊情况下的护理", "社交与行为问题", "行为问题"],
    "title": "如何通过积极的教育方法引导宝宝的行为?"
  },
  {
    "level": "学龄前(0-3周)",
    "tag": ["特殊情况下的护理", "社交与行为问题", "行为问题"],
    "title": "如何与专业人士合作处理严重的行为问题?"
  }
]

完美解决,溜之~

代码回顾

import pandas as pd
import json

# 读取 CSV 文件
csv_ = pd.read_csv('家长问题收集.csv')

# 使用 ffill 方法填充合并单元格的数据
csv_filled = csv_.fillna(method='ffill')

这两行代码的作用是读取 CSV 文件,并使用 ffill 方法填充合并单元格的数据。ffill 方法会将前一行的值填充到当前行的空值中。

# 创建新的数组格式
new_array = []
existing_titles = set()

这里我们创建了一个空数组 new_array 来存储最终的 JSON 数据,并创建了一个集合 existing_titles 来跟踪已经处理过的标题,确保不会重复处理。

for _, row in csv_filled.iterrows():
    level = row['1']
    
    # 找到最后一个非空列作为 title
    title = None
    for col in ['5', '4', '3']:
        if pd.notna(row[col]):
            title = row[col]
            break

这段代码遍历每一行,并从最后一列开始向前查找第一个非空值,将其作为标题(title)。这样可以确保我们找到的是每行的实际问题,而不是标签。

    # 如果 title 是 NaN 或已经存在于 existing_titles 中,则跳过处理
    if title is None or title in existing_titles:
        continue
    
    # 将 title 添加到 existing_titles 中
    existing_titles.add(title)

这里检查 title 是否为空或已经存在于 existing_titles 集合中。如果是,则跳过该行的处理。这可以防止重复处理相同的问题。

    # tags 是 level 和 title 之间的列
    tags = []
    for col in ['2', '3', '4']:
        if pd.notna(row[col]) and row[col] != title:
            tags.append(row[col])
        else:
            break

这段代码从第二列到第四列(跳过第一列和最后一列)提取标签。如果列值非空并且不等于 title,则将其添加到 tags 列表中。一旦遇到空值或 title,则停止添加标签。

    new_array.append({
        'level': level,
        'tag': tags,
        'title': title
    })

将处理后的数据(包括 leveltagstitle)添加到 new_array 中。

# 将新数组转换为 JSON 格式
json_result = json.dumps(new_array, ensure_ascii=False, indent=4)

# 打印或保存 JSON 结果
print(json_result)

# 如果需要保存到文件
with open('家长问题收集.json', 'w', encoding='utf-8') as f:
    f.write(json_result)

最后,将 new_array 转换为 JSON 格式并打印以及保存到文件中。

通过上述步骤,我们解决了以下问题:

  1. 填充合并单元格的数据:使用 ffill 方法填充空值。
  2. 确保每行数据的完整性:从最后一列开始查找第一个非空值作为问题标题。
  3. 避免重复处理:使用集合跟踪已经处理过的标题。
  4. 提取标签:从第二列到第四列提取标签,确保标签不包含问题标题。

附件——函数封装

import pandas as pd
import json

def csv_to_json(csv_file, first_key, last_key):
    """
    将 CSV 文件转换为 JSON 格式,其中第一个和最后一个列作为固定键,中间列作为标签。

    参数:
    csv_file (str): CSV 文件路径。
    first_key (str): JSON 中第一个列的键名。
    last_key (str): JSON 中最后一个列的键名。

    返回:
    str: 转换后的 JSON 字符串。
    """
    # 读取 CSV 文件
    csv_ = pd.read_csv(csv_file)

    # 使用 ffill 方法填充合并单元格的数据
    csv_filled = csv_.fillna(method='ffill')

    # 创建新的数组格式
    new_array = []
    existing_titles = set()

    for _, row in csv_filled.iterrows():
        level = row['1']
        
        # 找到有效的 title
        title = None
        for col in reversed(row.index):
            if pd.notna(row[col]) and row[col] not in existing_titles:
                title = row[col]
                break
        
        # 如果 title 仍然是 None,说明没有找到有效的 title,跳过处理
        if title is None:
            continue
        
        # 将 title 添加到 existing_titles 中
        existing_titles.add(title)
        
        # tags 是 level 和 title 之间的列
        tags = []
        for col in row.index[1:]:
            if pd.notna(row[col]) and row[col] != title:
                tags.append(row[col])
            elif row[col] == title:
                break
        
        new_array.append({
            first_key: level,
            'tag': tags,
            last_key: title
        })

    # 将新数组转换为 JSON 格式
    json_result = json.dumps(new_array, ensure_ascii=False, indent=4)

    return json_result

# 示例调用
csv_file = '家长问题收集.csv'
first_key = 'level'
last_key = 'title'

json_result = csv_to_json(csv_file, first_key, last_key)

# 打印或保存 JSON 结果
print(json_result)

# 如果需要保存到文件
with open('家长问题收集.json', 'w', encoding='utf-8') as f:
    f.write(json_result)