[MoodOfTheSong] 11. Transformacja kodu na bardziej obiektowy

Opiszę dzisiaj zmianę, którą dokonałem w projekcie Mood Of The Song. Zmianę będącą transformacją części kodu w stronę bardziej obiektowego – schowaniem gołych danych do obiektów.

W tym przypadku gołymi danymi nie były dane tak jak je zwykle rozumiemy, nie były to mianowicie zmienne numeryczne czy tekstowe – były to referencje do funkcji. Oto cała historia, krok po kroku:

Wprowadzenie

Zacznę od przedstawienia czegoś co niesamowicie mi się podoba w Pythonie: metody __call__.

Definiując klasę (niechże się nazywa Foo) możemy zaimplementować tą metodę, a jeśli to zrobimy, zostanie ona wywołana za każdym razem kiedy interpreter wykona podświetloną linię:

foo = Foo() # tworzymy instancję klasy Foo
foo() # wołamy tą instancję, jakby była funkcją

No właśnie, można używać obiektu klasy tak jak zwykle używamy funkcji. Strasznie to lubię bo często tworzę klasy, które mają jedną metodę i nie reprezentują danych, ale raczej jakieś działanie. Mówię na przykład o wzorcu projektowym komenda, który można by zaimplementować oraz używać w taki sposób:

class RemoveFileCommand:
  def __init__(self, path):
    self.path = path
  def execute(self):
    os.unlink(path)

command = RemoveFileCommand("my_file.txt")
command.execute()

Ale po co, skoro tak jest prościej:

class RemoveFileCommand:
  def __init__(self, path):
    self.path = path
  def __call__(self):
    os.unlink(path)

command = RemoveFileCommand("my_file.txt")
command()

Nieco gorzej wygląda to kiedy od razu wywołujemy komendę:

RemoveFileCommand("my_file.txt")()

Najczęściej jednak podwójne nawiasy nie są dla mnie problemem: taki obiekt albo tworzę gdzie indziej, a do miejsca w którym go używam, przekazuję jako parametr metody, albo używam jako argument dla operatora map:

out = map(
    MultiplyBy(4),
    [1, 2, 3, 4]
)
print(list(out)) # daje [4, 8, 12, 16]

Dobra, wracamy do mojej historii.

Rozdział 1. Przed refaktoringiem.

Istniała sobie klasa Vector, która reprezentowała akcję obliczania wektora cech dla konkretnego nagrania. Można by ją nazwać równie dobrze CalculateVectorCommand, wielu pewnie by wolało nazwę w stylu VectorCalculator. Nieważne, mi się pierwsza najbardziej podoba.

Jej obsługa była prosta – konstruujemy obiekt inicjalizując go listą przekształceń, a następnie go wywołujemy jako parametr podając sygnał.

vector = Vector(transformations)

# somewhere else
vector(signal)

Jeśli lista transformations ma, dajmy na to, 5 elementów – 5 różnych transformacji, to na podstawie sygnału wejściowego, obliczony zostanie wektor 5-cio elementowy, a każdy z tych elementów to będzie wynik jednej transformacji na sygnale wejściowym.

A oto implementacja klasy:

    class Vector:
        def __init__(self, transforms):
            self.transforms = transforms

        def __call__(self, signal):
            return list(
                map(
                    Feature(signal),
                    self.transforms
                ))

Tak na prawdę całą robotę robi klasa Feature, której implementacja wygląda z kolei tak:

        class Feature:
            def __init__(self, signal):
                self.signal = signal

            def __call__(self, transform):
                return transform(self.signal).mean()

W zaznaczonej linijce widać, że metoda __call__ wywołuje funkcję przekazaną jej jako argument, a potem liczy jeszcze średnią.

Już się pewnie domyślacie, że argument transformations, który przekazujemy w konstruktorze klasy Vector to po prostu lista funkcji. Co ważne, funkcji, które akceptują typ danych reprezentujący sygnał (tablice), a zwracają coś na czym możemy wywołać metodę mean. Ta lista wygląda mniej-więcej tak:

[
        librosa.feature.zero_crossing_rate,
        librosa.feature.spectral_rolloff,
        librosa.feature.spectral_bandwidth,
        librosa.feature.spectral_centroid
]

Takie rozwiązanie niosło ze sobą kilka problemów:

Co jeśli nie będę chciał obliczać średniej danego przekształcenia, ale na przykład medianę?
A z jeszcze innym nie będę chciał robić nic: przekształcenie zwróci wektor, a ja będę chciał ten zwrócony wektor po prostu dokleić do całego wektora cech?

2. Pętla zamiast map

Dopóki prawdziwe było założenie, że na sygnale stosujemy n transformacji, z których każda zwraca jeden wynik, mogliśmy użyć operatora map i mapować n transformacji na wektor n elementowy.

Odkąd uznajemy, że każda transformacja może zwrócić wektor jedno- lub kilkuelementowy, operator map należałoby zastąpić konstrukcją znaną z innych języków programowania: flat_map. Niestety Python czegoś takiego nie posiada, najprostsze będzie więc zastosowanie zwykłej pętli i doklejanie wyjść z każdej transformacji do wektora cech.

Tak więc to:

        def __call__(self, signal):
            return list(
                map(
                    Feature(signal),
                    self.transforms
                ))

zamieniamy na to:

        def __call__(self, signal):
            vector = []
            for transform in self.transforms:
                out = transform(signal)
                for o in out:
                    vector.append(o)
            return vector

Jak widzicie, chwilowo wyłączona została z całego procesu klasa Feature i stosowanie transformaty na sygnale jest dokonywane bezpośrednio w klasie Vector (w zaznaczonej linii kodu).

Tym samym straciliśmy jeden krok – obliczanie średniej z tego co zwróciła transformata. Kolejną zmianą w kodzie będzie więc przeniesienie tego kroku do transformaty.

3. Obiekt zamiast funkcji

Jak pisałem wcześniej, klasę Vector inicjalizowałem listą funkcji:

[
        librosa.feature.zero_crossing_rate,
        librosa.feature.spectral_rolloff,
        librosa.feature.spectral_bandwidth,
        librosa.feature.spectral_centroid
]

W jaki sposób włączyć do tej listy informację, że interesuje mnie średnia wartość wyniku przekształcenia? Właśnie tutaj nastąpiła główna zmiana – gołe funkcję zamieniłem na „wołalne” obiekty (takie, które mają zaimplementowaną __call__).

class Mean:
    def __init__(self, function):
        self.function = function

    def __call__(self, signal):
        return [self.function(signal).mean()]

# features
[
        Mean(librosa.feature.zero_crossing_rate),
        Mean(librosa.feature.spectral_rolloff),
        Mean(librosa.feature.spectral_bandwidth),
        Mean(librosa.feature.spectral_centroid)
]

4. Nie tylko średnia

I teraz najważniejsze – odpowiedź na pytanie: co zyskałem dzięki takiemu przekształceniu? Wspomniałem wcześniej, że czasem będę zainteresowany gołym wynikiem transformacji, czasem średnią, czasem medianą, a czasem jeszcze czymś innym.

No to do dzieła:

[
        Mean(first_transformation),
        Mean(second_transformation),
        Median(third_transformation),
        Raw(fourth_transformation)
]

Teraz, bez żadnych dodatkowych modyfikacji klasy Vector, otrzymam wektor cech wyglądający tak:

Jak widzicie, zamiast działać na liście funkcji, korzystam z listy obiektów, które enkapsułują te funkcję oraz ewentualne dodatkowe przekształcenia. Klasa Vector nie musi martwić się o to co dostanie w wyniku kolejnej transformacji i czy trzeba to jeszcze jakoś obrobić – ona po prostu ufa obiektowi, który jej został przekazany.