Herzlichen Glückwunsch! 🎉
Wenn Sie dies lesen, möchten Sie wahrscheinlich besser darin werden, Python-Code für die Datentechnik zu schreiben. In diesem Leitfaden zeige ich Ihnen, wie ich das Schreiben von Code als Mittel zur Problemlösung betrachte.
I. HINTERGRUNDKONZEPTE
In diesem Artikel werde ich den Code als deklarativ bzw. nicht deklarativ (imperativ) bezeichnen:
- Imperativer Code (nicht deklarativ) teilt dem Compiler mit, was ein Programm Schritt für Schritt tut. Der Compiler kann keine Schritte überspringen, da jeder Schritt vollständig vom vorhergehenden Schritt abhängt.
- Deklarativer Code teilt dem Compiler mit, wie der gewünschte Zustand eines Programms aussehen soll, und abstrahiert die Schritte, um diesen Zustand zu erreichen. Der Compiler kann Schritte überspringen oder sie kombinieren, da er alle Zustände im Voraus bestimmen kann.
Leistungsstarke Software (sehr schneller Code) wird dadurch erreicht, dass ein Maximum an Arbeit in der kleinsten Anzahl von Schritten erledigt wird, wie David Farley elegant formulierte.¹
Moderne Compiler verfügen über alle möglichen Tricks, um Code auf moderner Hardware schneller und effizienter laufen zu lassen. Je besser ein Compiler den Zustand eines Programms vorhersagen kann, desto mehr „Tricks“ kann er anwenden, was zu weniger Anweisungen und erheblichen Leistungsvorteilen führt.
Unten sehen Sie ein Architekturdiagramm der Schnittstelle zwischen einem Compiler und einer Verarbeitungseinheit (einem Stück Hardware). Lassen Sie sich vom Compiler helfen! Geben Sie ihm einfachere, besser vorhersehbare Anweisungen.
Um es zusammenzufassen: Deklarativer Code macht sich die Fähigkeiten moderner Compiler zunutze und führt zu einer höheren Leistung.
Okay, fangen wir an, etwas Code zu schreiben!
II. PROBLEMSTELLUNG
Sie haben eine Liste von Dingen, vielleicht ist die Liste leer, vielleicht hat sie Millionen von Einträgen, und Sie brauchen den ersten Wert, der nicht Null ist:
# A small list
things_small = [0, 1]
# An impossibly big list
things_big = list(range(1_000_000))
# A list with nulls and other stuff
things_with_nulls = [None, "", object()]
Gewünschtes Ergebnis:
Die Funktion sollte genaue Ergebnisse liefern – und keine 0’sor-Leerzeichenfolgen „“ diskriminieren.
Die Leistung der Lösung sollte nicht langsam sein. Es ist wahrscheinlich sinnvoll, min = O(1), max = O(k) anzustreben, wobei k die Größe der Liste ist
>> get_first_non_null([1, 2, 3])
1
>> get_first_non_null([None, 2, 3])
2
>> get_first_non_null([None, 0, 3])
0
>> get_first_non_null([None, None, None])
None
>> get_first_non_null([])
None
>> get_first_non_null([None, "", 1])
""
III. [CODE]-LÖSUNGEN:
Hier sind verschiedene Lösungsansätze, beginnend mit dem einfachsten.
Lösung 1: List Comprehension [Einfache Lösung]
Ich bin sicher, dass jeder Datentechniker dieses Problem schon dutzende Male gelöst hat. Einfache Bohnen! Iterieren Sie über die Liste und prüfen Sie, welche Elemente nicht null sind, und geben Sie dann den ersten Wert zurück:
def get_first_non_null_list_comp(my_vals: list, default=None):
"""
Get first non-null value using
list comprehension.
"""
filtered_vals = [x for x in my_vals if x is not None]
if len(filtered_vals)>0:
return filtered_vals[0]
else:
return default
Dies ist jedoch eindeutig nicht leistungsfähig:
- Sie müssen auf jedes Element der Liste zugreifen. Dies kann langsam sein, wenn Ihre Liste MASSIV ist 🐘.
- Das Listenverständnis kopiert im Wesentlichen die Liste und kann daher sehr speicherintensiv sein. Es sei denn, wir operieren an Ort und Stelle (my_vals = [x for x in my_vals]), was zu Problemen beim Überschreiben der ursprünglichen Liste führen kann. Wir sollten dies also vermeiden.
- Der Zugriff auf das erste Element in der Liste list[0] ist nicht-deklarativ » das heißt, Ihr Programm hat keine Garantie, was das Attribut sein wird, bis es es bekommt. Das ist für Python die meiste Zeit in Ordnung. Aber wenn man dazu übergeht, mehr „organisatorisch genutzten Code“ zu schreiben, sieht man oft Beispiele, wo der Zugriff auf Elemente in einer Liste schief geht. Zum Beispiel: customer_email = response[„data“][0][„custom_attributes“][-1][„email“]
- Es gibt 2 Return-Anweisungen – das ist teilweise nicht deklarativ und erhöht die Komplexität des Codes (und schränkt möglicherweise die Erweiterbarkeit ein).
- Das Listenverständnis kopiert im Wesentlichen die Liste und kann daher sehr speicherintensiv sein. Es sei denn, wir operieren an Ort und Stelle (my_vals = [x for x in my_vals]), was zu Problemen beim Überschreiben der ursprünglichen Liste führen kann. Wir sollten dies also vermeiden.
- Der Zugriff auf das erste Element in der Liste list[0] ist nicht-deklarativ » das heißt, Ihr Programm hat keine Garantie, was das Attribut sein wird, bis es es bekommt. Das ist für Python die meiste Zeit in Ordnung. Aber wenn man dazu übergeht, mehr „organisatorisch genutzten Code“ zu schreiben, sieht man oft Beispiele, wo der Zugriff auf Elemente in einer Liste schief geht. Zum Beispiel: customer_email = response[„data“][0][„custom_attributes“][-1][„email“]
- Es gibt 2 Return-Anweisungen – das ist teilweise nicht deklarativ und erhöht die Komplexität des Codes (und schränkt möglicherweise die Erweiterbarkeit ein).
Lösung 2: Schleifenauswertung [Einfache Lösung]
Wir können also die Funktion so ändern, dass sie durch die Liste iteriert, ohne alle Werte zu verarbeiten:
def get_first_non_null_loop(my_vals: list, default=None):
"""
Get first non-null value using
a loop.
"""
for x in my_vals:
if x is not None:
return x
# Otherwise, return the default value
return default
Das ist nicht schlecht und würde die meisten Code-Reviews bestehen. ⭐️
Aber es gibt noch Unzulänglichkeiten:
- Der Code ist unübersichtlich und würde nicht von Vektorisierung profitieren 🚫 🚀
- Der Code ist nicht deklarativ – unser Compiler ist traurig. 😢
- Ähnlich wie bei Lösung 1 oben gibt es 2 Return-Anweisungen. Ich möchte, dass es nur 1 gibt.
Was aber, wenn Sie Ihren Code auf die nächste Stufe bringen wollen? 💡
Lösung 3: Filtern mit einem Generator [Schwierige Lösung]
Dynamisches Laden von Werten mit Hilfe der in Python eingebauten Filterfunktion, die einen Generator erzeugt, mit dem wir dynamisch auf jede Komponente zugreifen und sie auswerten können:
from operator import is_not
from functools import partial
def get_first_non_null_generator(my_vals: list, default=None):
"""
Get first non-null value using
a generator (via filter).
"""
# Create a generator of values
filtered_vals = filter(partial(is_not, None), my_vals)
# Iterate and get the first not none value
return next(filtered_vals, default)
Was sind die Vorteile dieser Vorgehensweise?
- Der Filteroperator ist ein Generator/Iterator, d. h., er wertet nur die Elemente aus, die er benötigt. Da wir die nächste Funktion verwenden, wird es im Grunde Lazy Load.
- Die partielle Funktion ermöglicht es uns, dynamisch die schnellste Python-Auswertung auf den Wert anzuwenden » ist nicht None » sonst, wenn wir etwas wie [x for x in my_list if x] verwenden, werden 0’s ausgeschlossen.
- Die nächste Funktion holt das nächste Element aus dem Iterator. Der Speicher explodiert nicht, da wir immer nur 1 Wert auf einmal erhalten. Die Standardeinstellung ist explizit festgelegt, andernfalls wird ein StopIteration ausgelöst, sobald der Iterator erschöpft ist.
- Die deklarative Natur ermöglicht die Vektorisierung 🚀 (und Kompilierungsverbesserungen).
- Ermöglicht auch eine Just-in-Time-Kompilierung, wenn wir für weitere Optimierungen erweitern wollen.
Was ist Vektorisierung?
Schön, dass ich Ihr Interesse geweckt habe! Ich werde es ein anderes Mal genauer erklären. In der Zwischenzeit können Sie hier ein wenig darüber lesen: Vektorisierung: Ein Schlüsselwerkzeug zur Leistungssteigerung auf modernen CPUs
IV. ERWEITERUNG UNSERER LÖSUNG
Den ersten nicht leeren Wert aus einem Wörterbuch ermitteln.
Das erste Element einer Liste zu ermitteln ist recht einfach. Aber wie wäre es, den ersten nicht leeren Wert aus einem Wörterbuch zu ermitteln, das auf einer Reihe von Schlüsseln basiert?
Nehmen wir zum Beispiel das folgende Dokument:
{
"Schlüssel": {
"feld_1": "eins",
"feld_2": "two"
}
}
Angenommen, Sie wollen den Wert für field_1 abrufen, es sei denn, es existiert nicht, dann holen Sie den Wert von field_2 (es sei denn, es existiert ebenfalls nicht), ansonsten geben Sie einen Standardwert zurück.
Da unsere dritte Lösung get_first_non_null_generator() einen beliebigen Iterator annimmt, können wir einen Mapper erstellen, der unser Dokument an Nachschlageschlüssel bindet, und in unserer Funktion wie folgt verwenden:
my_doc = {
"field_1": "one",
"field_2": "two"
}
# Get the first non-empty value from a dictionary:
res = get_first_non_null_generator(
map(my_doc.get, ("field_1", "field_2"))
)
# We should get the first non-empty value
assert res == "one"
Tiefer gehend:
Hier ist ein etwas längeres Beispiel (das dem Anwendungsfall, den ich beim Schreiben dieses Codes hatte, näher kommt):
# A dict of fields with default and example values
my_dict = {
"name": {
"example": "Willy Wonka"
},
"country": {
"default": "USA",
"example": "Wonka-land"
},
"n_wonka_bars": {
"default": 0,
"example": 11
},
"has_golden_ticket": {
"default": False
},
"is_an_oompa_loompa": {
"description": "Is this person an Oompa Loompa?"
}
}
# Now I want to get an example record, from default/example vals:
expected_result = {
"name": "Willy Wonka",
"country": "Wonka-land",
"n_wonka_bars": 11,
"has_golden_ticket": False,
"is_an_oompa_loompa": None
}
# Iterate through fields, though if we wanted to
# get crazy, we can compress to a single line (not shown)
example_record = {}
for key, value in my_dict.items():
# We want "examples" before "default", if any
example_record[key] = get_first_non_null_generator(
map(value.get, ("example", "default"))
)
# We should get the above expected result
assert example_record == expected_result
Noch tiefer gehend:
Hier ist ein wirklich ausgeklügelter Anwendungsfall für den Zugriff auf Klassenattribute mit partiellen Funktionen und Mappern:
from typing import Any, Optional
from operator import attrgetter
class FieldAttributes:
"""
Field attributes.
We will want to access these dynamically
"""
example: Any
default: Any
description: Optional[str]
def __init__(self, example=None, default=None, description=None):
self.example = example
self.default = default
self.description = description
class Field(FieldAttributes):
"""Class representing a field"""
name: str
attrs: FieldAttributes
def __init__(self, name, **kwargs):
self.name = name
self.attrs = FieldAttributes(**kwargs)
class UserData:
"""Class representing our user data"""
name = Field("user_name", example="Willy Wonka")
country = Field("country", default="USA", example="Wonka-land")
n_wonka_bars = Field("n_wonka_bars", default=0, example=11)
has_golden_ticket = Field("has_golden_ticket", default=False)
is_an_oompa_loompa = Field("is_an_oompa_loompa",
description="Is this person an Oompa Loompa?"
)
# Access all the fields here
fields = (
name,
country,
n_wonka_bars,
has_golden_ticket,
is_an_oompa_loompa
)
# ------------------------------------------------
# We could compress it all down to something even tighter:
example_record = {
k.name: get_first_non_null_generator(
map(k.attrs.__getattribute__,
("example", "default")
)
)
for k in UserData.fields
}
assert example_record == expected_result
"""
If we were concerned with high-performance (at the expense
of readibility), we could compress everything further
into a single context – which could translate
neatly within a vectorized library. But this is way overkill
"""
example_record = dict(
zip(
map(attrgetter('name'), UserData.fields),
map(
get_first_non_null_generator,
map(
attrgetter("attrs.example", "attrs.default"),
UserData.fields
)
)
)
)
assert example_record == expected_result
Sehr oft soll der Code aktuelle und zukünftige Probleme lösen. Mein Ziel beim Schreiben dieser fortgeschrittenen (und etwas seltsamen) Fälle ist es, Sie dazu zu bringen, über die möglichen Verwendungszwecke für den von Ihnen geschriebenen Code nachzudenken. Ist die Vektorisierung ein Problem für die Zukunft? Ist es die menschliche Lesbarkeit? Werden Sie eine Schnittstelle zu einem seltsamen Ding haben, das stark erweitert werden muss? Oder müssen Sie einfach nur ein Element aus einer Liste zurückgeben?
Berücksichtigen Sie möglichst viele dieser Faktoren, wenn Sie Ihren Code im Voraus schreiben, und dokumentieren Sie Ihre Annahmen. (Es ist in Ordnung, Abkürzungen zu nehmen! Solange Sie es Ihrem zukünftigen Ich in der Dokumentation mitteilen.)
V. NACHDENKEN ÜBER LÖSUNGEN
Ein großer Teil der Arbeit eines leitenden Entwicklers besteht darin, wie man über Probleme denkt. Die meisten Probleme, mit denen Datenteams (und Softwareteams) konfrontiert werden, sind eine Kombination aus technischen und organisatorischen Belangen.
Zur Veranschaulichung: In unserem Fall schreiben wir einen Code, um den ersten Nicht-Null-Wert in einer Liste zu finden. Im Laufe der Zeit wird unsere Lösung jedoch auch von anderen Teams verwendet werden, die unsere Lösung auf unterschiedliche Weise nutzen werden. Zum Beispiel könnte jemand versuchen, den ersten Nicht-Null-Wert in einem Wörterbuch zu finden, das eine Liste von Schlüsseln enthält. Das ist nicht unbedingt eine schlechte Sache. Es ist unvermeidlich, dass Entwickler Ihren Code auf eine Art und Weise verwenden, die Sie beim Schreiben nicht vorausgesehen haben.
Wenn man nicht eingreift, wird die Komplexität einer Codebasis mit der Zeit zwangsläufig zunehmen. Wenn die Komplexität zu groß wird, entsteht ein großer Schlammball. Wie können Sie also den zukünftigen Zustand Ihrer Codebasis schützen?
Wenn unsere Organisation klein wäre: Wir können einen Kommentar im Code hinterlassen, der besagt: #Dieser Code funktioniert nur mit flachen Listen. Kontaktieren Sie YBressler, wenn Sie Probleme haben
Mit anderen Worten: Trennen Sie die technischen und organisatorischen Belange und lösen Sie jedes Problem separat. (Technisch = Code schreiben. Organisatorisch = einen Kommentar hinterlassen.)
Ehrlich gesagt, ist dies eine großartige Lösung, wenn Ihr Team klein ist. Aber sobald eine Organisation eine gewisse Größe erreicht oder Leute die Organisation verlassen, wird diese Lösung problematisch.
Eine bessere Lösung berücksichtigt die Belange des Lebenszyklus der Softwareentwicklung. Dies bedeutet in der Regel, dass Ihr Code einfach, leicht zu testen und leistungsfähig sein muss. Diese Art von Code ermöglicht zukünftigen Entwicklern ein einfaches Refactoring, so dass sie Ihre ursprüngliche Lösung für spätere Anforderungen wiederverwenden und erweitern können, ohne die Komplexität der Codebasis zu erhöhen.
Mit anderen Worten: Unser Code sollte sowohl die technischen als auch die organisatorischen Probleme lösen. Technisch = der Code funktioniert. Organisatorisch = der Code ist leicht zu verstehen und lässt sich leicht umgestalten.
Bei unseren Codebeispielen geht es mir natürlich um die Leistung einer Lösung. Genauso wichtig ist mir aber auch, wie zukünftige Entwickler mit dieser Lösung umgehen werden.
Wenn eine Codelösung nicht leicht zu verstehen ist (hoher Grad an Komplexität), werden sich die Leute scheuen, Änderungen daran vorzunehmen. Sie werden sie entweder auf eine noch komplexere Art und Weise verwenden oder mehr Code erstellen, was die Komplexität der Codebasis ebenfalls erhöht.
VI. SCHLUSSFOLGERUNG:
Zusammenfassend lässt sich sagen, dass erfahrene Dateningenieure [versuchen], Code zu schreiben, der leicht zu verstehen und leistungsstark ist, aber vor allem zukünftige Probleme löst, indem er die Komplexität einer Codebasis reduziert.
Zur Veranschaulichung: Die schnelle Lösung get_first_non_null_generator() ist clever, leicht zu lesen und performant. Vor allem aber zielt sie darauf ab, die Komplexität einer Codebasis zu reduzieren.
Quelle: medium.com
Erfahren Sie hier mehr über Lösungen im Bereich Data Engineering oder besuchen Sie eines unserer kostenlosen Webinare.