Jak pisać kod jak Senior Data Engineer

Dec 16 2022
Pisanie wysokowydajnego kodu bez tworzenia kolejnych problemów dla siebie w przyszłości. Gratulacje! Jeśli to czytasz, prawdopodobnie chcesz lepiej pisać kod dla inżynierii danych.

Pisanie wysokowydajnego kodu bez tworzenia kolejnych problemów dla siebie w przyszłości.

Gratulacje!

Jeśli to czytasz, prawdopodobnie chcesz lepiej pisać kod dla inżynierii danych. W tym przewodniku pokażę ci, jak podchodzę do pisania kodu jako środka do rozwiązywania problemów.

Trochę o mnie

Jestem starszym inżynierem danych w Headspace Health i zajmuję się inżynierią danych od ponad 5 lat. Więcej na mojej osobistej stronie internetowej .

I. PODSTAWOWE KONCEPCJE

W całym artykule będę odnosił się do kodu jako deklaratywnego i niedeklaratywnego (imperatywnego) :

  • Kod imperatywny (niedeklaratywny) mówi kompilatorowi, co robi program, krok po kroku. Kompilator nie może pominąć kroków, ponieważ każdy krok jest całkowicie zależny od poprzedniego.
  • Kod deklaratywny mówi kompilatorowi, jaki powinien być pożądany stan programu, streszczając kroki, jak to osiągnąć. Kompilator może pomijać kroki lub łączyć je, ponieważ wszystko może określić z wyprzedzeniem wszystkie stany.

Nowoczesne kompilatory mają różne sztuczki , dzięki którym kod działa szybciej i wydajniej na nowoczesnym sprzęcie. Im lepiej kompilator może przewidzieć stan programu, tym więcej „sztuczek” może zastosować, co skutkuje mniejszą liczbą instrukcji i znacznymi korzyściami w zakresie wydajności.

Poniżej znajduje się schemat architektury interfejsu kompilatora z jednostką przetwarzającą (elementem sprzętowym). Niech kompilator ci pomoże! Daj mu prostsze, bardziej przewidywalne instrukcje.

Architektura sprzętowa jednostki przetwarzającej. (źródło: Science Direct)

Podsumowując: kod deklaratywny wykorzystuje możliwości nowoczesnych kompilatorów i skutkuje wyższą wydajnością.

Dobra, przejdźmy do pisania kodu!

II. OŚWIADCZENIE PROBLEMU

Masz listę things, być może lista jest pusta, może zawiera miliony elementów i potrzebujesz pierwszej non-nullwartości:

# 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()]

  • Funkcja powinna zwracać dokładne wyniki — i nie dyskryminować 0'sani pustych ciągów znaków "".
  • Wydajność rozwiązania nie powinna być niska. Prawdopodobnie rozsądne jest dążenie do min = O(1), max = O(k), gdzie kjest rozmiarem listy
  • >> 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])
    ""
    

Rozwiązanie 1: Rozumienie listy [proste rozwiązanie]

Jestem pewien, że każdy inżynier danych rozwiązał ten problem dziesiątki razy. Łatwa fasola! Przejrzyj listę i zobacz, które elementy nie są puste, a następnie zwróć pierwszą wartość:

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

  • Musisz operować na każdym elemencie na liście. Może to być powolne, jeśli Twoja lista jest OGROMNA
  • Rozumienie listy zasadniczo kopiuje listę, więc może wymagać dużej ilości pamięci. Chyba że działamy w miejscu ( my_vals = [x for x in my_vals]), co może powodować problemy z nadpisaniem oryginalnej listy. Dlatego powinniśmy tego unikać.
  • Dostęp do pierwszego elementu na liście list[0]jest niedeklaratywny >> co oznacza, że ​​twój program nie ma gwarancji, jaki będzie atrybut, dopóki go nie otrzyma. To jest w porządku dla Pythona, przez większość czasu. Ale gdy zaczniesz pisać więcej „kodu używanego w organizacji”, zobaczysz przykłady, w których dostęp do elementów na liście idzie nie tak. Na przykład: customer_email = response["data"][0]["custom_attributes"][-1]["email"]<<EEEEEK!
  • Istnieją 2 instrukcje return — jest to częściowo niedeklaratywne i zwiększa złożoność kodu (potencjalnie ograniczając rozszerzalność).

Możemy więc zmienić funkcję, aby przeglądała listę bez przetwarzania wszystkich wartości:

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

Ale są jeszcze niedociągnięcia:

  • Kod jest zapętlony i nie skorzystałby na wektoryzacji
  • Kod nie jest deklaratywny — nasz kompilator jest smutny.
  • Podobnie jak w rozwiązaniu 1 powyżej, istnieją 2 instrukcje zwrotu. Chcę, żeby było tylko 1.

Rozwiązanie 3: Filtruj za pomocą generatora [Rozwiązanie trudne]

Dynamicznie ładuj wartości za pomocą wbudowanej filterfunkcji Pythona, która tworzy generator, który pozwala nam dynamicznie uzyskiwać dostęp i oceniać każdy komponent:

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)

  • Operator jest generatorem/iteratorem filter, co oznacza, że ​​ocenia tylko elementy, które musi. Ponieważ używamy tej nextfunkcji, będzie to zasadniczo leniwe ładowanie.
  • Funkcja pozwala nam dynamicznie zastosować najszybszą ocenę Pythona do wartości >> partial>>is not None w przeciwnym razie, jeśli użyjemy czegoś takiego , [x for x in my_list if x]zostanie 0'swykluczone.
  • Funkcja nextpobiera następny element z iteratora. Pamięć nie eksploduje, ponieważ otrzymujemy tylko 1 wartość na raz. The default jest jawnie ustawione, w przeciwnym razie spowoduje to podniesienie a StopIterationpo wyczerpaniu iteratora.
  • Deklaracyjny charakter pozwala na wektoryzację (i ulepszenia kompilacji).
  • Pozwala również na kompilację just in time , jeśli chcemy rozszerzyć ją na dalszą optymalizację.

Cieszę się, że wzbudziłem Twoje zainteresowanie! Wyjaśnię szczegółowo innym razem. Tymczasem możesz przeczytać trochę na ten temat tutaj: Wektoryzacja: kluczowe narzędzie do poprawy wydajności nowoczesnych procesorów

IV. ROZSZERZENIE NASZEGO ROZWIĄZANIA

Pobieranie pierwszej niepustej wartości ze słownika.

Uzyskanie pierwszego elementu listy jest dość proste. Ale co powiesz na pobranie pierwszej niepustej wartości ze słownika na podstawie zestawu kluczy?

Weźmy na przykład następujący dokument:

{
  "key": {
    "field_1": "one",
    "field_2": "two" 
  }
}

Ponieważ nasze trzecie rozwiązanie get_first_non_null_generator()obejmuje dowolny iterator, możemy utworzyć plik mapper, który powiąże nasz dokument z kluczami wyszukiwania i użyć w naszej funkcji w następujący sposób:

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"

Oto nieco dłuższy przykład (który bardziej przypomina przypadek użycia, który miałem do napisania tego kodu):

# 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

Oto naprawdę wyrafinowany przypadek użycia dostępu do atrybutów klas za pomocą funkcji częściowych i maperów:

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

Weź pod uwagę jak najwięcej z tych czynników podczas pisania kodu z góry i udokumentuj swoje założenia. (Można iść na skróty! Pod warunkiem, że poinformujesz o tym siebie w przyszłości w dokumentacji).

V. MYŚLENIE O ROZWIĄZANIACH

Dużą częścią bycia starszym programistą jest sposób, w jaki myślisz o problemach. Większość problemów, z jakimi borykają się zespoły ds. danych (i zespoły ds. oprogramowania), to połączenie problemów technicznych i organizacyjnych.

Aby zademonstrować, w naszym przypadku piszemy kod, aby znaleźć pierwszą non-nullwartość na liście. Ale z biegiem czasu nasze rozwiązanie będzie wykorzystywane przez inne zespoły, które będą wykorzystywać nasze rozwiązanie na różne sposoby. Na przykład ktoś może spróbować znaleźć pierwszą non-nullwartość w słowniku, mając listę kluczy. To niekoniecznie jest złe. Nieuniknione jest, że programiści wykorzystają Twój kod w sposób, którego nie przewidziałeś, pisząc go po raz pierwszy.

Bez interwencji złożoność bazy kodu na pewno wzrośnie z czasem. Jeśli złożoność stanie się zbyt poważna, utworzysz wielką kulę błota . Wiedząc o tym, jak możesz chronić przyszły stan swojej bazy kodu?

Jeśli nasza organizacja była mała: możemy zostawić komentarz w kodzie o treści:#This code only works with flat lists. Contact YBressler if you have problems

Innymi słowy, należy rozdzielić problemy techniczne i organizacyjne i rozwiązać je osobno. (Techniczne = napisz kod. Organizacyjne = zostaw komentarz.)

Szczerze mówiąc, jest to świetne rozwiązanie, jeśli Twój zespół jest mały. Ale gdy organizacja osiągnie określoną wielkość lub ludzie ją opuszczą, to rozwiązanie staje się problematyczne.

Lepsze rozwiązanie uwzględnia problemy związane z cyklem życia oprogramowania. Zwykle oznacza to upewnienie się, że kod jest prosty, łatwy do przetestowania i wydajny. Ten typ kodu pozwoli przyszłym programistom na łatwą refaktoryzację, umożliwiając im zmianę przeznaczenia i rozszerzenie oryginalnego rozwiązania do późniejszych potrzeb bez zwiększania złożoności bazy kodu.

Innymi słowy, nasz kod powinien rozwiązywać problemy techniczne i organizacyjne. Techniczny = kod działa. Organizacyjny = kod jest łatwy do zrozumienia i można go z łatwością refaktoryzować.

W naszych przykładach kodu z pewnością interesuje mnie wydajność rozwiązania. Jestem równie zaniepokojony tym, jak przyszli programiści będą wchodzić w interakcje z tym rozwiązaniem.

Jeśli rozwiązanie kodowe nie jest łatwe do zrozumienia (wysoki stopień złożoności), ludzie będą się bali wprowadzać w nim zmiany. Albo użyją go w jeszcze bardziej złożony sposób, albo stworzą więcej kodu, co również zwiększy złożoność bazy kodu.

VI. WNIOSEK:

Podsumowując, starsi inżynierowie danych [starają się] pisać kod, który jest łatwy do zrozumienia, jest wydajny, ale co najważniejsze, rozwiązuje przyszłe problemy poprzez zmniejszenie złożoności bazy kodu.

Jako punkt demonstracyjny powyżej, szybkie rozwiązanie get_first_non_null_generator() jest sprytne, łatwe do odczytania i wydajne. Co najważniejsze, ma na celu zmniejszenie złożoności bazy kodu.

Bibliografia:

  1. Farley, D. (2022). W Modern Software Engineering: Robienie tego, co działa, aby szybciej tworzyć lepsze oprogramowanie (s. 128), Addison-Wesley.