Перевод статьи Tour of Python Itertools.

В этой статье мы рассмотрим возможности модулей itertools и more_itertools, а также покажем как использовать их на практике для обработки последовательностей данных.

Для языка Python разработано множество отличных библиотек, но большинство из них по функциональности даже не приближаются к тем, что встроены непосредственно в стандартную библиотеку, например, модуль itertools. В свою очередь модуль more_itertools, как следует из названия, является его гармоничным дополнением. Эти два модуля предоставляют инструментарий, по аналогии сопоставимый с функциональностью кухонного комбайна, когда дело доходит до обработки/итерации последовательностей данных. Тем не менее, на первый взгляд, не все функции из их состава могут показаться полезными на практике. Поэтому давайте сделаем небольшой тур по наиболее интересным, а также примерам того, как можно использовать их с максимальной эффективностью!

itertools

Compress

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

dates = [
    "2020-01-01",
    "2020-02-04",
    "2020-02-01",
    "2020-01-24",
    "2020-01-08",
    "2020-02-10",
    "2020-02-15",
    "2020-02-11",
]

counts = [1, 4, 3, 8, 0, 7, 9, 2]

from itertools import compress
bools = [n > 3 for n in counts]
print(list(compress(dates, bools)))  # функция compress возвращает итератор!
#  ['2020-02-04', '2020-01-24', '2020-02-10', '2020-02-15']

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

Accumulate

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

from itertools import accumulate
import operator

data = [3, 4, 1, 3, 5, 6, 9, 0, 1]

list(accumulate(data, max))  # перемещаем максимум по последовательности
#  [3, 4, 4, 4, 5, 6, 9, 9, 9]

list(accumulate(range(1, 11), operator.mul))  # факториал
#  [1, 2, 6, 24, 120, 720, 5040, 40320, 362880, 3628800]

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

Если вас не интересуют промежуточные результаты вычислений, вы можете использовать функцию reduce (называемый также fold в других языках программирования). Результат выполнения этой функции сохраняет только конечное значение и поэтому более эффективен с точки зрения использования памяти.

Cycle

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

# цикл по игрокам
from itertools import cycle

players = ["John", "Ben", "Martin", "Peter"]

next_player = cycle(players).__next__
player = next_player()
#  "John"

player = next_player()
#  "Ben"
#  ...

# бесконечный спиннер
import time

for c in cycle('/-\|'):
    print(c, end = '\r')
    time.sleep(0.2)

Tee

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

Примером ее использования на практике может послужить функция pairwise, которая возвращает пары значений из итерируемого ввода (текущее и предыдущее значение):

from itertools import tee

def pairwise(iterable):
    """
    s -> (s0, s1), (s1, s2), (s2, s3), ...
    """
    a, b = tee(iterable, 2)
    next(b, None)
    return zip(a, b)

Эта функция удобна если вам нужно получить несколько отдельных указателей на один и тот же поток данных. Однако будьте осторожны при его использовании, поскольку он может быть довольно дорогостоящим, когда дело касается памяти. Также важно отметить, что вы не должны использовать исходный объект итератор после передачи его в функцию tee, поскольку можете его “испортить”. Например, непреднамеренно сдвинуть указатель на текущее значение в новых итераторах.

more_itertools

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

Divide

И так знакомство с модулем more_itertools начнем с функции divide. Как следует из названия, ее использование позволяет разделить процедуру итерации на заданное количество, так сказать, под-итераций (итераторов). Как вы можете видеть из примера ниже, в результате ее выполнения длина полученных итераторов может получиться различной, так как будет зависеть от количества элементов в исходной последовательности и, указанного при вызове функции, количества под-итераций.

from more_itertools import divide
data = ["first", "second", "third", "fourth", "fifth", "sixth", "seventh"]

[list(l) for l in divide(3, data)]
#  [['first', 'second', 'third'], ['fourth', 'fifth'], ['sixth', 'seventh']]

Partition

С помощью этой функции мы также можем разделить наш итератор на отдельные части, но на этот раз с использованием предиката (логического условия):

# Разделение последовательности дат по срокам
from datetime import datetime, timedelta
from more_itertools import partition

dates = [ 
    datetime(2015, 1, 15),
    datetime(2020, 1, 16),
    datetime(2020, 1, 17),
    datetime(2019, 2, 1),
    datetime(2020, 2, 2),
    datetime(2018, 2, 4)
]
# определим функцию генерирующую дату на месяц позже от текущей
is_old = lambda x: datetime.now() - x 
                

В первом примере мы разделяем список дат на «новые» и «старые», используя значение текущей даты, генерируемое лямбда-функцией.

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

Consecutive_groups

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

import datetime
import more_itertools
import pprint
  
dates = [ 
    datetime.datetime(2020, 1, 15),
    datetime.datetime(2020, 1, 16),
    datetime.datetime(2020, 1, 17),
    datetime.datetime(2020, 2, 1),
    datetime.datetime(2020, 2, 2),
    datetime.datetime(2020, 2, 4)
]

ordinal_dates = []
for d in dates:
    ordinal_dates.append(d.toordinal())

groups = [list(map(datetime.datetime.fromordinal, group)) for group in more_itertools.consecutive_groups(ordinal_dates)]

pprint.pprint(groups)

#[[datetime.datetime(2020, 1, 15, 0, 0), datetime.datetime(2020, 1, 16, 0, 0), datetime.datetime(2020, 1, 17, 0, 0)],
# [datetime.datetime(2020, 2, 1, 0, 0), datetime.datetime(2020, 2, 2, 0, 0)],
# [datetime.datetime(2020, 2, 4, 0, 0)]]

И так имеется список дат, в котором некоторые из них идут подряд. Получим список всех последовательностей (серий) дат, значения которых идут подряд.

И первое что нужно сделать перед тем, как передать значения дат в функцию consequence_groups, это преобразовать их в порядковые числа. Затем, используя синтаксис списковых включений list comprehension, мы перебираем группы упорядоченных последовательностей дат, созданных функцией consecutive_groups, и конвертируем их обратно в тип datetime.datetime, используя функции map и fromordinal.

Side_effect

Допустим, вам нужно вызвать побочный эффект при итерации по списку элементов.

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

В нашем случае этим побочным эффектом может быть запись логов, запись результатов вычислений в файл или, как в приведенном ниже примере, подсчет числа прошедших событий:

import more_itertools

num_events = 0

def increment_num_events(_):
    global num_events
    num_events += 1
    print(f'Всего произошло событий: {num_events}')

# создает новый итератор на основе другого range(3)
event_iterator = more_itertools.side_effect(increment_num_events, range(3))

more_itertools.consume(event_iterator)

#Всего произошло событий: 1
#Всего произошло событий: 2
#Всего произошло событий: 3

В примере мы объявляем обычную функцию, которая будет при каждом своем вызове увеличивать на единицу глобальную переменную счетчик num_events. Передадим ее в функцию side_effect вместе с итератором, который создадим с помощью функции range.

Далее для того, чтобы перебрать полученный итератор event_iterator мы будем использовать функцию consume, также предоставляемую модулем more_itertools. Она, по сути, запускает и “исчерпывает” полученный итератор, при этом не возвращая значений. По умолчанию используется весь итератор, но может быть предоставлен второй необязательный аргумент для ограничения числа получаемых из него элементов.

Позже, когда все элементы event_iterator будут выбраны, для каждого будет вызвана функция increment_num_events, выводя в терминале текущее значение счетчика событий, то есть значение переменной num_events.

Collapse

Это более мощная версия flatten, еще одной функции из модуля more_itertools. Функция collapse позволяет сделать плоским список (кортеж) со сколь угодно большим числом уровней вложенности. Можно указать номер уровня до которого нужно «сгладить» исходную последовательность, передав значение для необязательного параметра levels. Используя другой необязательный параметр base_type можно указать некоторый базовый тип элементов под-последовательности, чтобы остановить «сглаживание» исходной. Вот несколько примеров использования этой функции:

import more_itertools
import os

tree = [40, [25, [10, 3, 17], [32, 30, 38]], [78, 50, 93]]
flatten_value = list(more_itertools.collapse(tree))
print(flatten_value)
#[40, 25, 10, 3, 17, 32, 30, 38, 78, 50, 93]

flatten_value = list(more_itertools.collapse(tree, levels=1))
print(flatten_value)
#[40, 25, [10, 3, 17], [32, 30, 38], 78, 50, 93]

tree = [40, (25, [10, 3, 17], [32, 30, 38]), [78, 50, 93]]
flatten_value = list(more_itertools.collapse(tree, base_type=tuple))
print(flatten_value)
#[40, (25, [10, 3, 17], [32, 30, 38]), 78, 50, 93]

В этом примере мы обрабатываем древовидную структуру данных в виде вложенных списков и кортежей: сворачиваем ее, чтобы получить плоский список ее значений. В первом случае мы делаем список полностью плоским. Во втором – ограничиваем уровень для «сглаживания». В третьем – указываем тип последовательности, то есть кортеж tuple, до уровня которой исходный список будет «сглажен».

Split_at

Давайте вернемся к проблеме разбиения последовательностей данных на под-последовательности. Функция split_at разбивает итерируемую последовательность на отдельные списки на основе условий, задаваемых предикатом (логическим выражением). Принцип ее работы напоминает функцию split, которая используется для разбиения на части строк по заданному разделителю. Но в нашем случае вместо строки используется итерируемая последовательность, а разделителя – функция предиката.

import more_itertools

lines = [
    "erhgedrgh",
    "erhgedrghed",
    "esdrhesdresr",
    "ktguygkyuk",
    "-------------",
    "srdthsrdt",
    "waefawef",
    "ryjrtyfj",
    "-------------",
    "edthedt",
    "awefawe",
]

list(more_itertools.split_at(lines, lambda x: '-------------' in x))
#  [['erhgedrgh', 'erhgedrghed', 'esdrhesdresr', 'ktguygkyuk'], ['srdthsrdt', 'waefawef', 'ryjrtyfj'], ['edthedt', 'awefawe']]

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

Bucket

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

# группируем по типу
import more_itertools

class Cube:
    pass

class Circle:
    pass

class Triangle:
    pass

shapes = [Circle(), Cube(), Circle(), Circle(), Cube(), Triangle(), Triangle()]
s = more_itertools.bucket(shapes, key=lambda x: type(x))
# s -> 
list(s[Cube])
#  [<__main__.cube object at>, <__main__.cube object at>]
list(s[Circle])
# [<__main__.circle object at>, <__main__.circle object at>, <__main__.circle object at>]

В этом примере мы показали, как сгруппировать итерируемую последовательность в зависимости от типа ее элементов (объектов разных классов). Сначала мы объявляем несколько типов (классов) фигур, затем создаем список соответствующих объектов. Далее при вызове функции bucket этот список в соответствии с логикой, реализуемой ламбда функцией, преданной в key, будет преобразован в объект типа bucket или корзину с сортированными данными. Этот объект ведет себя как обычный словарь dict , у которого ключи будут соответствовать именам типов групп, а их значения – итераторами соответствующих значений. Кроме того, как вы можете видеть, каждый элемент в объекте букета является генератором, поэтому нам нужно вызвать функцию list , чтобы получить его значения.

Map_reduce

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

from more_itertools import map_reduce
data = 'This sentence has words of various lengths in it, both short ones and long ones'.split()

keyfunc = lambda x: len(x)
result = map_reduce(data, keyfunc)
# defaultdict(None, {
#   4: ['This', 'both', 'ones', 'long', 'ones'],
#   8: ['sentence'],
#   3: ['has', 'it,', 'and'],
#   5: ['words', 'short'],
#   2: ['of', 'in'],
#   7: ['various', 'lengths']})

valuefunc = lambda x: 1
result = map_reduce(data, keyfunc, valuefunc)
# defaultdict(None, {
#   4: [1, 1, 1, 1, 1],
#   8: [1],
#   3: [1, 1, 1],
#   5: [1, 1],
#   2: [1, 1],
#   7: [1, 1]})

reducefunc = sum
result = map_reduce(data, keyfunc, valuefunc, reducefunc)
# defaultdict(None, {
#   4: 5,
#   8: 1,
#   3: 3,
#   5: 2,
#   2: 2,
#   7: 2})

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

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

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

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

Sort_together

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

# Исходная таблица
"""
      Name     |    Address    | Date of Birth |   Updated At 
----------------------------------------------------------------
John           |               |  1994-02-06   |   2020-01-06  
Ben            |               |  1985-04-01   |   2019-03-07  
Andy           |               |  2000-06-25   |   2020-01-08  
Mary           |               |  1998-03-14   |   2018-08-15  
"""

from more_itertools import sort_together

cols = [
    ("John", "Ben", "Andy", "Mary"),
    ("1994-02-06", "1985-04-01", "2000-06-25", "1998-03-14"),
    ("2020-01-06", "2019-03-07", "2020-01-08", "2018-08-15")
]

sort_together(cols, key_list=(1, 2))
#  [('Ben', 'John', 'Mary', 'Andy'), ('1985-04-01', '1994-02-06', '1998-03-14', '2000-06-25'), ('2019-03-07', '2020-01-06', '2018-08-15', '2020-01-08')]

Исходными данными для функции является итерируемый список cols со значениями таблицы по столбцам, как это показано в примере.

В параметре key_listопределяется, какие столбцы таблицы используются для сортировки и с каким приоритетом (в каком порядке проводить сортировку). В случае приведенного этого примера сначала таблица будет сортирована по столбцу с датами рождения Date of Birth, а затем по столбцу со временем обновления данных пользователя Updated At.

Seekable

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

from more_itertools import seekable

data = "This is example sentence for seeking back and forth".split()

it = seekable(data)
for word in it:
    ...

next(it)
# StopIteration
it.seek(3)
next(it)
# "sentence"

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

Filter_except

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

from more_itertools import filter_except

data = ['1.5', '6', 'not-important', '11', '1.23E-7', 'remove-me', '25', 'trash']
list(map(float, filter_except(float, data, TypeError, ValueError)))
#  [1.5, 6.0, 11.0, 1.23e-07, 25.0]

Функция filter_except фильтрует элементы исходной итерируемой последовательности, последовательно передавая ее элементы в заданную функцию ( float). Функция проверяет, возбуждается при ее вызове исключение заданного типа ( TypeError, ValueError) или нет, сохраняя только элементы, прошедшие проверку.

Unique_to_each

unique_to_each – одна из самых непонятных функций в модуле more_itertools. Она принимает на вход несколько итерируемых последовательностей и возвращает элементы из каждой из них, которых нет в других, то есть уникальные элементы. Посмотрим на нее в действии на примере:

from more_itertools import unique_to_each

# Граф (список смежных вершин)
graph = {'A': {'B', 'E'}, 'B': {'A', 'C'}, 'C': {'B'}, 'D': {'E'}, 'E': {'A', 'D'}}

unique_to_each({'B', 'E'}, {'A', 'C'}, {'B'}, {'E'}, {'A', 'D'})
# [[], ['C'], [], [], ['D']]
# Если мы отбрасываем узел B, то C становится изолированным, а если мы отбрасываем узел E, то и D становится изолированным
img

В примере выше мы определяем структуру графа данных, используя список смежных вершин adjacency list (фактически словарь dict). Затем передаем последовательности вершин соседей каждого узла как отдельный набор данных unique_to_each. Наша функция выведет список узлов, которые будут изолированы, если соответствующий узел будет удален.

Numeric_range

Довольно часто нам приходится иметь дело с генерацией последовательностей нецелочисленных значений в заданном диапазоне. Кроме того получаемые значения могут представляться в форматах, отличных от простых типов. И в этом случае функция numeric_range именно то, что нам нужно:

from more_itertools import numeric_range
import datetime
from decimal import Decimal

list(numeric_range(Decimal('1.7'), Decimal('3.5'), Decimal('0.3')))
#  [Decimal('1.7'), Decimal('2.0'), Decimal('2.3'), Decimal('2.6'), Decimal('2.9'), Decimal('3.2')]

start = datetime.datetime(2020, 2, 10)
stop = datetime.datetime(2020, 2, 15)
step = datetime.timedelta(days=2)
list(numeric_range(start, stop, step))
#  [datetime.datetime(2020, 2, 10, 0, 0), datetime.datetime(2020, 2, 12, 0, 0), datetime.datetime(2020, 2, 14, 0, 0)]

Самое приятное в использовании функции numeric_range то, что она ведет себя точно так же, как функция range. То есть для управления ее работой необходимо указать значения тех же аргументов: start, stop и step. В примере выше мы сначала получаем генератор значений типа Decimal, то есть десятичных дробей от 1.7 до 3.5 с шагом 0.3, а затем генератор дат datetime между 2020/2/10 и 2020/2/15 с шагом 2 дня.

Make_decorator

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

from more_itertools import make_decorator
from more_itertools import map_except

mapper_except = make_decorator(map_except, result_index=1)

@mapper_except(float, ValueError, TypeError)
def read_file(f):
    ... # Считываем из последовательность строк в виде текстовых и числовых данных
    return ['1.5', '6', 'not-important', '11', '1.23E-7', 'remove-me', '25', 'trash']

list(read_file("file.txt"))
#  [1.5, 6.0, 11.0, 1.23e-07, 25.0]

В этом примере на основе функции map_except вначале создаем декоратор. Отметим, что при вызове make_decorator в именованный аргумент result_index = 1 мы передаем значение, соответствующее позиции аргумента функции map_except, в который передается итерируемая последовательность. Функция read_file имитирует чтение списка строк из некоторого файла, а затем возвращает итератор списка строк. Результат работы внутренней функции передается декоратору, который фильтрует последовательность строк, убирая ненужные элементы, оставляя только строки, представляющие собой числа с плавающей запятой.

Заключение

Я надеюсь, что вы узнали что-то новое из этой статьи. Я уверен что в дальнейшем использование возможностей модулей itertools и more_itertools поможет значительно облегчить вашу жизнь. Тем не менее использование рассмотренных модулей требует некоторой практики. Кроме примеров рассмотренных в этой статье вы можете ознакомиться с рецептами itertools или просто заставьте себя использовать их как можно чаще, чтобы освоить основы работы с ними. 😉

1 комментарий

Оставить комментарий