Python ジェネレーター:パフォーマンス向上とコードの簡素化

データサイエンスプロジェクトに取り組んでいると想像してみてください。メモリに読み込むとマシンがクラッシュしてしまうほど大きなデータセットを処理することになったり、ライブデータストリームのような無限のシーケンスを扱う場合、すべてを同時に格納することは不可能です。これらは、データサイエンティストがコーヒーポットを手に取る原因となる種類の課題です。  

この記事では、Pythonジェネレータについて学び、コードを簡素化する方法を紹介します。この考え方は実践が必要なので、Pythonが初めての方はこの記事で少し迷った場合は、Python入門コースを試して、しっかりとした基礎を築いてみてください。

Pythonジェネレータとは何ですか?

Pythonのジェネレータは、遅延して値のシーケンスを生成する特別な種類の関数、またはコンパクトな式です。ジェネレータは、工場のコンベアベルトのように考えることができます。製品をすべて一か所に積み重ねてスペースを使い果たす代わりに、各アイテムがラインを通るたびに処理します。これにより、ジェネレータはメモリ効率がよくなり、Pythonのiteratorプロトコルの自然な拡張機能となります。このプロトコルは、forループや内包表記など、Pythonの多くの組み込みツールの基盤となっています。

ジェネレータの魔法は、yieldキーワードにあります。単一の値を出力して関数を終了するreturnとは異なり、yieldは値を生成し、関数の実行を一時停止してその状態を保存します。ジェネレータが再度呼び出されると、前回の続きから処理を再開します。

例えば、巨大なログファイルを1行ずつ読み込んでいるとします。ジェネレータを使用すると、メモリ全体にファイルを読み込まずに各行を処理できます。「遅延評価」により、ジェネレータは従来の関数と区別され、パフォーマンス重視のタスクに適したツールとなります。

基本的なPythonジェネレータの例

アイデアの理解を深めるために少し練習しましょう。以下は、最初のn個の整数を生成するジェネレータ関数です。

def generate_integers(n): for i in range(n): yield i # ここで一時停止してiを返します # ジェネレータを使用する for num in generate_integers(5): print(num)
0 1 2 3 4

ホイールの下で何が起こっているかを見るためのビジュアルを作成しました:

Pythonジェネレータ構文とパターン

ジェネレータは複数の方法で実装できます。つまり、2つの主要な方法があります: ジェネレータ関数とジェネレータ式。

ジェネレータ関数

ジェネレータ関数は通常の関数のように定義されますが、returnの代わりにyieldキーワードを使用します。呼び出されると、イテレートできるジェネレータオブジェクトを返します。

def count_up_to(n): count = 1 while count <= n: yield count count += 1 # ジェネレータを使用する counter = count_up_to(5) for num in counter: print(num)
1 2 3 4 5

上記の例から、関数count_up_toが呼び出されると、ジェネレータオブジェクトが返されることがわかります。forループが値を要求するたびに、関数はyieldに達するまで実行され、countの現在の値を生成し、状態をイテレーション間で保持して、停止した場所から正確に再開できるようにします。

ジェネレータ式

ジェネレータ式は、ジェネレータを作成するコンパクトな方法です。リスト内包表記に似ていますが、角かっこの代わりに丸かっこを使用します。

# リスト内包表記(イーガー評価) squares_list = [x**2 for x in range(5)] # [0, 1, 4, 9, 16] # ジェネレータ式(遅延評価) squares_gen = (x**2 for x in range(5)) # ジェネレータの使用 for square in squares_gen: print(square)
0 1 4 9 16

リスト内包表記とジェネレータ式の違いは何ですか?リスト内包表記はリスト全体をメモリに作成しますが、ジェネレータ式は値を1つずつ生成してメモリを節約します。リスト内包表記に馴染みがない場合は、Python List Comprehension Tutorialで詳細を確認できます。

Pythonジェネレータ vs. イテレータ

Pythonの従来のイテレータは、明示的な__iter__()および__next__()メソッドを持つクラスが必要であり、多くの冗長なコードと手動の状態管理が必要でした。一方、ジェネレータ関数は、状態を自動的に保持し、これらのメソッドを必要としないことでプロセスを簡略化します。これは、各数値の2乗をnまで生成する単純な関数によって示されています。

Pythonジェネレータの使用理由

Pythonのジェネレータについて説明する際に、それらがなぜ使用されるのかのアイデアも伝えました。このセクションでは、少し詳細に説明します。ジェネレータは単なる洒落たPythonの機能ではなく、実際に現実の問題を解決するのです。

メモリ効率

リストや配列とは異なり、全ての要素を同時にメモリに格納するのではなく、ジェネレータは値を必要に応じて生成するため、一度に1つのアイテムのみをメモリに保持します。

例えば、Python 2のrange()xrange()の違いを考えてみましょう。

  • range()はメモリ内にリストを作成しましたが、大きな範囲では問題が発生する可能性があります。

  • xrange()はジェネレータのように振る舞い、遅延値を生成します。

xrange()の振る舞いがより有用だったため、Python 3ではrange()もジェネレータのように振る舞うため、すべての値を同時に保存するメモリオーバーヘッドを回避します。

この考えを示すために、1000万個の数値のシーケンスを生成する際のメモリ使用量を比較してみましょう。

import sys # リストを使用する numbers_list = [x for x in range(10_000_000)] print(f"Memory used by list: {sys.getsizeof(numbers_list) / 1_000_000:.2f} MB") # ジェネレータを使用する numbers_gen = (x for x in range(10_000_000)) print(f"Memory used by generator: {sys.getsizeof(numbers_gen)} bytes")
Memory used by list: 89.48 MB Memory used by the generator: 112 bytes

ジェネレーターはリストと比較してほとんどメモリを使用せず、その違いは大きいです。

パフォーマンスの向上  

遅延評価のおかげで、値は必要な時にのみ計算されます。これは、シーケンス全体が生成されるのを待たずに、データの処理をすぐに開始できることを意味します。

例えば、最初の100万個の数の二乗の合計を考えてみてください:

# リストを使用する(イーガー評価) sum_of_squares_list = sum([x**2 for x in range(1_000_000)]) # ジェネレーターを使用する(遅延評価) sum_of_squares_gen = sum(x**2 for x in range(1_000_000))

両方のアプローチで同じ結果が得られますが、ジェネレーター版は大量のリストを作成せず、結果をより速く取得できます。

シンプリシティと可読性 

ジェネレーターは、ボイラープレートコードを排除することで、反復子の実装を簡略化します。クラスベースの反復子とジェネレーター関数を比較してみましょう:

以下は、クラスベースの反復子です:

class SquaresIterator: def __init__(self, n): self.n = n self.current = 0 def __iter__(self): return self def __next__(self): if self.current >= self.n: raise StopIteration result = self.current ** 2 self.current += 1 return result # 使用方法 squares = SquaresIterator(5) for square in squares: print(square)

以下は、ジェネレーター関数です:

def squares_generator(n): for i in range(n): yield i ** 2 # 使用方法 squares = squares_generator(5) for square in squares: print(square)

ジェネレーターバージョンは短く、読みやすく、ボイラープレートコードが必要ありません。これはPythonの哲学の完璧な例です:シンプルが良いです。

無限シーケンスの取り扱い

最後に、ジェネレーターは無限シーケンスを表現するのに非常に適しており、これは単純なリストでは不可能です。たとえば、フィボナッチ数列を考えてみてください:

def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b # 使用方法 fib = fibonacci() for _ in range(10): print(next(fib))
0 1 1 2 3 5 8 13 21 34

このジェネレータはメモリを使い果たすことなく、フィボナッチ数を無制限に生成することができます。他にも、ライブデータストリームの処理や時系列データの操作などがあります。

高度なPythonジェネレータのコンセプト  

さて、さらに難しいアイデアを見てみましょう。このセクションでは、ジェネレータの組み合わせ方や.send().throw().close()などのユニークなジェネレータメソッドの使い方を探求します。

ジェネレータを連鎖させる

ジェネレータを組み合わせることができます。ジェネレータを連鎖させることで、データを変換したりフィルタリングしたりして、モジュール化された方法で処理することができます。

無限の数列があるとして、それぞれの数を二乗して奇数の結果をフィルタリングしたいとします:

def infinite_sequence(): num = 0 while True: yield num num += 1 def square_numbers(sequence): for num in sequence: yield num ** 2 def filter_evens(sequence): for num in sequence: if num % 2 == 0: yield num # ジェネレータを組み合わせる numbers = infinite_sequence() squared = square_numbers(numbers) evens = filter_evens(squared) # 最初の10個の偶数の二乗を表示 for _ in range(10): print(next(evens))
0 4 16 36 64 100 144 196 256 324

このプロセスは、infinite_sequence 関数が数を無限に生成し、square_numbers が各数の二乗を生成し、filter_evens が奇数を取り除いて偶数の二乗のみを生成するというものです。私たちのアソシエイトPython開発者キャリアトラックでは、このようなことに取り組んでいるため、ジェネレーターを使用した複雑なパイプラインの構築とデバッグ方法、およびイテレーターやリスト内包表記について学ぶことができます。およびリスト内包表記。

特別なジェネレーターメソッド  

ジェネレーターには、双方向通信と制御された終了を可能にする高度なメソッドが付属しています。

send()

.send() メソッドを使用すると、値をジェネレーターに戻すことができ、それをコルーチンに変換することができます。これはインタラクティブまたはステートフルなジェネレーターを作成するのに役立ちます。

def accumulator(): total = 0 while True: value = yield total if value is not None: total += value # ジェネレータの使用 acc = accumulator() next(acc) # ジェネレータを開始 print(acc.send(10)) # 出力: 10 print(acc.send(5)) # 出力: 15 print(acc.send(20)) # 出力: 35

動作原理:

  • ジェネレータはnext(acc)で初期化されます。

  • .send(value)への各呼び出しは、ジェネレータに値を渡し、yield文内のvalueに割り当てられます。

  • ジェネレータはその状態(total)を更新し、新しい結果を返します。

throw()

.throw()メソッドを使用すると、ジェネレータ内で例外を発生させることができ、エラーハンドリングや特定の条件のシグナリングに役立ちます。  

def resilient_generator(): try: for i in range(5): yield i except ValueError: yield "Error occurred!" # ジェネレータの使用 gen = resilient_generator() print(next(gen)) # 出力: 0 print(next(gen)) # 出力: 1 print(gen.throw(ValueError)) # 出力: "エラーが発生しました!"

これがこの方法が機能する方法です:

  • ジェネレータは通常、.throw() が呼び出されるまで実行されます。

  • 例外はジェネレータ内で発生し、try-except ブロックを使用して処理できます。

close()

メソッド.close()GeneratorExit例外を発生させることでジェネレータを停止します。これはリソースのクリーンアップや無限ジェネレータの停止に便利です。

def infinite_counter(): count = 0 try: while True: yield count count += 1 except GeneratorExit: print("Generator closed!") # ジェネレータの使用 counter = infinite_counter() print(next(counter)) # 出力: 0 print(next(counter)) # 出力: 1 counter.close() # 出力: "Generator closed!"

そしてこちらが動作方法です:

  • ジェネレータは.close()が呼ばれるまで実行されます。

  • GeneratorExit例外が発生し、ジェネレーターが終了する前にクリーンアップやメッセージの記録を行うことが許可されます。

データサイエンスにおける実践的な応用  

ジェネレーターが有用であることに気づいてきたことを願っています。このセクションでは、使用例を際立たせ、日常生活で実際にどのように機能するかを想像できるようにします。

大規模データセットの処理  

データサイエンスにおける最も一般的な課題の1つは、メモリに収まりきらない大規模データセットを扱うことです。ジェネレーターは、そのようなデータを1行ずつ処理する方法を提供します。  

10 GBの売上データを含むCSVファイルがあり、特定の地域のレコードをフィルタリングする必要があると想像してください。以下は、ジェネレーターパイプラインを使用してこれを実現する方法です:

import csv def read_large_csv(file_path): """ Generator to read a large CSV file line by line.""" with open(file_path, mode="r") as file: reader = csv.DictReader(file) for row in reader: yield row def filter_by_region(data, region): """ Generator to filter rows by a specific region.""" for row in data: if row["Region"] == region: yield row # ジェネレーターパイプライン file_path = "sales_data.csv" region = "North America" data = read_large_csv(file_path) filtered_data = filter_by_region(data, region) # フィルタリングされたデータの処理 for record in filtered_data: print(record)

以下は、何が起こっているかです:

  1. read_large_csvはファイルを行単位で読み込み、各行を辞書として出力します。

  2. filter_by_regionは指定された地域に基づいて行をフィルタリングします。

  3. パイプラインはデータを増分的に処理し、メモリの過負荷を回避します。

このアプローチは、データのクリーニングと変換が分析の前に行われるETL(Extract, Transform, Load)ワークフローに利点があります。このようなものは、PythonにおけるETLとELTコースで見ることができます。

ストリーミングとパイプライン

データは時々連続したストリームとして到着します。センサーデータ、ライブフィード、またはソーシャルメディアなどを考えてみてください。

IoTデバイスと一緒に作業しており、毎秒温度の読み取りを生成する場合を考えます。10個の読み取りを持つスライディングウィンドウ上の平均温度を計算したいとします:

def sensor_data_stream(): """Simulate an infinite stream of sensor data.""" import random while True: yield random.uniform(0, 100) # センサーデータのシミュレーション def sliding_window_average(stream, window_size): """ Calculate the average over a sliding window of readings.""" window = [] for value in stream: window.append(value) if len(window) > window_size: window.pop(0) if len(window) == window_size: yield sum(window) / window_size # ジェネレーターパイプライン sensor_stream = sensor_data_stream() averages = sliding_window_average(sensor_stream, window_size=10) # 毎秒平均を出力 for avg in averages: print(f"Average temperature: {avg:.2f}")

こちらが説明です:

  1. sensor_data_streamはセンサー読み取りの無限ストリームをシミュレートしています。

  2. sliding_window_averageは、直近の10回の読み取りのスライディングウィンドウを維持し、それらの平均値を返します。

  3. このパイプラインはデータをリアルタイムで処理するため、監視や分析に最適です。

追加の使用事例

データサイズが予測不可能である場合や、データが継続的に流れてくる場合にも、ジェネレータが使用されます。

Webスクレイピング

ウェブサイトをスクレイピングする際、処理する必要があるページやアイテムの数をよく知らないことがあります。ジェネレータを使用すると、この予測不可能な状況を優雅に処理できます:

def scrape_website(url): """ Generator to scrape a website page by page.""" while url: # ページの取得と解析のシミュレーション print(f"Scraping {url}") data = f"Data from {url}" yield data url = get_next_page(url) # 次のページを取得する架空の関数 # 使用方法 scraper = scrape_website("https://example.com/page1") for data in scraper: print(data)

シミュレーションタスク

モンテカルロ法やゲーム開発などのシミュレーションでは、ジェネレータを使用すると無限または動的なシーケンスを表現できます:

def monte_carlo_simulation(): """ Generator to simulate random events for Monte Carlo analysis.""" import random while True: yield random.random() # 使用方法 simulation = monte_carlo_simulation() for _ in range(10): print(next(simulation))

メモリと速度のベンチマーク

動作原理から、メモリ効率が重要なシナリオではジェネレータが優れていますが、(驚くかもしれませんが)常に最速のオプションであるとは限りません。ジェネレータとリストを比較して、そのトレードオフを理解しましょう。

以前、私たちはジェネレーターがメモリの観点でリストよりも優れていることを示しました。これは、1,000万の数値のシーケンスを生成する際のメモリ使用量を比較した部分です。では、異なること、つまり速度の比較を行いましょう:

import time # リスト内包表記 start_time = time.time() sum([x**2 for x in range(1_000_000)]) print(f"List comprehension time: {time.time() - start_time:.4f} seconds") # ジェネレーター式 start_time = time.time() sum(x**2 for x in range(1_000_000)) print(f"Generator expression time: {time.time() - start_time:.4f} seconds")
List comprehension time: 0.1234 seconds Generator expression time: 0.1456 seconds

この場合、ジェネレーターはメモリを節約しますが、実際にはリストよりも遅くなります。これは、この小さなデータセットでは、実行を一時停止して再開するオーバーヘッドがあるためです。

パフォーマンスの違いは小さなデータセットでは無視できますが、大きなデータセットでは、ジェネレーターのメモリ節約がわずかな速度ペナルティを上回ることがよくあります。

発生する問題

最後に、一般的な間違いや問題を見てみましょう:

ジェネレーターは使い切れる

ジェネレータが使い果たされると、再利用することはできません。もう一度反復するには、再作成する必要があります。

gen = (x for x in range(5)) print(list(gen)) # 出力: [0, 1, 2, 3, 4] print(list(gen)) # 出力: [] (ジェネレータが使い果たされています)

遅延評価はトリッキーなことがあります

ジェネレータは要求に応じて値を生成するため、エラーや副作用がジェネレータが反復されるまで現れないかもしれません。

ジェネレータを過度に使用することがあります

小さなデータセットや単純なタスクの場合、ジェネレータを使用するオーバーヘッドはメモリの節約に値しないかもしれません。複数の反復のためにデータを具現化する例を考えてみてください。

# ジェネレータ式 gen = (x**2 for x in range(10)) # リストに具現化 squares = list(gen) # リストを再利用 print(sum(squares)) # 出力: 285 print(max(squares)) # 出力: 81

ジェネレータを使用するタイミングの選択

要約すると、ジェネレータを使用するタイミングについて非常に一般的なルールをいくつか提供します。使用方法:

  • 大規模なデータセット:メモリに収まらない大規模なデータセットを扱う場合にはジェネレータを使用してください。
  • 無限のシーケンス:ライブデータストリームやシミュレーションなどの無限のシーケンスを表現するためにジェネレータを使用してください。
  • パイプライン:データを段階的に変換およびフィルタリングするモジュラーなデータ処理パイプラインを構築する際には、ジェネレータを使用してください。

データを具体化するタイミング(リストに変換する)

  • 小規模データセット:メモリに問題がなく、すべての要素への高速アクセスが必要な場合は、ジェネレータを使用せず、代わりにリストを使用してください。
  • 複数の反復:同じデータを複数回反復する必要がある場合は、ジェネレータを使用しないでリストに具体化して、ジェネレータを再作成することを避けてください。

結論と要点

この記事では、ジェネレーターがデータサイエンスの実践的な課題に取り組むのにどのように役立つかを探ってきました。大規模なデータセットの処理からリアルタイムデータパイプラインの構築まで。練習を続けてください。ジェネレーターをマスターする最良の方法は、自分の作業で使用することです。最初に、リスト内包表記をジェネレーター式に置き換えてみたり、ループをジェネレーター関数にリファクタリングしてみてください。

基本をマスターしたら、ジェネレーターの概念に基づいた新しい、さらに高度なトピックを探求できます:

  • コルーチン.send().throw() を使用して、データを受信・処理できるジェネレーターを作成し、双方向通信を可能にします。

  • 非同期プログラミング:ジェネレーターをPythonのasyncioライブラリと組み合わせて、効率的でノンブロッキングなアプリケーションを構築します。

  • 並行性:ジェネレーターが協調マルチタスキングと軽量な並行性を実装する方法を学びます。

学習を続けて専門家になりましょう。今日はPython DeveloperキャリアトラックまたはPython Programmingスキルトラックを選択してください。開始するために以下のリンクをクリックしてください。

Source:
https://www.datacamp.com/tutorial/python-generators