Python-Generatoren: Leistung steigern und Code vereinfachen

Stellen Sie sich vor, Sie arbeiten an einem Datenwissenschaftsprojekt, und Ihre Aufgabe besteht darin, einen so großen Datensatz zu verarbeiten, dass das Laden in den Speicher Ihren Computer abstürzen lässt. Oder Sie beschäftigen sich mit einer unendlichen Sequenz, wie einem Live-Datenstrom, bei dem Sie nicht alles gleichzeitig speichern können. Das sind die Arten von Herausforderungen, die Datenwissenschaftler zur Kaffeekanne greifen lassen – und manchmal auch zur Reset-Taste.

In diesem Artikel werden wir Python-Generatoren kennenlernen und wie Sie sie verwenden können, um Ihren Code zu vereinfachen. Diese Idee erfordert etwas Übung, also wenn Sie neu in Python sind und in diesem Artikel ein wenig den Faden verlieren, versuchen Sie unseren Einführung in Python Kurs, um ein solides Fundament aufzubauen.

Was sind Python-Generatoren?

Im Kern sind Python-Generatoren eine besondere Art von Funktion oder sogar ein kompaktes Ausdruck, das eine Folge von Werten träge erzeugt. Stellen Sie sich Generatoren wie ein Förderband in einer Fabrik vor: Anstatt alle Produkte an einem Ort zu stapeln und keinen Platz mehr zu haben, verarbeiten Sie jeden Artikel, sobald er vorbeikommt. Dies macht Generatoren speichereffizient und zu einer natürlichen Erweiterung von Pythons iterator-Protokoll, das viele von Pythons integrierten Werkzeugen wie Schleifen und Listenverständnissen zugrunde liegt.  

Die Magie hinter Generatoren liegt im yield-Schlüsselwort. Im Gegensatz zu return, das einen einzelnen Wert ausgibt und die Funktion verlässt, erzeugt yield einen Wert, pausiert die Ausführung der Funktion und speichert ihren Zustand. Wenn der Generator erneut aufgerufen wird, setzt er dort fort, wo er aufgehört hat. 

Stellen Sie sich zum Beispiel vor, Sie lesen eine riesige Protokolldatei Zeile für Zeile. Ein Generator kann jede Zeile verarbeiten, während sie gelesen wird, ohne die gesamte Datei in den Speicher zu laden. Diese „faule Auswertung“ hebt Generatoren von traditionellen Funktionen ab und macht sie zu einem bevorzugten Werkzeug für leistungs-sensitive Aufgaben.  

Ein einfaches Python-Generatorbeispiel

Lassen Sie uns ein wenig üben, um das Konzept zu verstehen. Hier ist eine Generatorfunktion, die die ersten n Ganzzahlen erzeugt.

def generate_integers(n): for i in range(n): yield i # Hier pausiert es und gibt i zurück # Verwendung des Generators for num in generate_integers(5): print(num)
0 1 2 3 4

Ich habe eine Visualisierung erstellt, um Ihnen zu zeigen, was unter der Haube passiert:

Python Generator Syntax und Muster

Generatoren können auf verschiedene Arten implementiert werden. Es gibt jedoch zwei Hauptarten: Generatorfunktionen und Generatorausdrücke.

Generatorfunktionen

Eine Generatorfunktion ist wie eine normale Funktion definiert, verwendet jedoch das Schlüsselwort yield anstelle von return. Beim Aufruf gibt sie ein Generatorobjekt zurück, über das iteriert werden kann.

def count_up_to(n): count = 1 while count <= n: yield count count += 1 # Verwendung des Generators counter = count_up_to(5) for num in counter: print(num)
1 2 3 4 5

Aus dem obigen Beispiel können wir sehen, dass wenn die Funktion count_up_to aufgerufen wird, gibt sie ein Generator-Objekt zurück. Jedes Mal, wenn die for-Schleife einen Wert anfordert, läuft die Funktion, bis sie auf yield stößt, und liefert den aktuellen Wert von count und bewahrt ihren Zustand zwischen den Iterationen, sodass sie genau dort fortgesetzt werden kann, wo sie aufgehört hat.

Generatorausdrücke  

Generatorausdrücke sind eine kompakte Möglichkeit, Generatoren zu erstellen. Sie ähneln Listenabstraktionen, verwenden jedoch Klammern anstelle von eckigen Klammern.

# Listenabstraktion (sofortige Auswertung) squares_list = [x**2 for x in range(5)] # [0, 1, 4, 9, 16] # Generatorausdruck (verzögerte Auswertung) squares_gen = (x**2 for x in range(5)) # Verwendung des Generators for square in squares_gen: print(square)
0 1 4 9 16

Was ist also der Unterschied zwischen einer List Comprehension und einem Generator Ausdruck? Die List Comprehension erstellt die gesamte Liste im Speicher, während der Generator Ausdruck Werte einzeln erzeugt, was Speicher spart. Wenn Sie mit List Comprehensions nicht vertraut sind, können Sie in unserem Python List Comprehension Tutorial darüber lesen.

Python Generator vs. Iterator

Traditionelle Iteratoren in Python erforderten Klassen mit expliziten __iter__() und __next__() Methoden, was viel Boilerplate und manuelle Zustandsverwaltung erforderte, während Generatorfunktionen den Prozess vereinfachen, indem sie automatisch den Zustand beibehalten und die Notwendigkeit dieser Methoden beseitigen – wie durch eine einfache Funktion demonstriert, die das Quadrat jeder Zahl bis n zurückgibt.

Warum wir Python Generatoren verwenden

Bei der Erklärung, was Python-Generatoren sind, habe ich auch einige Ideen vermittelt, warum sie verwendet werden. In diesem Abschnitt möchte ich etwas mehr ins Detail gehen. Denn Generatoren sind nicht nur ein schickes Python-Feature, sondern lösen tatsächlich echte Probleme.

Speichereffizienz  

Im Gegensatz zu Listen oder Arrays, die alle ihre Elemente gleichzeitig im Speicher halten, erzeugen Generatoren die Werte „on the fly“, sodass sie jeweils nur ein Element im Speicher halten. 

Betrachten Sie beispielsweise den Unterschied zwischen Pythons 2 range() und xrange():  

  •  range() erstellte eine Liste im Speicher, was bei großen Bereichen problematisch sein konnte.

  •  xrange() verhielt sich wie ein Generator, der Werte faul erzeugt.

Da das Verhalten von xrange() nützlicher war, verhält sich jetzt in Python 3 auch range() wie ein Generator, sodass der Speicheraufwand vermieden wird, alle Werte gleichzeitig zu speichern.  

Um die Idee zu zeigen, vergleichen wir den Speicherverbrauch beim Erzeugen einer Sequenz von 10 Millionen Zahlen:  

import sys # Verwendung einer Liste 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") # Verwendung eines Generators 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

Wie Sie sehen können, verbraucht der Generator im Vergleich zur Liste fast keinen Speicher, und dieser Unterschied ist signifikant.

Leistungsverbesserungen  

Dank der verzögerten Auswertung werden Werte nur bei Bedarf berechnet. Dies bedeutet, dass Sie sofort mit der Datenverarbeitung beginnen können, ohne auf die vollständige Generierung der Sequenz warten zu müssen.  

Stellen Sie sich zum Beispiel vor, Sie summieren die Quadrate der ersten 1 Million Zahlen:

# Verwendung einer Liste (sofortige Auswertung) sum_of_squares_list = sum([x**2 for x in range(1_000_000)]) # Verwendung eines Generators (verzögerte Auswertung) sum_of_squares_gen = sum(x**2 for x in range(1_000_000))

Obwohl beide Ansätze das gleiche Ergebnis liefern, vermeidet die Generatorversion die Erstellung einer massiven Liste, sodass wir schneller zu einem Ergebnis gelangen. 

Einfachheit und Lesbarkeit  

Generatoren vereinfachen die Implementierung von Iteratoren, indem sie Standardcode eliminieren. Vergleichen Sie einen klassenbasierten Iterator mit einer Generatorfunktion:

Hier ist der klassenbasierte Iterator:

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 # Verwendung squares = SquaresIterator(5) for square in squares: print(square)

Hier ist die Generatorfunktion:

def squares_generator(n): for i in range(n): yield i ** 2 # Verwendung squares = squares_generator(5) for square in squares: print(square)

Die Generatorversion ist kürzer, einfacher zu lesen und erfordert keinen Standardcode. Es ist ein perfektes Beispiel für die Philosophie von Python: einfach ist besser.

Umgang mit unendlichen Sequenzen

Zu guter Letzt möchte ich sagen, dass Generatoren besonders gut für die Darstellung unendlicher Sequenzen geeignet sind, etwas, das einfach mit Listen unmöglich ist. Betrachten Sie beispielsweise die Fibonacci-Folge:

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

Dieser Generator kann Fibonacci-Zahlen unbegrenzt ohne Speicherprobleme erzeugen. Weitere Beispiele sind die Verarbeitung von Echtzeitdatenströmen oder die Arbeit mit Zeitreihendaten.

Fortgeschrittene Python-Generator-Konzepte  

Jetzt werfen wir einen Blick auf einige schwierigere Ideen. In diesem Abschnitt werden wir erkunden, wie man Generatoren komponiert und einzigartige Generator-Methoden wie .send(), .throw() und .close() verwendet.  

Generatoren miteinander verketten

Generatoren können kombiniert werden. Sie können Daten modular transformieren, filtern und verarbeiten, indem Sie Generatoren miteinander verketten. 

Angenommen, Sie haben eine unendliche Sequenz von Zahlen und möchten jede Zahl quadrieren und ungerade Ergebnisse filtern:

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 # Die Generatoren kombinieren numbers = infinite_sequence() squared = square_numbers(numbers) evens = filter_evens(squared) # Die ersten 10 geraden Quadrate ausgeben for _ in range(10): print(next(evens))
0 4 16 36 64 100 144 196 256 324

Der Prozess beinhaltet die Funktion infinite_sequence, die unendlich Zahlen generiert, während square_numbers das Quadrat jeder Zahl liefert, und dann filter_evens ungerade Zahlen filtert, um nur gerade Quadrate zu produzieren. Unsere Associate Python Developer Karrierebahn beschäftigt sich mit solchen Dingen, damit Sie sehen können, wie komplexe Pipelines mit Generatoren aufgebaut und debuggt werden, sowie Iteratoren und Listenabstraktionen.

Spezielle Generator-Methoden  

Generatoren verfügen über fortgeschrittene Methoden, die eine Kommunikation in beide Richtungen und eine kontrollierte Beendigung ermöglichen.

send()

Die Methode .send() ermöglicht es Ihnen, Werte zurück in einen Generator zu übergeben und ihn in einen Coroutine umzuwandeln. Dies ist nützlich, um interaktive oder zustandsbehaftete Generatoren zu erstellen.  

def accumulator(): total = 0 while True: value = yield total if value is not None: total += value # Verwendung des Generators acc = accumulator() next(acc) # Generator starten print(acc.send(10)) # Ausgabe: 10 print(acc.send(5)) # Ausgabe: 15 print(acc.send(20)) # Ausgabe: 35

So funktioniert es: 

  • Der Generator wird mit next(acc) initialisiert.  

  • Jeder Aufruf von .send(value) übergibt einen Wert an den Generator, der in der yield-Anweisung der Variable value zugewiesen wird.  

  • Der Generator aktualisiert seinen Zustand (total) und gibt das neue Ergebnis zurück.

throw()

Die Methode .throw() ermöglicht es Ihnen, eine Ausnahme innerhalb des Generators auszulösen, was für die Fehlerbehandlung oder das Signalisieren bestimmter Bedingungen hilfreich sein kann.  

def resilient_generator(): try: for i in range(5): yield i except ValueError: yield "Error occurred!" # Verwendung des Generators gen = resilient_generator() print(next(gen)) # Ausgabe: 0 print(next(gen)) # Ausgabe: 1 print(gen.throw(ValueError)) # Ausgabe: "Fehler aufgetreten!"

So funktioniert dieses:

  • Der Generator läuft normalerweise, bis .throw() aufgerufen wird.

  • Die Ausnahme wird im Generator ausgelöst, der sie mit einem try-except-Block behandeln kann.

close()

Die Methode .close() stoppt einen Generator, indem eine GeneratorExit-Ausnahme ausgelöst wird. Dies ist nützlich, um Ressourcen freizugeben oder unendliche Generatoren zu stoppen.

def infinite_counter(): count = 0 try: while True: yield count count += 1 except GeneratorExit: print("Generator closed!") # Verwenden des Generators counter = infinite_counter() print(next(counter)) # Ausgabe: 0 print(next(counter)) # Ausgabe: 1 counter.close() # Ausgabe: "Generator geschlossen!"

Und so funktioniert es:

  • Der Generator läuft, bis .close() aufgerufen wird.

  • Die GeneratorExit-Ausnahme wird ausgelöst, um dem Generator das Aufräumen oder Protokollieren einer Nachricht vor der Beendigung zu ermöglichen.

Real-World-Anwendungen in der Datenwissenschaft  

Ich hoffe, Sie beginnen zu schätzen, wie nützlich Generatoren sind. In diesem Abschnitt werde ich versuchen, die Anwendungsfälle hervorzuheben, damit Sie sich vorstellen können, wie sie tatsächlich in Ihrem Alltag für Sie funktionieren. 

Verarbeitung großer Datensätze  

Eine der häufigsten Herausforderungen in der Datenwissenschaft besteht darin, mit Datensätzen zu arbeiten, die zu groß sind, um in den Speicher zu passen. Generatoren bieten eine Möglichkeit, solche Daten zeilenweise zu verarbeiten.  

Stellen Sie sich vor, Sie haben eine 10 GB große CSV-Datei mit Verkaufsdaten und müssen Datensätze für eine bestimmte Region filtern. So können Sie eine Generator-Pipeline verwenden, um dies zu erreichen:  

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 # Generator-Pipeline file_path = "sales_data.csv" region = "North America" data = read_large_csv(file_path) filtered_data = filter_by_region(data, region) # Verarbeiten der gefilterten Daten for record in filtered_data: print(record)

Hier ist, was passiert: 

  1. read_large_csv liest die Datei zeilenweise und gibt jede Zeile als Dictionary zurück.  

  2. filter_by_region filtert Zeilen basierend auf der angegebenen Region.  

  3. Der Pipeline-Prozess verarbeitet Daten inkrementell, um eine Überlastung des Speichers zu vermeiden.

Dieser Ansatz bietet Vorteile für Extrahieren, Transformieren und Laden-Workflows, bei denen Daten gereinigt und transformiert werden müssen, bevor sie analysiert werden können. Sie werden so etwas in unserem Kurs ETL und ELT in Python sehen.

Streaming und Pipelines

Daten kommen manchmal als kontinuierlicher Strom an. Denken Sie an Sensordaten, Live-Feeds oder soziale Medien.

Angenommen, Sie arbeiten mit IoT-Geräten, die jede Sekunde Temperaturmessungen erzeugen. Sie möchten die durchschnittliche Temperatur über ein gleitendes Fenster von 10 Messungen berechnen:

def sensor_data_stream(): """Simulate an infinite stream of sensor data.""" import random while True: yield random.uniform(0, 100) # Simulieren von Sensordaten 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 # Generator-Pipeline sensor_stream = sensor_data_stream() averages = sliding_window_average(sensor_stream, window_size=10) # Drucken Sie den Durchschnitt jede Sekunde for avg in averages: print(f"Average temperature: {avg:.2f}")

Hier ist die Erklärung:

  1. sensor_data_stream simuliert einen unendlichen Strom von Sensorwerten.

  2. sliding_window_average pflegt ein gleitendes Fenster der letzten 10 Messwerte und gibt ihren Durchschnitt zurück.  

  3. Die Pipeline verarbeitet Daten in Echtzeit, was sie ideal für Überwachung und Analytik macht.

Zusätzliche Anwendungsfälle

Generatoren werden auch in Situationen verwendet, in denen die Datenmenge unvorhersehbar ist oder wenn sie kontinuierlich kommen/ unendlich sind.  

Web-Scraping

Beim Scrapen von Websites weiß man oft nicht, wie viele Seiten oder Elemente man verarbeiten muss. Generatoren ermöglichen es, diese Unvorhersehbarkeit elegant zu handhaben:

def scrape_website(url): """ Generator to scrape a website page by page.""" while url: # Simuliere das Abrufen und Parsen einer Seite print(f"Scraping {url}") data = f"Data from {url}" yield data url = get_next_page(url) # Hypothetische Funktion, um die nächste Seite zu erhalten # Verwendung scraper = scrape_website("https://example.com/page1") for data in scraper: print(data)

Simulationsaufgaben

In Simulationen, wie Monte-Carlo-Methoden oder der Spieleentwicklung, können Generatoren unendliche oder dynamische Sequenzen darstellen:

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

Speicher- und Geschwindigkeitsbenchmarks

Aufgrund ihrer Funktionsweise sind Generatoren in Szenarien, in denen die Speichereffizienz entscheidend ist, überlegen, aber (Sie werden überrascht sein zu erfahren) sie sind möglicherweise nicht immer die schnellste Option. Lassen Sie uns Generatoren mit Listen vergleichen, um ihre Vor- und Nachteile zu verstehen.

Zuvor haben wir gezeigt, wie Generatoren im Hinblick auf den Speicher besser sind als Listen. Dies war der Teil, in dem wir den Speicherverbrauch beim Generieren einer Sequenz von 10 Millionen Zahlen verglichen haben. Lassen Sie uns nun einen anderen Vergleich durchführen:

import time # List comprehension 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") # Generator expression 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

Obwohl ein Generator Speicher spart, ist er in diesem Fall tatsächlich langsamer als die Liste. Dies liegt daran, dass bei diesem kleineren Datensatz der Overhead des Anhaltens und Fortsetzens der Ausführung vorhanden ist.

Der Leistungsunterschied ist für kleine Datensätze vernachlässigbar, aber bei großen Datensätzen überwiegen oft die Speichereinsparungen der Generatoren die geringe Geschwindigkeitsstrafe.

Probleme, die auftreten können

Schließlich wollen wir uns einige häufige Fehler oder Probleme ansehen:

Generatoren sind erschöpflich

Sobald ein Generator erschöpft ist, kann er nicht wiederverwendet werden. Sie müssen ihn neu erstellen, wenn Sie erneut iterieren möchten.

gen = (x for x in range(5)) print(list(gen)) # Ausgabe: [0, 1, 2, 3, 4] print(list(gen)) # Ausgabe: [] (der Generator ist erschöpft)

Die Lazy Evaluation kann knifflig sein 

Da Generatoren Werte auf Abruf erzeugen, können Fehler oder Seiteneffekte erst auftreten, wenn der Generator iteriert wird.  

Sie können Generatoren überstrapazieren 

Für kleine Datensätze oder einfache Aufgaben könnte der Overhead der Verwendung eines Generators die Speichereinsparungen nicht wert sein. Betrachten Sie dieses Beispiel, in dem ich Daten für mehrere Iterationen materialisiere.

# Generatorausdruck gen = (x**2 for x in range(10)) # In eine Liste materialisieren squares = list(gen) # Die Liste wiederverwenden print(sum(squares)) # Ausgabe: 285 print(max(squares)) # Ausgabe: 81

Entscheidung, wann Generatoren verwendet werden sollen

Zusammenfassend werde ich einige sehr allgemeine Regeln dazu geben, wann Generatoren verwendet werden sollten. Verwenden Sie sie für:

  • Große Datensätze: Verwenden Sie Generatoren, wenn Sie mit Datensätzen arbeiten, die zu groß sind, um in den Speicher zu passen.
  • Unendliche Sequenzen: Verwenden Sie Generatoren zur Darstellung unendlicher Sequenzen, wie zum Beispiel Live-Datenströme oder Simulationen.
  • Pipelines: Verwenden Sie Generatoren, um modulare Datenverarbeitungspipelines aufzubauen, die Daten inkrementell transformieren und filtern.

Wann Daten materialisieren (in eine Liste umwandeln)

  • Kleine Datensätze: Verwenden Sie keine Generatoren, wenn der Speicherplatz kein Problem darstellt und Sie schnellen Zugriff auf alle Elemente benötigen; verwenden Sie stattdessen eine Liste. 
  • Mehrere Iterationen: Verwenden Sie keine Generatoren, wenn Sie mehrmals über dieselben Daten iterieren müssen; materialisieren Sie sie stattdessen in einer Liste, um das erneute Erstellen des Generators zu vermeiden. 

Schlussfolgerung und Schlüsselerkenntnisse.

In diesem Artikel haben wir erkundet, wie Generatoren Ihnen helfen können, reale Herausforderungen in der Datenwissenschaft zu bewältigen, von der Verarbeitung großer Datensätze bis zum Aufbau von echtzeitfähigen Datenpipelines. Üben Sie weiter. Der beste Weg, Generatoren zu meistern, besteht darin, sie in Ihrer eigenen Arbeit zu verwenden. Versuchen Sie zum Einstieg, eine Listenverständnis durch einen Generatorausdruck zu ersetzen oder eine Schleife in eine Generatorfunktion umzuschreiben.

Nachdem Sie die Grundlagen gemeistert haben, können Sie neue und fortgeschrittenere Themen erkunden, die auf dem Generatorkonzept aufbauen:

  • Koroutinen: Verwenden Sie .send() und .throw(), um Generatoren zu erstellen, die Daten empfangen und verarbeiten können und so eine bidirektionale Kommunikation ermöglichen.

  • Asynchrones Programmieren: Kombinieren Sie Generatoren mit Pythons asyncio-Bibliothek, um effiziente, nicht blockierende Anwendungen zu erstellen.  

  • Parallelität: Erfahren Sie, wie Generatoren kooperatives Multitasking und leichte Parallelität implementieren können.  

Weiterlernen und ein Experte werden. Nehmen Sie heute unseren Python-Entwickler Karrierepfad oder unseren Python-Programmierung Fähigkeitskurs. Klicken Sie auf den Link unten, um zu beginnen.

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