Распаковка словаря в Python

Перевод статьи «How to Flatten a Dictionary in Python in 4 Different Ways».

В этой статье мы рассмотрим четыре способа преобразовать многоуровневый словарь (т.е. содержащий вложенные словари) в одноуровневый, «плоский». Каждый из этих методов имеет свои плюсы и минусы (мы кратко проанализируем производительность).

Все примеры в этом руководстве запускаются на Python 3.7.

Зачем вообще распаковывать вложенные словари?

Есть много причин, по которым вам может понадобиться именно «плоский» словарь. Например, так проще сравнить два словаря. А еще так проще перемещаться по словарю и осуществлять какие-либо действия с ним, поскольку плоская структура имеет всего один уровень в глубину.

Python — очень гибкий язык, а это значит, что вы можете достичь желаемой цели несколькими способами. Чтобы выбрать наилучшее решение, нужно взвесить преимущества всех имеющихся вариантов.

Цель этой статьи — рассказать о доступных опциях и дать вам как можно больше данных, чтобы вы могли сделать осознанный выбор. Итак, поехали.

P.S. Если у вас нет Python 3.7, вы можете его установить при помощи pyenv (даже параллельно с другими версиями — и без всяких конфликтов).

Делаем многоуровневый словарь плоским при помощи собственной рекурсивной функции

Быстрый поиск в Google приводит нас на StackOverflow. Первый же ответ предлагает рекурсивную функцию, которая перебирает словарь и возвращает «уплощенный» экземпляр. Вдохновившись этой функцией, давайте создадим немного более продвинутую версию.

Можем начать с type hinting (явного указания типов). Это улучшит читаемость и сделает код типобезопасным.

from collections.abc import MutableMapping

def flatten_dict(d: MutableMapping, parent_key: str = '', sep: str ='.') -> MutableMapping:
    items = []
    for k, v in d.items():
        new_key = parent_key + sep + k if parent_key else k
        if isinstance(v, MutableMapping):
            items.extend(flatten_dict(v, new_key, sep=sep).items())
        else:
            items.append((new_key, v))
    return dict(items)


>>> flatten_dict({'a': 1, 'c': {'a': 2, 'b': {'x': 3, 'y': 4, 'z': 5}}, 'd': [6, 7, 8]})
{'a': 1, 'c.a': 2, 'c.b.x': 3, 'c.b.y': 4, 'c.b.z': 5, 'd': [6, 7, 8]}

Показатели производительности

Мы можем быстро убедиться, что функция действительно возвращает плоский словарь, но как насчет производительности? Будет ли хорошей идеей использовать этот подход в продакшен-среде? Давайте запустим быстрый замер показателей скорости.

Здесь и во всех дальнейших замерах производительности мы будем пользоваться магической функцией IPythontimeit, а также memit из библиотеки memory_profiler.

P.S. Чтобы функция %memit заработала, сперва нужно запустить %load_ext memory_profiler.

In [4]: %timeit flatten_dict({'a': 1, 'c': {'a': 2, 'b': {'x': 3, 'y': 4, 'z': 5}}, 'd': [6, 7, 8]})
7.28 µs ± 54.6 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

In [5]: %load_ext memory_profiler

In [6]: %memit flatten_dict({'a': 1, 'c': {'a': 2, 'b': {'x': 3, 'y': 4, 'z': 5}}, 'd': [6, 7, 8]})
peak memory: 84.94 MiB, increment: 0.29 MiB

Плюсы

Этот подход вполне понятен и, разумеется, он вполне рабочий.

Минусы

Все элементы словаря сохраняются в памяти в списке, который затем передается в конструктор dict. Это напрасная трата ресурсов памяти, а кроме того, плохо сказывается на скорости.

Несмотря на то, что добавление элементов в список в Python происходит быстро, повторение этой операции не является необходимым. В следующем разделе мы рассмотрим, как это можно улучшить с помощью генераторов.

Распаковываем вложенный словарь, используя собственную рекурсивную функцию + генераторы

Первая версия нашей функции работает, и даже относительно быстро. Тем не менее, есть проблема.

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

Куда лучшее решение — использовать генераторы Python. Генератор — объект, который может приостанавливать выполнение и запоминать состояние, чтобы воспроизвести его позже. Используя генератор, мы можем избавиться от временного списка, не изменив общее поведение функции.

from collections.abc import MutableMapping

def _flatten_dict_gen(d, parent_key, sep):
    for k, v in d.items():
        new_key = parent_key + sep + k if parent_key else k
        if isinstance(v, MutableMapping):
            yield from flatten_dict(v, new_key, sep=sep).items()
        else:
            yield new_key, v


def flatten_dict(d: MutableMapping, parent_key: str = '', sep: str = '.'):
    return dict(_flatten_dict_gen(d, parent_key, sep))

>>> flatten_dict({'a': 1, 'c': {'a': 2, 'b': {'x': 3, 'y': 4, 'z': 5}}, 'd': [6, 7, 8]})
{'a': 1, 'c.a': 2, 'c.b.x': 3, 'c.b.y': 4, 'c.b.z': 5, 'd': [6, 7, 8]}

Показатели производительности

In [9]: %timeit flatten_dict({'a': 1, 'c': {'a': 2, 'b': {'x': 3, 'y': 4, 'z': 5}}, 'd': [6, 7, 8]})
7.39 µs ± 78.7 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

In [7]: %memit flatten_dict({'a': 1, 'c': {'a': 2, 'b': {'x': 3, 'y': 4, 'z': 5}}, 'd': [6, 7, 8]})
peak memory: 45.27 MiB, increment: 0.25 MiB

Плюсы

В этом коде тоже все понятно. Эта версия работает так же, как и предыдущая, но более эффективно использует память. Если говорить о цифрах, то потребление ресурсов памяти снижается на 50% по сравнению с версией, где используются списки.

Минусы

Этот код может не слишком хорошо обрабатывать крайние случаи. Например, если мы передадим функции объект типа словаря, не являющийся экземпляром MutableMapping, этот пример упадет. Но этот недостаток касается и предыдущей версии нашей функции.

«Уплощаем» словарь в Python, используя json_normalize из pandas

Предыдущие решения, которые мы рассмотрели, работают хорошо. Но писать собственные решения для довольно распространенной проблемы — это заново изобретать велосипед. А ведь у нас есть популярные библиотеки для манипуляций с данными, такие как pandas.

pandas имеет дженерик-функцию для нормализации объектов JSON, которые в Python представлены в виде словарей. Для нас это отличная возможность не создавать решение с нуля, а использовать проверенный вариант.

Кроме того, конечный результат отлично выглядит и занимает всего одну строчку.

from collections.abc import MutableMapping
import pandas as pd

def flatten_dict(d: MutableMapping, sep: str= '.') -> MutableMapping:
    [flat_dict] = pd.json_normalize(d, sep=sep).to_dict(orient='records')
    return flat_dict


>>> flatten_dict({'a': 1, 'c': {'a': 2, 'b': {'x': 3, 'y': 4, 'z': 5}}, 'd': [6, 7, 8]})
{'a': 1, 'd': [6, 7, 8], 'c.a': 2, 'c.b.x': 3, 'c.b.y': 4, 'c.b.z': 5}

Показатели производительности

In [5]: %timeit flatten_dict({'a': 1, 'c': {'a': 2, 'b': {'x': 3, 'y': 4, 'z': 5}}, 'd': [6, 7, 8]})
779 µs ± 10.7 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

In [6]: %memit flatten_dict({'a': 1, 'c': {'a': 2, 'b': {'x': 3, 'y': 4, 'z': 5}}, 'd': [6, 7, 8]})
peak memory: 86.30 MiB, increment: 0.90 MiB

Плюсы

Это решение легко понять. Мы используем надежную библиотеку.

Минусы

Использовать pandas лишь для того, чтобы преобразовать многоуровневый словарь в одноуровневый, это несколько «слишком». Если в вашем проекте pandas больше ни для чего не нужна, лучше использовать более легковесную библиотеку, такую как FlatDict.

Кроме того, согласно показателей timeit, этот вариант в 100 раз медленнее нашего собственного решения, что не здорово.

Распаковываем многоуровневый словарь, используя библиотеку flatdict

flatdict — это библиотека Python, создающая из вложенного словаря одноуровневый. Она доступна в Python 3.5 и выше.

Пока мы увидели, что написание собственных решений — не идеальный вариант, но и использование полнофункциональной библиотеки вроде pandas лишь для распаковки словаря — тоже не предел мечтаний.

В качестве альтернативы мы можем использовать библиотеку flatdict — куда более легковесную и к тому же проверенную временем.

Эта библиотека очень гибкая; помимо прочего она позволяет использовать пользовательские разделители. Одна из лучших фич, которые она предлагает, — возможность доступа к новосозданному словарю, как к старому. То есть вы можете получить доступ к значениям, используя как новые ключи, так и старые.

Давайте рассмотрим пример.

>>> import flatdict
>>> d =  flatdict.FlatDict(data, delimiter='.')

# d - экземпляр FlatDict
>>> d
<FlatDict id=140665244199904 {'a': 1, 'c.a': 2, 'c.b.x': 3, 'c.b.y': 4, 'c.b.z': 5, 'd': [6, 7, 8]}>"

# и позволяет доступ к значениям по плоским ключам
>>> d['c.b.y']
4

# но кроме того - и по вложенным
>>> d['c']['b']['y']
4

# и может быт преобразован в уплощенный словарь
>>> dict(d)
{'a': 1, 'c.a': 2, 'c.b.x': 3, 'c.b.y': 4, 'c.b.z': 5, 'd': [6, 7, 8]}

Как видите, flatdict обеспечивает отличную гибкость и удобство.

Показатели производительности

In [3]: %timeit flatdict.FlatDict({'a': 1, 'c': {'a': 2, 'b': {'x': 3, 'y': 4, 'z': 5}}, 'd': [6, 7, 8]}, delimiter='.')
8.97 µs ± 21.6 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

In [4]: %memit flatdict.FlatDict({'a': 1, 'c': {'a': 2, 'b': {'x': 3, 'y': 4, 'z': 5}}, 'd': [6, 7, 8]}, delimiter='.')
peak memory: 45.21 MiB, increment: 0.14 MiB

Плюсы

Метод понятный и рабочий, а библиотека легковесная. Можно получать доступ ко вложенным элементам двумя способами. По скорости и потреблению ресурсов памяти это решение столь же эффективно, как и вариант с использованием генераторов.

Минусы

Это все равно внешняя библиотека. Как и в случае любого другого open-source инструмента, если вдруг в библиотеке обнаружится баг, вам придется ждать, пока автор его исправит. А авторы порой забрасывают свои проекты, что ставит под угрозу уже ваш проект. Но, несмотря на все это, в данном случае плюсы, пожалуй, перевешивают минусы.

Заключение

В этой статье мы рассмотрели четыре разных способа уплощения (распаковки) многоуровневого словаря в Python. Каждое из этих решений имеет свои достоинства и недостатки, так что выбор наилучшего целиком зависит от вашего вкуса и требований проекта.