Die Optimierung von NumPy-Code ist entscheidend für die Effizienz in der wissenschaftlichen Datenverarbeitung.
Die Programmiersprache Python ist im Bereich der wissenschaftlichen Berechnungen und des maschinellen Lernens stark von NumPy abhängig. Diese Bibliothek fungiert als leistungsstarker Motor für andere wichtige Bibliotheken wie Pandas, Scikit-Learn, SciPy und PyTorch. Die Geschwindigkeit von NumPy resultiert aus seiner Implementierung in optimiertem C, wodurch zusammenhängende Speicherblöcke ohne die Overhead-Kosten des Python-Objektmodells und des dynamischen Interpreters bearbeitet werden.
Leider nutzen viele Datenwissenschaftler und Entwickler die Leistungsfähigkeit von NumPy nicht optimal aus. Durch die Verwendung standardmäßiger Python-Schleifen oder das Schreiben einfacher Berechnungen, die unnötige Speicherzuweisungen und Array-Kopien erzwingen, entstehen Leistungsengpässe. Bei der Arbeit mit großen Datensätzen führen diese Ineffizienzen zu übermäßigem RAM-Verbrauch, Cache-Fehlgriffen und langsamen Ausführungszeiten. Um leistungsstarken numerischen Code zu schreiben, ist es wichtig zu verstehen, wie NumPy Berechnungen, Speicherzuweisungen und Datenlayouts im Hintergrund verwaltet.
In diesem Artikel werden drei wesentliche Techniken zur Optimierung von NumPy-Code behandelt:
- Vektorisierung und Broadcasting
- In-Place-Operationen mit dem out-Parameter
- Speicheransichten anstelle von Kopien nutzen
1. Vektorisierung und Broadcasting statt expliziter Schleifen
Explizite Python-Schleifen sind die größten Leistungskiller in der numerischen Berechnung. Das Durchlaufen einer Datenstruktur Element für Element zwingt den Python-Interpreter, bei jedem Schritt Typprüfungen und Methodenaufrufe durchzuführen.
Ein häufiger Fehler ist die Verwendung von np.vectorize. Viele Entwickler gehen davon aus, dass das Einwickeln einer Standard-Python-Funktion in np.vectorize diese in optimierten C-Code umwandelt. In Wirklichkeit ist np.vectorize lediglich ein praktischer Wrapper, der eine langsame, standardmäßige Python-Schleife hinter einer saubereren API ausführt, ohne Leistungsgewinne zu bieten.
Um zu optimieren, muss der Code unter Verwendung nativer universeller Funktionen (ufuncs) und Broadcasting geschrieben werden. Broadcasting ermöglicht es NumPy, Operationen auf Arrays unterschiedlicher Formen durchzuführen, ohne Daten zu kopieren, und verarbeitet die Operationen direkt in kompiliertem C.
Das folgende naive Beispiel durchläuft ein 2D-Array zeilenweise und spaltenweise, um eine spaltenweise Standardisierung durchzuführen (Subtraktion des Spaltenmittelwerts und Division durch die Spaltenstandardabweichung):
import numpy as np
import time
# Erstellen einer Beispielmatrix (50000 Zeilen, 1000 Spalten)
matrix = np.random.rand(50000, 1000)
start_time = time.time()
# Naive schleifenbasierte Spaltennormalisierung
res = matrix.copy()
for col in range(matrix.shape[1]):
col_mean = np.mean(matrix[:, col])
col_std = np.std(matrix[:, col])
for row in range(matrix.shape[0]):
res[row, col] = (matrix[row, col] - col_mean) / col_std
duration_loop = time.time() - start_time
print(f"Verschachtelte Schleife bearbeitete Matrix in: {duration_loop:.4f} Sekunden")
Die Ausgabe lautet:
Verschachtelte Schleife bearbeitete Matrix in: 10.9986 Sekunden
Anstelle von Schleifen berechnen wir den Mittelwert und die Standardabweichung entlang der vertikalen Achse (axis=0). NumPy passt automatisch diese 1D-Zusammenfassungsstatistiken an die Zeilen der 2D-Matrix mithilfe von Broadcasting an:
import numpy as np
import time
# Erstellen einer Beispielmatrix (50000 Zeilen, 1000 Spalten)
matrix = np.random.rand(50000, 1000)
start_time = time.time()
# Berechnung von Mittelwerten und Standardabweichungen entlang der Achse 0 in kompiliertem C
means = np.mean(matrix, axis=0)
stds = np.std(matrix, axis=0);
# Lassen Sie Broadcasting automatisch die Formen erweitern und in einer Zeile berechnen
res_vectorized = (matrix - means) / stds
duration_vectorized = time.time() - start_time
print(f"Vektorisierte Broadcasting bearbeitete Matrix in: {duration_vectorized:.4f} Sekunden")
Die Ausgabe lautet:
Vektorisierte Broadcasting bearbeitete Matrix in: 0.1972 Sekunden
Das entspricht einer Beschleunigung um etwa 56-fach!
In der vektorisierten Implementierung werden die Operationen matrix – means und die anschließende Division durch stds unter Verwendung der Broadcasting-Regeln von NumPy ausgeführt. Da die Matrix die Form (50000, 1000) hat und means die Form (1000,), dehnt NumPy das means-Array konzeptionell aus, um die Form der Matrix anzupassen. Im Hintergrund geschieht diese Erweiterung sofort im Speicher, ohne Daten zu duplizieren, und die Berechnungen werden auf SIMD (Single Instruction, Multiple Data)-CPU-Anweisungen übertragen, was zu einer massiven Beschleunigung von über 50-fach führt.
2. In-Place-Operationen und der out-Parameter
Wenn Sie Ausdrücke wie y = 2 * x + 3 schreiben, erwarten Sie möglicherweise, dass sie effizient ausgeführt werden. Im Hintergrund bewertet NumPy diesen Ausdruck jedoch Schritt für Schritt:
- Es wird ein temporäres Array im Speicher zugewiesen, um das Ergebnis von 2 * x zu speichern.
- Es wird ein weiteres Array zugewiesen, um das Ergebnis der Addition von 3 zum temporären Array zu speichern.
- Schließlich wird dieses zweite temporäre Array mit dem Variablennamen y verknüpft.
Bei der Arbeit mit sehr großen Arrays (z. B. Millionen von Einträgen) führt die Zuweisung und die Garbage Collection dieser temporären Zwischenergebnisse zu erheblichen Overhead-Kosten. Dies belastet die CPU-Caches und überlastet die Bandbreite des Speichers.
Wir können diesen Overhead vermeiden, indem wir In-Place-Berechnungen mit Operatoren wie *= und += durchführen oder den im Grunde genommen in fast allen NumPy-Universalfunktionen integrierten out-Parameter nutzen. Für weitere Informationen zu effektiven Techniken in Python können Sie auch effiziente Skripte zur Automatisierung der explorativen Datenanalyse lesen.
Das folgende naive Beispiel führt eine grundlegende lineare Skalierung auf einem massiven Array durch, was mehrere temporäre Zuweisungen verursacht:
import numpy as np
import time
# Erstellen eines großen 1D-Arrays mit 10 Millionen Elementen
x = np.random.rand(10000000)
scale = 2.5
offset = 1.2
start_time = time.time();
# Standardmäßige verkettete Mathematik erzeugt temporäre Zwischenergebnisse
y_naive = scale * x + offset
duration_naive = time.time() - start_time
print(f"Verketteter Ausdruck wurde ausgeführt in: {duration_naive:.4f} Sekunden")
Die Ausgabe lautet:
Verketteter Ausdruck wurde ausgeführt in: 0.0393 Sekunden
Hierbei wird das Zielausgabe-Array einmal vorab zugewiesen, und sein Puffer wird für alle nachfolgenden mathematischen Operationen wiederverwendet, wodurch temporäre Zuweisungen umgangen werden:
import numpy as np
import time
# Erstellen eines großen 1D-Arrays mit 10 Millionen Elementen
x = np.random.rand(10000000)
scale = 2.5
offset = 1.2
start_time = time.time();
# Vorabzuweisung des endgültigen Arrays
y_optimized = np.empty_like(x);
# Mathematik direkt in den Zielpuffer ohne Zwischenspeicher durchführen
np.multiply(x, scale, out=y_optimized);
np.add(y_optimized, offset, out=y_optimized);
duration_optimized = time.time() - start_time;
print(f"Optimierter In-Place-Ausdruck wurde ausgeführt in: {duration_optimized:.4f} Sekunden");
print(f"Beschleunigung: {duration_naive / duration_optimized:.2f}x schneller!");
Die Ausgabe lautet:
Optimierter In-Place-Ausdruck wurde ausgeführt in: 0.0133 Sekunden
In diesem optimierten Beispiel verwenden wir np.multiply(x, scale, out=y_optimized), um das Ergebnis der Multiplikation direkt in unser vorab zugewiesenes y_optimized-Array zu schreiben. Anschließend addiert np.add(y_optimized, offset, out=y_optimized) den Offset und schreibt das Ergebnis zurück in denselben Puffer. Dadurch werden die Zuweisungen und die Garbage Collection temporärer Puffer vollständig vermieden, was Systemressourcen spart, die Daten im CPU-Cache hält und die Ausführungsgeschwindigkeit erhöht.
3. Speicheransichten vs. Speicherkopien (Slicing vs. erweiterte Indizierung)
Zu verstehen, wann NumPy eine Ansicht eines Arrays zurückgibt und wann eine Kopie, ist eines der kritischsten Themen in der numerischen Programmierung:
- Eine Ansicht ist ein neues Array-Objekt, das auf denselben zugrunde liegenden Datenpuffer wie das ursprüngliche Array zeigt. Das Erstellen einer Ansicht ist eine Null-Kopie-Operation, die in $O(1)$ konstanter Zeit und Raum abläuft.
- Eine Kopie weist einen brandneuen Datenpuffer zu und dupliziert die Daten. Dies läuft in $O(N)$ linearer Zeit und Raum ab.
Die grundlegende Indizierung (unter Verwendung von Start-, Stopp- und Schrittindizes, z. B. arr[0:10:2]) gibt immer eine Ansicht zurück. Im Gegensatz dazu gibt die erweiterte Indizierung (unter Verwendung von Indizes oder booleschen Masken, z. B. arr[[0, 2, 4]]) immer eine Kopie zurück.
Wenn Sie nur Teilsegmente eines Arrays lesen oder aktualisieren müssen, führt die Verwendung erweiterter Indizierung zu massiven, unnötigen Speicherzuweisungen.
Hier versuchen wir, eine massive 2D-Matrix (jede zweite Zeile und Spalte) durch Übergabe von Indizes zu unterteilen. Dies zwingt NumPy, ein großes neues Array zuzuweisen und alle Elemente zu kopieren:
import numpy as np
import time
# Erstellen einer Matrix mit 10.000 x 10.000 Elementen
matrix = np.random.rand(10000, 10000)
start_time = time.time();
# Erweiterte Indizierung mit Ganzzahl-Arrays erzwingt eine physische Kopie der Daten
rows = np.arange(0, matrix.shape[0], 2);
cols = np.arange(0, matrix.shape[1], 2);
sub_matrix_copy = matrix[rows[:, None], cols];
duration_copy = time.time() - start_time;
print(f"Erweiterte Indizierungskopie abgeschlossen in: {duration_copy:.4f} Sekunden");
Die Ausgabe lautet:
Erweiterte Indizierungskopie abgeschlossen in: 0.1575 Sekunden
Nun führen wir dieselbe Operation durch, verwenden jedoch die grundlegende Indizierung. Anstelle von Datenkopien passt NumPy die Stride-Metadaten an, um sofort auf denselben Puffer zu zeigen:
import numpy as np
import time
# Erstellen einer Matrix mit 10.000 x 10.000 Elementen
matrix = np.random.rand(10000, 10000)
start_time = time.time();
# Grundlegende Indizierung gibt sofort eine Null-Kopie-Ansicht zurück
sub_matrix_view = matrix[::2, ::2];
duration_view = time.time() - start_time;
print(f"Grundlegende Indizierungsansicht abgeschlossen in: {duration_view:.8f} Sekunden");
Die Ausgabe lautet:
Grundlegende Indizierungsansicht abgeschlossen in: 0.00001001 Sekunden
Wenn Sie ein Array mit matrix[::2, ::2] schneiden, berührt NumPy den zugrunde liegenden Datenpuffer nicht. Es wird einfach ein neuer Array-Header mit modifizierten Metadaten erstellt: einer anderen Form und neuen Strides (der Anzahl der Bytes, die in jeder Dimension übersprungen werden müssen, um das nächste Element zu finden). Diese Operation erfolgt in weniger als einer Mikrosekunde, unabhängig von der Größe der Matrix.
Seien Sie sich jedoch des Kompromisses bewusst: Da die Ansicht denselben Speicherpuffer teilt, wird die Modifikation von sub_matrix_view auch das ursprüngliche Array ändern. Wenn Sie die Modifikation des ursprünglichen Arrays vermeiden müssen, müssen Sie explizit .copy() aufrufen.
Fazit
Das Schreiben von sauberem, leistungsfähigem NumPy-Code erfordert ein Umdenken in Bezug auf Schleifen, Speicherzuweisungen und Datenstrukturen. Durch die Vermeidung standardmäßiger Python-Konzepte zugunsten nativer NumPy-Mechanismen können Sie Berechnungsengpässe beseitigen.
Zusammenfassend:
- Verzichten Sie auf Python-Schleifen und np.vectorize und lassen Sie vektorisierte Broadcasting-Berechnungen in optimiertes C ablaufen.
- Verwenden Sie In-Place-Operationen und den out-Parameter, um den Zuweiser zu umgehen, Cache-Thrashing zu verhindern und den RAM-Verbrauch zu reduzieren.
- Meistern Sie Ansichten vs. Kopien, um sofortige, Null-Kopie-Slicing anstelle teurer erweiterter Indizierungskopien zu nutzen.
Die Integration dieser drei Leistungsdesignmuster wird Ihre Datenverarbeitungs-Pipelines schlank, schnell und skalierbar für Produktionslasten halten. Weitere Informationen zu effektiven Techniken finden Sie in unserem Artikel über Python-Decorators für leistungsstarke Datenpipelines und zur effektiven Merkmalsauswahl.
Matthew Mayo (@mattmayo13) hat einen Master-Abschluss in Informatik und ein Diplom in Datenanalyse. Als Chefredakteur von KDnuggets und Statology sowie als beitragender Redakteur bei Machine Learning Mastery hat Matthew das Ziel, komplexe Konzepte der Datenwissenschaft zugänglich zu machen. Seine beruflichen Interessen umfassen natürliche Sprachverarbeitung, Sprachmodelle, Algorithmen des maschinellen Lernens und die Erkundung neuer KI-Technologien. Er ist bestrebt, Wissen in der Datenwissenschaftsgemeinschaft zu demokratisieren. Matthew programmiert seit seinem 6. Lebensjahr.
Abonnieren Sie den kostenlosen E-Book ‚KDnuggets Artificial Intelligence Pocket Dictionary‘ zusammen mit dem führenden Newsletter zu Datenwissenschaft, maschinellem Lernen, KI und Analytik direkt in Ihrem Posteingang.
„`
Quellen: kdnuggets
Bildquelle: KI generiert
🚀