W ciągu najbliższych tygodni na Szychcie pojawi się seria wpisów, które mają przybliżyć tworzenie dobrej jakości kodu w kontekście przetwarzania danych. Wpisy będą techniczne, nie będzie wykresów i narracji zbudowanych na danych. Jednocześnie jest to dobra okazja do porównania dwóch języków programowania R i Pythona, z czego skorzystamy.

Który język będzie górą? Czy filozofia, która za nimi stoi jest zbliżona? Która społeczność kładzie większy nacisk na tworzenie wysokiej jakości kodu?

Pierwsza część odpowiedzi na te pytania już w dzisiejszym wpisie. Zajmiemy się kwestią zarządzania bibliotekami. Dlaczego tak istotna jest kontrola nad wersjami używanych bibliotek? Jakie narzędzia służą do tego w R i w Pythonie?

Dlaczego wersje bibliotek mają znaczenie?

Na problem z wersjami bibliotek (pakietów) napotyka każdy kto pracuje nad więcej niż jednym projektem. Chcąc tworzyć nietrywialne rozwiązania korzystamy z ogromu oprogramowania, które stworzył ktoś inny. W R i w Pythonie to oprogramowanie jest umieszczane w pakietach, które można ściągać z internetu. Pakietami w Pythonie są na przykład pandas czy numpy, a w R dplyr czy ggplot2.

Skoro już wiemy czym są biblioteki, rozważmy 3 scenariusze.

  1. Mamy dwa projekty. Jeden stworzyliśmy rok temu, drugi rozwijamy dzisiaj. Mając na komputerze jedną wersję pakietów (R lub Python) może się okazać, że kod ze starego projektu przestaje nam działać na zbiorze pakietów, których używamy przy nowym projekcie. Co gorsze nie jesteśmy w stanie go uruchomić, bo nie pamiętamy jakie wersje onegdaj zainstalowaliśmy.
  2. Mamy 3 osoby w zespole, każda z nich niezależnie rozwija ten sam kod korzystając z systemu kontroli wersji. Po spięciu w jedną całość okazuje się, że wersje zależności są ze sobą niezgodne. Przykład: pandas < 1.0.0 pozwalał na dosyć liberalne nadpisywanie wartości w kolumnach i wierszach. Obecnie powoduje to błędy. W R pakiety z tidyverse są znane z tego, że nie są wstecznie kompatybilne i pojawiają się w nim duże zmiany.
  3. Napisaliśmy oprogramowanie którym chcemy podzielić się ze światem jako open-source. Skąd osoba, która miałaby skorzystać z naszego pakietu ma wiedzieć jakie inne biblioteki musi mieć zainstalowane?

Z tych scenariuszy wyłania nam się wniosek:

Jeżeli mamy więcej niż jeden projekt i/lub współpracujemy z co najmniej jedną osobą to kontrola nad zależnościami będzie dla nas przydatna.

Anonimowy mieszkaniec Poznańskich Jeżyc

Uwaga! To nie jest sztuka dla sztuki! To jest konieczność jeśli chce się pisać coś więcej niż jednorazowe skrypty.

Aby skutecznie zarządzać zależnościami, mamy do rozwiązania dwa podstawowe problemy.

  1. Jak określić wymagania dotyczące wersji bibliotek (i jak dzielić się nimi pomiędzy różnymi użytkownikami/komputerami)?
  2. Jak rozwiązać problem powstawania konfliktów pomiędzy zależnościami.

Punkt drugi, jak zobaczymy poniżej, dotyczy głównie Pythona.

Zarządzanie zależnościami w Pythonie

W Pythonie mamy kilka opcji na zarządzanie zależnościami (dependency management). Służą do tego między innymi pipenv, pip + piptools, poetry, conda.

Jak to bywa w programowaniu, niemal każdy ma swój ulubiony sposób i jest przekonany o jego wyższości. Dlaczego jest tak dużo opcji? Bo zarządzanie zależnościami to zadanie łatwe do sformułowania, ale trudne do zrealizowania. Niektóre biblioteki mają sprzeczne zależności. Znajdowanie ,,najlepszego wspólnego mianownika" nie jest trywialne gdy mówimy o dziesiątkach czy setkach pakietów.

Flow dla “rozwiązywania drzewa zależności”

Pracujemy nad nowym projektem i zaczynamy instalować biblioteki, które będą dla nas przydatne. Jakie kroki należy wykonać?

Wersja podstawowa czyli conda lub pip + venv

Conda i pip + venv oferują w wersji podstawowej podobne podejście. Zaczynamy oczywiście od tworzenie środowiska wirtualnego.

conda init  # wersja conda
python3 -m venv .venv  # wersja pip + venv

Mogłoby nas kusić pójście na skróty i stworzenie po prostu pliku requirements.txt, który zawierałby informacje o pakietach, które potrzebujemy. Na przykład:

numpy
pandas

Niestety jest to niewystarczające. Po pierwsze, jeśli zainstalowaliśmy te pliku rok temu, to obecnie wykonując komendę

pip install -r requirements.txt

zainstalujemy nowsze wersje pakietów nie mając żadnej gwarancji, że są one kompatybilne z napisanym dawniej kodem.

Po drugie, nawet jeśli określimy konkretne wersje numpy i pandasa, to nie dookreślimy wersji ich zależności. Zatem środowisko jakie odtwarzalibyśmy nadal byłoby potencjalnie za każdym razem inne (nondeterministic build).

Bardziej wyrafinowe podejście zakłada dwa etapy. Najpierw instalujemy kolejne biblioteki. Jeśli doszło to konfliktu zależności dostajemy o nich informacje i cofamy się zmieniając wersje instalowanych bibliotek. Następnie wersje bibliotek dla działającego programu zapisujemy w pliku requirements.txt. Można go wygenerować na przykład komendą

pip freeze > requirements.txt  # wersja pip + venv
conda list -e > requirements.txt # wersja conda

Niestety takie podejście także wiąże się z kilkoma problemami.

Określenie wszystkich wersji na sztywno i ich całkowite niezmienianie bardzo utrudnia to, aby software którego używamy był rozsądnie aktualny, co jest jedną z dobrych zasad tworzenia oprogramowania. Być może w jakiejś z bibliotek będących zależnościami naprawiono istotnego bugu i chcemy korzystać z nowszej wersji? ,,Punktowe" poprawianie requirements.txt może prowadzić do konfliktów. Innym działaniem, które możemy podjąć jest usunięcie jakiejś biblioteki. Czy przy okazji nie należałoby usunąć też jej zależności? Są przecież niepotrzebne.

Ponieważ każda biblioteka, w każdej wersji, ma swoje zależności, dostajemy gigantyczne drzewo opisujące relacje między nimi. Potrzebny jest specjalne oprogramowanie, które je ,,rozwiąże", to znaczy wskaże zbiór wersji, który pozwoli zainstalować wszystkie biblioteki bez konfliktów.

Idealnego rozwiązania nie ma, ale są sposoby jak sobie z tym radzić.

pipenv i poetry

Użytkownik instaluje kolejne biblioteki za pomocą specjalnej komendy, na przykład:

pipenv install pandas

Pipenv tworzy pod spodem plik Pipfile, który zawiera informacje o bibliotekach, na których nam zależy (możliwe jest określenie konkretnych wersji).

Na podstawie tego pliku pipenv generuje Pipfile.lock, który zawiera określenie dokładnych wersji wszystkich zależności.

pipenv lock

I tyle w wersji bardzo podstawowej :)

Aby odtworzyć środowisko na podstawie Pipfile.lock używamy komendy

pipenv install --ignore-pipfile

W teorii wszystko pięknie. W praktyce komenda pipenv install potrafi działać bardzo długo i wyrzucać błędy. Więcej informacji na temat pipenv-a można znaleźć tutaj. Podobnie do pipenva działa poetry.

pip + venv + pip-tools

Każdy ma prawo używać czego chce, ale przedstawię bardziej dokładny kod dla opcji, która dla mnie osobiście jest najwygodniejsza.

  1. Tworzymy nowe środowisko w ukrytym folderze .venv
python3 -m venv .venv
  1. Aktywujemy środowisko
source .venv/bin/activate

Od teraz komendy python3, pip3 będą odwoływać się do pythona z naszego wirtualnego środowiska.

Uwaga, na Windowsie komenda wygląda nieco inaczej

.venv\Scripts\activate
  1. Instalujemy pip-toolsy
pip install -U pip setuptools wheel
  1. Określamy plik requirements.in

Tutaj zawieramy informacje o pakietach, z których chcemy korzystać.

pandas
numpy>=1.15
sqlalchemy
  1. Tworzymy requirements.txt z dokładnymi wersjami wszystkich zależności
pip-compile --generate-hashes requirements.in
  1. Aby odtworzyć środowisko, instalujemy biblioteki z requirements.txt
pip3 install -r requirements.txt

Uwaga: jeśli korzystamy z systemu kontroli wersji (git) to potrzebujemy kontrolować wersje zarówno pliku requirements.in jak i requirements.txt. Analogicznie Pipfile i Pipfile.lock.

Uwaga 2: nie ma jednego jasnego zwycięzcy. Każda opcja ma swoje zalety i wady. Diabeł tkwi w szczegółach. Pipenv i poetry są bardziej złożone. pip-tools to prostsze rozwiązanie.

Uwaga 3: czasem warto zdać się na mądrość tłumu i używać najpopularniejszego rozwiązania.

Dependency management w R

R, jak wiadomo, jest językiem programowania nastawionym na analizę danych. Po to został stworzony prawie 30 lat temu.

„Nie można zrobić konia wyścigowego ze świni. – Nie – odparł Samuel – Ale można z niej zrobić bardzo szybą świnię.“

John Steinbeck, Na wschód od Edenu

Tworzenie oprogramowania w R jest utrudnione, bo narzędzia do tego służące są mniej rozwinięte niż w Pythonie. Wynika to także z tego, że społeczność eRowa jest bardziej skupiona na tym jak wydobyć informację z danych, niż jak zbudować dobrze działający (od strony czysto inżynierskiej) system.

Jeszcze kilka lat temu jedyną opcją był packrat.

Posługujący się wynalazkiem strzelec, zapytany o przydatność broni, miał podobno wyrazić, że kulomiot jest jak jego teściowa. Ciężki, brzydki, całkowicie bezużyteczny i nic, tylko wziąć i utopić w rzece.

Andrzej Sapkowski, Sezon burz

Ale od tego czasu sporo się zmieniło i mamy renv. Pozwala on łatwo określić zależności i się nimi dzielić za pomocą systemu kontroli wersji.

library(renv)
# instalujemy pakiety
install.packages(c("dplyr", "ggplot2"))

renv::snapshot() # tworzy plik lock (podobny do Pipfile.lock)

# instalujmy dalej
install.packages(c("biogram"))

renv::restore() # jeśli coś się wywala

renv::snapshot() # żeby zapamiętać obecne biblioteki

Dostajmy plik, który wygląda mniej więcej tak:

{
  "R": {
    "Version": "4.0.5",
    "Repositories": [
      {
        "Name": "CRAN",
        "URL": "https://cran.rstudio.com"
      }
    ]
  },
  "Packages": {
    "renv": {
      "Package": "renv",
      "Version": "0.13.2",
      "Source": "Repository",
      "Repository": "CRAN",
      "Hash": "079cb1f03ff972b30401ed05623cbe92"
    },
    "rmarkdown": {
      "Package": "rmarkdown",
      "Version": "2.11",
      "Source": "Repository",
      "Repository": "CRAN",
      "Hash": "320017b52d05a943981272b295750388"
    }
  }
}

Wygląda on podobnie to Pipfile.lock.

Kiedy otrzymujemy go od współpracownika lub siebie samego z przeszłości

uruchmiany renva i przywracamy środowisko:

renv::init()

Dlaczego nie ma tak dużego problemu ze ,,skonfliktowanymi" wersjami? Wynika to z natury tego jak i skąd instaluje się pakiety w R.

W R mamy kilka głównych ,,źródeł pakietów" (package repository). Przede wszystkim CRAN, ale dla zastosowań biologicznych kluczowy jest BioConductor. Domyślnie, kiedy wywołuje się funkcję install.packages(), z CRAN ściągane są najnowsze wersje pakietów.

Zapewnienie, że nie ma konfliktu między pakietem a jego zależnościami leży po stronie twórcy pakietu. Jeśli z jakiegoś powodu pakietu nie da się zbudować lub nie przechodzi on testów jest on usuwany z CRANa. Dzięki temu, w teorii, nie ma konfliktów. W praktyce mogą one wystąpić na poziomie uruchomienia programu (runtime error). Przecież zbudowanie pakietu to nie wszystko, a przecież nie każdy fragment kodu jest odpowiednio sprawdzany testami.

Oczywiście pakiety są nie tylko na CRANie. Część jest dostępnych (wyłącznie) na githubie, gitlabie czy bitbuckecie. renv wspiera również ich instalację korzystając z pakietu remote.

Notabene, ten pakiet jest bardzo ciekawy w kontekście zarządzania zależnościami, bo potrafi ścigać wersje z githuba na moment konkretnego commita. Więc w teorii, mógłby on służyć do określenia dokładnych zależności. Ale ponieważ nie wszystkie pakiety są open sourcowe (a te, które są nieczęsto określają wygodnie wersje dostępne na githubie), to nie jest to rozwiązanie, które byłoby wystarczająco uniwersalne.

Porównanie

W podejściu do zarządzania zależnościami widzimy ogromną różnicę w filozofi stojącej za R i Pythonem.

import this

Komenda powyżej wyświetli wiersz na temat Pythona. Jeden jej fragment jest dla nas istotny:

Explicit is better than implicit.

Python wymaga dokładnego określenia zależności tak, żeby nie zaskakiwały nas błędy podczas uruchomienia programu. R idzie bardziej na żywioł, ale jednocześnie jest mocno wymagający względem osób, które chcą się dzielić kodem. Kto ma pakiety na CRANie dostaje regularnie wiadomości o tym, że jedna z zależności jego pakietu jest usuwana z CRANa. Za każdym razem wymaga to sprawdzenia i przepisania własnego kodu. W przeciwnym razie i nasz pakiety zostanie usunięty.

Pod względem zarządzania zależnościami Python ma dużą przewagę nad eRem. Ponieważ Python służy nie tylko do przetwarzania danych a zwłaszcza do ,,zwykłego" tworzenia oprogramowania, oferuje on użytkownikom wiele zaawansowanych rozwiązań. Środowisko eRowców dopiero dojrzewa do jakości oprogramowania jako czegoś istotnego. Dlatego te zagadnienia są niejako na uboczu i tworzenie naprawdę profesjonalnego, produkcyjnego kodu w R wymaga od twórcy wiele wysiłku.

I jeszcze ciekawostka dla tych, którzy dotrwali do końca. Pierwszy obrazek z dzisiejszego wpisu został wygenerowany za pomocą sztucznej inteligencji. Moje zapytanie to:

two winding roads going uphill, parts of computers lying on a sidewalk. At the top of the hill there is a jing jang symbol looking like a sun, digital art

W wolnej chwili zachęcam do zabawy DALLe.