目次


Python でのオンデマンド・データ, 第 2 回

itertools のマジック

標準ライブラリーに含まれる、イテレーターを処理するための万能のツールキットについて学ぶ

Comments

コンテンツシリーズ

このコンテンツは全#シリーズのパート#です: Python でのオンデマンド・データ, 第 2 回

このシリーズの続きに乞うご期待。

このコンテンツはシリーズの一部分です:Python でのオンデマンド・データ, 第 2 回

このシリーズの続きに乞うご期待。

このシリーズの第 1 回では、Python イテレーターの基礎と、独自のイテレーターを作成する最も単純な方法、つまり、ジェネレーター関数またはジェネレーター式を作成する方法を説明しました。いったん基礎を把握すれば、後は次々と、イテレーターを基礎としたあらゆる類のアイデアを思い付くでしょう。けれども、すでに完成しているものを一から作り直さないようにすることが得策です。

Python の標準ライブラリーには、よく知られたものも、そうではないものも含め、貴重なサポートとツールが驚くほど包括的に揃っています。イテレーターを操作するのに役立つモジュールは複数ありますが、まずは組み込みでそのまま利用できる機能、つまりほとんどのところインポートしなくても使用できる機能を調べることをお勧めします。

イテレーターに基づいて map を使用する

map 関数を使用すると、イテレーターから生成される各アイテムを処理して、それらの結果から新しいイテレーターを生成できます。

>>> r = range(10)
>>> m = map(str, r)
>>> next(m)
'0'
>>> list(m)
['1', '2', '3', '4', '5', '6', '7', '8', '9']

map に渡す最初の引数はマッピング関数です。これが、各アイテムに適用されます。上記の例では、特定の範囲に含まれる各整数を文字列に変換する str 関数を渡しています。map イテレーターに最初のアイテムを要求すると、文字列が返されます。以降は、list を使用してすべてのアイテムを順に抽出します。これによって作成されるリストは 0 ではなく 1 から始まっていることに注目してください。map から最初のアイテムをすでに抽出しているためです。リストやタプルとは異なり、イテレーターはランダム・アクセスをサポートしません。つまり、アイテムは一度に 1 つずつ、順方向で返されます。

もちろん独自のマッピング関数を作成することもできます。以下のカスタム・マッピング関数は、map から 1 つの入力値を期待します。このコードをインタープリター・セッションに貼り付けてください。

def get_letter(i):
    return chr(ord('a')+i)

上記のコードでは、文字「a」に対応する数値コードを取得するために ord を使用し、新しい文字コードを導出する引数を関数に追加しています。chr 関数は、取得した数値コードを変換して文字列の文字に戻します。この関数を map で使用しましょう。

>>> r = range(10)
>>> list(map(get_letter, r))
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']

マッピングとジェネレーター式の比較

お気付きかもしれませんが、上記のコードは第 1 回で説明したジェネレーター式とよく似ています。実際、このコードを次のように作成することもできます。

>>> list( ( get_letter(i) for i in range(10) ) )
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']

ジェネレーター式を list に渡す引数として組み込んでいる方法に注目してください。これを見ると、ジェネレーター式は他の Python 式と同じように使用できることがわかるはずです。この例での get_letter 関数は単純なものなので、以下のようにジェネレーター式の中に組み込むことさえできます。

>>> list( ( chr(ord('a')+i) for i in range(10) ) )
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']

冗長なようにも思えますが、マッピングはジェネレーター式よりも Python で使われてきた実績が長く、マッピングが引き続き重宝する特殊なケースはあります。ただし大体のところは、こうした単純なイテレーターの操作にはジェネレーター式を使用することになるでしょう。

イテレーターから縮約する

場合によっては、あるイテレーターを別のイテレーターに変換するのではなく、イテレーターから 1 つの値を計算する必要があることもあります。

>>> sum(range(20, 30))
245

sum 関数は数値 (整数や浮動小数点数) のイテレーターを取り、イテレーターから生成されるすべてのアイテムの合計を返します。この例での場合、20 から 30 までのすべての数値の合計です。このように一連の値を処理して 1 つの新しい値にする処理は、縮約と呼ばれています。Python には、縮約を目的とした汎用の関数もあります。まず、以下の関数を貼り付けてください。

def sum_of_squares(acc, i):
    return acc + i**2

この関数を使用して縮約するには、以下のように functools モジュールが必要です。

>>> import functools
>>> functools.reduce(sum_of_squares, range(10))
285

最初の引数は、2 つの引数を取る関数です。この関数は、まず累積値を取り、その累積値に何らかの形で新しい値を適用して更新してから、更新後の値を返します。この例の場合、累積値はそれまでに取ったアイテムの合計です。

map を使用するよりもジェネレーター式を使用したほうが読んで理解しやすくなるのと同様に、多くの場合は reduce にも、読んで理解しやすくなる代替手段がありますが、特定の特化した用途で使用するには縮約が役立ちます (また概念上、reduce は map の対となる手法でもあります)。

map の概念は、イテレーターを取り、そこから新しいイテレーターを派生させるというものです。reduce の概念は、イテレーターを取り、イテレーターのすべてのアイテムから単一の値を導出して返すというものです。この 2 つの概念の組み合わせは、膨大な量のデータを処理する際の有力な考え方となります。

string.join メソッド

縮約として機能する特殊なメソッドについて説明します。

>>> tenletters = ( chr(ord('a')+i) for i in range(10) )
>>> '!'.join(tenletters)
'a!b!c!d!e!f!g!h!i!j'

文字列に対する join メソッドは、イテレーターを取り、そのイテレーターのアイテムとなっている文字列の間を指定された文字列でつなぎ合わせて新しい文字列を作成します。もう少し試してみましょう。

>>> '!'.join(tenletters)
''
>>> tenletters = ( chr(ord('a')+i) for i in range(10) )
>>> ''.join(tenletters)
'abcdefghij'
>>> tenletters = ( chr(ord('a')+i) for i in range(10) )
>>> ' and '.join(tenletters)
'a and b and c and d and e and f and g and h and i and j'

最初の行による出力が再度強調している点は、イテレーターは一方向であり、いったん使い尽くされる何も出力が返されないことです。この点はイテレーターを使用するコードでのバグの原因となりがちなので、注意してください。次の行では、新しいジェネレーターを設定しています。ご覧のように、空の文字列に対して join を実行すると、指定されたイテレーターから生成されるすべての文字列がそのまま連結されます。最後のいくつかの行で示されているように、文字列を長くして join を実行することもできます。

フィルタリング

入力のサブセットだけが含まれるイテレーターを作成するために使用できる組み込み関数は他にもあります。第 1 回で、以下の例を記載しました。

>>> import math
>>> notby2or3 = ( n for n in range(1, 20) if math.gcd(n, 2) == 1 and math.gcd(n, 3) == 1 )
>>> list(notby2or3)
[1, 5, 7, 11, 13, 17, 19]

notby2or3 関数について不明点がある場合は、第 1 回に戻って確認してください。filter 関数を使用して、このジェネレーター式と同等のものを実装することができます。この点も覚えておくと役立ちます。

>>> import math
>>> def notby2or3(n):
...     return math.gcd(n, 2) == 1 and math.gcd(n, 3) == 1
... 
>>> list(filter(notby2or3, range(1, 20)))
[1, 5, 7, 11, 13, 17, 19]

ラムダ式というものを使用すると、このコードを数行短縮して作成できます。ですが、これについてはチュートリアル・シリーズの範囲外です。

itertools を導入する

イテレーターを使用すると最終的によく出現してくる細かいパターンはいくつもあります。itertools モジュールには、こうしたパターンの多くに対応するハイパフォーマンス実装が用意されているため、学ぶ価値は大いにあります。

例えば、一連のイテレーターを連結して 1 つの長いイテレーターを作成するとします。

>>> import itertools
>>> it = itertools.chain(range(5), range(5, 0, -1), range(5))
>>> list(it)
[0, 1, 2, 3, 4, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4]

itertools.chain 関数に一連のイテレーターを引数として渡すと、この関数はそれらのイテレーターの出力をつなぎ合わせたチェーンを生成します。一連のイテレーターからなるリストまたはタプルを扱う場合は、関数の可変の位置指定引数に Python の特殊な構文を使用できます。

>>> list_of_iters = [range(5), range(5, 0, -1), range(5)]
>>> it = itertools.chain(*list_of_iters)
>>> list(it)
[0, 1, 2, 3, 4, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4]

関数の変数引数には、どのイテレーターでも使用することができます。リストやタプルである必要はありません。以下のコードを貼り付けてください。

def forth_back_forth(n):
    yield range(n)
    yield range(n, 0, -1)
    yield range(n)

このジェネレーターを使用して、itertools.chain に引数を渡すことができます。

>>> it = itertools.chain(*forth_back_forth(3))
>>> list(it)
[0, 1, 2, 3, 2, 1, 0, 1, 2]

無限イテレーター

forth_back_forthitertools.chain の組み合わせから、私はそこからさらに、戻る/進む/戻るという数値パターンを続けるとしたらどうなるだとうと考えました。以下のコードを貼り付けてください。

def zigzag_iters(period):
    while True:
        yield range(period)
        yield range(period, 0, -1)

次に、このコードを試します。

>>> it = zigzag_iters(2)
>>> next(it)
range(0, 2)
>>> next(it)
range(2, 0, -1)
>>> next(it)
range(0, 2)
>>> next(it)
range(2, 0, -1)
>>> next(it)
range(0, 2)

next(it) を繰り返し入力し続けると、2 つの出力範囲の間を何度も循環することになります。その理由は、True が無限ループを実装し、このループから抜け出すためのコードが含まれていないためです。このジェネレーターは、Python プロセス (この例では対話型インタープリター) が終了するまで絶えず中断と再開を繰り返します。任意のジェネレーター・オブジェクトに対して close() メソッドを使用すれば、ジェネレーターが実行中であっても中断と再開を繰り返す状態を終了させることができます。

お気付きかもしれませんが、上記の例では、イテレーターの中身を示すリストは使用されていません。したがって、このコードを実行するということは、基本的に無限リストを作成することであり、当然、最終的にはメモリーが使い果たされます。

もう 1 つ注意する点として、このコードが返すのはイテレーターであり、アイテムではありません。forth_back_forth を使用した目的は、itertools.chain によって一連のイテレーターからアイテムを取得することです。同じ目的を zigzag_iters によって達成することはできません。それは、zigzag_iters はアイテムを取得するだけなく、無限に生成されるアイテムの集合を作成しようとするからです。さらに、関数に渡すすべての引数は、それが変数引数であったとしても、関数が実行される前に既知になっていなければなりません。つまり、Python ランタイムは無限イテレーターからすべてのアイテムを取得しようとしますが、それは不可能なことです。

実際のところ、ジェネレーターを使用するようになると、無限イテレーターは有用な概念になります。けれども、無限イテレーターから生成されるアイテムの集合を作成しようとするような処理には注意する必要があります。

イテレーターをカスケード構造にする

zigzag_iters では itertools.chain を使用できないため、より直接的なバージョンを作成します。

def zigzag(period):
    while True:
        for n in range(period):
            yield n
        for n in range(period, 0, -1):
            yield n

上記のコードを貼り付けて、このイテレーターが果てしなく動作することを確認してください。

>>> it = zigzag(2)
>>> next(it)
0
>>> next(it)
1
>>> next(it)
2
>>> next(it)
1
>>> next(it)
0
>>> next(it)
1
>>> next(it)
2
>>> next(it)
1
>>> next(it)
2
>>> next(it)
1
>>> next(it)
0
>>> next(it)
1

外側のイテレーターの内部に 1 つ以上のイテレーターが含まれているというパターンも一般的なものです。そのため、Python にはこのパターンを対象とした標準的な構文が用意されています。それが、作成し直した以下の zigzag で使用されている yield from ステートメントです。このコードは機能的には上記のコードとまったく変わりません。

def zigzag(period):
    while True:
        yield from range(period)
        yield from range(period, 0, -1)

あるイテレーターで別の完全なイテレーターを指定するとしたら、yield from の使用を考えてください。この場合、指定されたイテレーターは完全に使い尽くされます。指定されたイテレーターが無限イテレーターだとすれば、外側のイテレーターも無限イテレーターになり、yield from ステートメントを果てしなく実行し続けます。

itertools.chain の変型

完全を期して最後に指摘しておきます。前述したように、zigzag_iters 関数は関数引数の無限の集合を作成しようとするとため、itertools.chain と一緒には使用できません。itertools の設計思想ではこの点が考慮されて、zigzag_iters と連携する変型として itertools.chain.from_iterable が用意されています。

>>> it = itertools.chain.from_iterable(zigzag_iters(2))
>>> next(it)
0
>>> next(it)
1
>>> next(it)
2
>>> next(it)
1
>>> next(it)
0
>>> next(it)
1
>>> next(it)
2
>>> next(it)
1
>>> next(it)
0
>>> next(it)
1
>>> next(it)
2
>>> next(it)
1

itertools に含まれているその他の機能

itertools には、無限イテレーターを処理するのに便利なルーチンがいくつか含まれています。その 1 つは itertools.islice です。このルーチンを使用すると、無限イテレーターを含むイテレーターからサブセット・シーケンスを抽出できます。

>>> it1 = zigzag(5)
>>> it2 = itertools.islice(it1, 0, 20)
>>> list(it2)
[0, 1, 2, 3, 4, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4, 5, 4, 3, 2, 1]
>>> it2 = itertools.islice(it1, 1, 20)
>>> list(it2)
[1, 2, 3, 4, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4, 5, 4, 3, 2, 1]
>>> it2 = itertools.islice(it1, 0, 20, 2)
>>> list(it2)
[0, 2, 4, 4, 2, 0, 2, 4, 4, 2]

この関数はイテレーターを取り、開始インデックスから終了インデックスまでのアイテムを抽出します。必要に応じて、ステップを指定することもできます。つまりある意味、range と同じように使用できます。ただし、range とは異なり、この関数では負のステップ数を使用できないことに注意してください。

グループ化

itertools に含まれる関数の中で、とりわけ有用であると同時に最も扱いが難しいのはグループ化関数です。とはいえ、これについて理解しておく価値は十分にあります。

何らかの基準に従って複数のイテレーターをグループに分けるには、itertools.groupby を使用します。以下に、24 時間を構成する 4 つの時間帯に時間をグループ化する例を示します。

>>> hours = range(12)
>>> def divide_by_3(n): return n//3
... 
>>> it = itertools.groupby(hours, divide_by_3)
>>> next(it)
(0, <itertools._grouper object at 0x1075ac9e8>)

itertools.groupby に入力イテレーターとグループ化関数を指定し、イテレーターから生成される各アイテムに対し、この関数を実行します。あるアイテムから次のアイテムの間でグループ化関数の実行結果が変わると、新しいグループが作成されて、次にグループ化関数の値が変わるまで、すべてのアイテムがそのグループに振り分けられます。グループ化関数 divide_by_3 では、切り捨て除算演算子 // を使用して、除算した値を次に最も小さい整数に切り捨てます。

図 1 に、この例での itertools.groupby の動作を理解しやすいように視覚化します。

図 1. itertools.groupby の視覚化

itertools.groupby を理解する上で重要な点は、グループ化関数の値の変化を基準に、出力されるアイテムがグループ化されることです。ある程度の練習を積めば、あらゆる類の使用ケースに応じて上手くグループ化関数を作成できるようになります。

この itertools.groupby の例をもう少し深く探りましょう。

>>> hours = range(12)
>>> for quadrant, group_it in itertools.groupby(hours, divide_by_3):
...     print('Items in quadrant', quadrant)
...     for i in group_it:
...         print(i)
... 
Items in quadrant 0
0
1
2
Items in quadrant 1
3
4
5
Items in quadrant 2
6
7
8
Items in quadrant 3
9
10
11

上記のコードでは外側のループが itertools.groupby を呼び出しますが、ループ内の各アイテムはタプルです。このタプルの 2 番目の要素 group_it は、図 1 に示されているようにイテレーターです。したがって、内側のループは各グループのイテレーターに対して動作します。

無限イテレーターを使用してグループ化する

このチュートリアルで説明したいくつかの概念を 1 つにまとめる方法として、無限イテレーターから生成されるアイテムをグループ化します。まず、無限イテレーターを手際よく作成する、itertools に含まれる以下の関数を見てください。

>>> inf_it = itertools.cycle(range(12))
>>> print(list(itertools.islice(inf_it, 32)))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4, 5, 6, 7]

最初の行で使用している itertools.cycle は、指定された有限イテレーターでのシーケンスを無限に繰り返します。この例の場合は、0 から 11 までのシーケンスを何度も生成するだけです。シーケンスのサブセットを安全に取得してリストとして出力するには、itertools.islice を使用します。さらに、itertools.repeat も使用します。この関数は、単一の値を取り、その値を何度も繰り返し処理します。

このチュートリアルで最後に取り上げるのは、itertools.groupbyitertools.cycle を組み合わせたツールです。

>>> inf_it = itertools.cycle(range(12))
>>> hours_32 = itertools.islice(inf_it, 32)
>>> for quadrant, group_it in itertools.groupby(hours_32, divide_by_3):
...     print('Items in quadrant', quadrant, ':', list(group_it))
... 
Items in quadrant 0 : [0, 1, 2]
Items in quadrant 1 : [3, 4, 5]
Items in quadrant 2 : [6, 7, 8]
Items in quadrant 3 : [9, 10, 11]
Items in quadrant 0 : [0, 1, 2]
Items in quadrant 1 : [3, 4, 5]
Items in quadrant 2 : [6, 7, 8]
Items in quadrant 3 : [9, 10, 11]
Items in quadrant 0 : [0, 1, 2]
Items in quadrant 1 : [3, 4, 5]
Items in quadrant 2 : [6, 7]

上記のセッションを慎重に調べると、これらのイテレーター・ツールが相互作用する方法について貴重な洞察を得られます。例えば、時間の数値のサイクルによってグループ化関数の結果がどのように繰り返し処理されるのかが、このセッションに示されています。

今回のチュートリアルで、いかに簡潔に、基本構成要素を組み合わせて興味深いイテレーター・パターンを作り出せるかがわかってきたはずです。こうした機能を、独自の特化したジェネレーターに組み合わせれば、一連のデータを効率的に処理するという問題を解決する際の強力なパラダイムになります。

繰り返し試してください

今回のチュートリアルで取り上げることができなかった itertools の機能は他にもありますが、このモジュールが提供する基本的な機能は把握できたはずです。また、このチュートリアルでは、最も扱いにくいながらも、大きな価値をもたらす可能性のある機能の 1 つとして、groupby について詳しく見ていきました。

標準ライブラリーと同じく、itertools モジュールにも包括的なドキュメントが用意されていますが、通常は、こうしたドキュメントは経験を積んだ開発者が詳細を確認するためのもので、入門書として意図されたものではありません。入門者にお勧めするのは、まずは今回と前回のチュートリアルで説明した構成要素から始めて、itertools の機能を 1 つずつ実験し、それからイテレーターを操作するために組み込みや標準ライブラリーで利用できる他の機能を試してみることです。

このシリーズの第 3 回では学習の幅をさらに広げ、コルーチンという特殊なジェネレーター関数を取り上げます。また、標準ライブラリーに含まれる、強力ながらも扱いに注意が必要なもう 1 つのモジュールとして、asyncio についても説明します。


ダウンロード可能なリソース


関連トピック


コメント

コメントを登録するにはサインインあるいは登録してください。

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Cloud computing
ArticleID=1064514
ArticleTitle=Python でのオンデマンド・データ, 第 2 回: itertools のマジック
publish-date=01242019