2025, Nov 01 11:17

Рекурсивное сплющивание JSON в одну строку с индексами

Сделайте вложенный JSON плоским: когда pandas.json_normalize и explode бессильны, выручит рекурсивный обход с индексами и стабильными ключами для одной строки.

Превратить сильно вложенный JSON в структуру из одной строки кажется задачей простой — пока не появляются списки объектов и многоуровневая вложенность. Стандартные инструменты вроде pandas.json_normalize и explode справляются со многими случаями, но когда в данных перемешаны списки словарей с вложенными списками, они нередко оставляют столбцы, в которых по‑прежнему лежат списки. Если цель — полностью плоский объект одной записи, где у каждого вложенного значения есть индексированный ключ, нужен детерминированный способ обхода и «раскрутки» структуры.

Проблема в контексте

JSON приходит из ответа Elasticsearch: запись с множеством скалярных полей и несколькими массивами объектов — например, icdDiagnosisCodes, serviceProcedures, procedureCodeModifiers и serviceDiagnoses. Применение json_normalize с ограничением max_level или последовательное использование explode не дали одной плоской строки: столбцы со значениями-списками остались без изменений.

import requests
import pandas as pd
import json
resp = requests.get(
    ELASTICSEARCH_URL,
    data=QUERY,
    auth=(config.get('username'), config.get('password')),
    verify=False,
    headers={'Content-Type': 'application/json'}
)
blob = resp.json()
slice_ = blob["hits"]["hits"][0]["_source"]["response"]["data"][CLAIMTYPE]
frame_main = pd.json_normalize(slice_, max_level=2).fillna('')
frame_nested = pd.json_normalize(frame_main['icdDiagnosisCodes'])
print(frame_nested.head(10).to_string())

Даже после нормализации такие поля, как icdDiagnosisCodes и serviceProcedures, остаются списками, а дальнейшие попытки explode не приводят к получению одной плоской строки с отдельными столбцами для каждого вложенного атрибута.

Что на самом деле происходит

json_normalize раскладывает словари по столбцам, но не раскрывает автоматически каждый список словарей в набор однозначных скалярных столбцов. Один вызов с max_level обрывает обход на заданной глубине; на этом уровне поля‑списки остаются списками. explode может превратить столбец со списком в несколько строк или записей, но если нужна однострочная репрезентация, где каждое вложенное значение становится отдельным полем, придётся пройтись по структуре вручную и назначить стабильные, уникальные ключи.

Есть и практическое требование модели данных: нужно сохранить все значения, даже повторяющиеся, а такие поля, как procedureCodeModifiers, могут содержать много элементов (до 25). Это исключает дедупликацию и требует схемы ключей с индексами по порядку.

Рекурсивный подход к «сплющиванию»

Подход ниже «сплющивает» словарь, склеивая вложенные ключи разделителем для наглядности и добавляя числовые индексы для позиций в списках. Он сохраняет все значения и различает дубликаты по позиции, что покрывает требование к полям вроде procedureCodeModifier_1, procedureCodeModifier_2 и т. д.

def squash_map(obj_map, base_key=None, glue="___"):
    flat = {}
    for k, v in obj_map.items():
        new_key = k if base_key is None else base_key + glue + k
        if isinstance(v, str):
            flat[new_key] = v
        elif isinstance(v, list):
            for idx, item in enumerate(v):
                flat.update(squash_map(item, base_key=f"{new_key}_{idx}", glue=glue))
    return flat

Вот минимальный фрагмент данных, показывающий, как применяются индексы списков и конкатенация ключей:

sample_payload = {
    "providerCity": "SOME CITY",
    "providerSpecialtyDescription": "PHYSICAL/OCCUPATIONAL THERAPY",
    "updateDate": "YYYY-MM-DD",
    "providerNpi": "XXXXXXXXXXX",
    "icdDiagnosisCodes": [
        {
            "icdDiagnosisCode": "M25551",
            "icdDiagnosisDecimalCode": "M25.551",
            "icdDiagnosisCodeDescription": "PAIN IN RIGHT HIP"
        },
        {
            "icdDiagnosisCode": "M545",
            "icdDiagnosisDecimalCode": "M54.5",
            "icdDiagnosisCodeDescription": "LOW BACK PAIN"
        }
    ],
    "dateOfBirth": "YYYY-MM-DD"
}
from pprint import pprint
pprint(squash_map(sample_payload))
{'dateOfBirth': 'YYYY-MM-DD',
 'icdDiagnosisCodes_0___icdDiagnosisCode': 'M25551',
 'icdDiagnosisCodes_0___icdDiagnosisCodeDescription': 'PAIN IN RIGHT HIP',
 'icdDiagnosisCodes_0___icdDiagnosisDecimalCode': 'M25.551',
 'icdDiagnosisCodes_1___icdDiagnosisCode': 'M545',
 'icdDiagnosisCodes_1___icdDiagnosisCodeDescription': 'LOW BACK PAIN',
 'icdDiagnosisCodes_1___icdDiagnosisCodeDecimalCode': 'M54.5',
 'providerCity': 'SOME CITY',
 'providerNpi': 'XXXXXXXXXXX',
 'providerSpecialtyDescription': 'PHYSICAL/OCCUPATIONAL THERAPY',
 'updateDate': 'YYYY-MM-DD'}

Порядок вывода в примере — заслуга pretty printer; в самом возвращаемом словаре сохраняется порядок добавления элементов.

Почему это работает с заданными ограничениями

Эта стратегия выдаёт один плоский словарь, который можно считать единственной записью. Каждый элемент вложенного списка получает индекс по порядку, а значит, дубликаты сохраняются намеренно. Поля вроде procedureCodeModifier естественным образом появятся как procedureCodeModifiers_0___procedureCodeModifier, procedureCodeModifiers_1___procedureCodeModifier и т. п., что покрывает сценарий с множеством модификаторов. То же действует и для вложенных массивов, например serviceDiagnoses внутри serviceProcedures, поскольку индексация повторяется на каждом участке пути.

Зачем это знать

Когда нужна однострочная репрезентация сложного JSON — особенно с «списками в списках» объектов — универсальное «сплющивание» часто не справляется. Небольшой явный обход даёт предсказуемую схему именования столбцов, стабильный порядок через индексы и отсутствие потерь значений. Это важно, когда потребители дальше по цепочке ждут наличие полей независимо от дублей и количества, а разные полезные нагрузки отличаются числом вложенных элементов.

Выводы

Если цель — по‑настоящему плоский объект, начните с определения правил построения ключей и индексации списков. Примените отдельный обход, чтобы получить нужную форму, и уже потом передайте результат в пайплайн с DataFrame. Так вложенные списки не «протекут» в столбцы, а требования вроде «сохранить все значения» и «поддерживать множество модификаторов» будут выполнены однозначно.

Статья основана на вопросе на StackOverflow от Bhavani Kumar Metla и ответе от Swifty.