Budujemy model w R. Daje nawet dobre wyniki. Ale jak go użyć produkcyjnie? Gdyby tak pytać o wynik korzystając z API?
Dzisiaj o tym jak przygotować w R web-serwis, czyli narzędzie które zapytane (w odpowiedni sposób) przez internet zwróci nam jakiś wynik. W naszym przypadku odpowiedzią będzie wynik działania modelu.
Zadanie podzielimy na kilka etapów:
- przygotowanie modelu
- przygotowanie funkcji, które będą używać naszego modelu
- web-service korzystający z tych funkcji
Przygotowanie modelu
Na warsztat weźmiemy bardzo prosty przykład i prosty model. Będziemy przewidywać gatunek irysa na podstawie rozmiarów płatka (petal) i kielicha (sepal) – czyli wykorzystamy popularny w uczeniu maszynowym zbiór iris.
Model też nie będzie wydumany. Na tym zbiorze sprawdza się las losowy, więc bez większych kombinacji po prostu takiego modelu użyjemy. W R to banalne:
1 2 3 4 5 6 7 |
library(randomForest) # budujemy model model <- randomForest(Species ~ ., data = iris) # zapisujemy go w postaci pliku saveRDS(model, "model_rf.RDS") |
I to wszystko – model jest gotowy. Oczywiście na inne potrzeby może bardziej skomplikowany model, z przetworzeniem danych wejściowych, szukaniem dodatkowych cech i tak dalej – ale nie o tym jest ten wpis.
Przygotowanie predykcji
Teraz czas na przygotowanie funkcji, która zwróci wynik działania modelu. Jako parametry podamy rozmiary płatka i kielicha, w odpowiedzi oczekujemy gatunku irysa.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
# wczytujemy wytrenowany model model <- readRDS("model_rf.RDS") predict_class <- function(sl, sw, pl, pw) { # przygotowanie danych na które odpowie model new_df <- data.frame( Sepal.Length = sl, Sepal.Width = sw, Petal.Length = pl, Petal.Width = pw ) # predykcja pred <- as.character(predict(model, new_df)) return(pred) } |
Sprawdźmy jak nasz model odpowiada na przykładowe zapytanie:
1 2 3 |
predict_class(5.1, 3.5, 1.4, 0.2) ## [1] "setosa" |
I znowu – to wszystko. Teraz wystarczy to ubrać w API.
Przygotowanie web-service’u
Z pomocą przychodzi pakiet plumber, który właściwie całą pracę wykona za nas. O ile mu pomożemy.
W pierwszej kolejności budujemy skrypt (w R), w którym zdefiniujemy funkcje jakie będą dostępne przez API.
Zacznijmy od prostego przykładu: przepisanie podanego jako argument tekstu. Odpowiedni kod rozumiany przez plumber to:
1 2 3 4 5 6 |
#* Wypisanie tekstu #* @param msg Tekst do wypisania #* @get /echo function(msg = "") { list(msg = paste0("Podany tekst to: '", msg, "'")) } |
W pierwszej linii opisujemy krótko co funkcja (metoda API) robi.
W drugiej (i ewentualnie kolejnych) – jakie są parametry (ich nazwy) i do czego służą.
Trzecia linia mówi o sposobie pobrania parametrów (metoda HTTP: w tym przypadku GET, ale może by POST) oraz pod jakim adresem dostępna jest metoda.
Kolejne linie to już sama funkcja. Zwróćcie uwagę, że nie musimy już podawać jej nazwy.
W powyższym przykładzie, mając API uruchomione na adresie http://serwer.api możemy wywołać naszą funkcję poprzez zapytanie:
1 |
http://serwer.api/echo?msg=tekst bedacy parametrem |
co w odpowiedzi da nam:
1 |
{"msg":["Podany tekst to: 'tekst bedacy parametrem'"]} |
Jak widać jest to JSON.
Możemy teraz zbudować funkcję, która odpowie nam wynikiem z modelu. Nieco inaczej zdefiniujemy sposób podania jej parametrów poprzez API:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#* Predict Class #* @get /irisclass/<sl>/<sw>/<pl>/<pw> function(sl, sw, pl, pw) { new_df <- tibble( Sepal.Length = sl, Sepal.Width = sw, Petal.Length = pl, Petal.Width = pw ) pred <- as.character(predict(model, new_df)) return(list(Sepal.Length = sl, Sepal.Width = sw, Petal.Length = pl, Petal.Width = pw, Spices = pred)) } |
Zwróćcie uwagę, że nie wczytujemy w funkcji zapisanego modelu – zrobimy to globalnie, aby zrobić to tylko raz przy uruchomieniu web service’u a nie za każdym wywołaniem funkcji z API. Bo przecież model może by duży, a API powinno odpowiadać szybko.
W powyższym przypadku podanie parametrów wygląda inaczej niż wcześniej – podajemy je jako element ścieżki (URLa) zapytania. Wywołanie wyglądać będzie zatem:
1 |
http://serwer.api/irisclass/5.1/3.5/1.4/0.2 |
a odpowiedź:
1 |
{"Sepal.Length":["5.1"],"Sepal.Width":["3.5"],"Petal.Length":["1.4"],"Petal.Width":["0.2"],"Spices":["setosa"]} |
Dostajemy więc ten sam wynik, jaki dostaliśmy w przypadku użycia funkcji predict_class() (podając oczywiście te same parametry). Wszystko zatem działa.
Uruchomienie web service’u
Czas na uruchomienie web service’u. W tym właśnie miejscu pojawia się plumber. Wszystkie przygotowane funkcje pakujemy do jednego skryptu, niech to będzie plik plumbel.R:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
# plumber.R library(tidyverse) library(randomForest) # wczytanie modelu model <- readRDS("model_rf.RDS") #* Wypisanie tekstu #* @param msg Tekst do wypisania #* @get /echo function(msg = "") { list(msg = paste0("Podany tekst to: '", msg, "'")) } #* Predict Class #* @get /irisclass/<sl>/<sw>/<pl>/<pw> function(sl, sw, pl, pw) { new_df <- tibble( Sepal.Length = sl, Sepal.Width = sw, Petal.Length = pl, Petal.Width = pw ) pred <- as.character(predict(model, new_df)) return(list(Sepal.Length = sl, Sepal.Width = sw, Petal.Length = pl, Petal.Width = pw, Spices = pred)) } #* Plot out data from the iris dataset #* @serializer contentType list(type='image/png') #* @param spec If provided, filter the data to only this species (e.g. 'setosa') #* @get /irisplot/<spec> function(spec) { myData <- iris title <- "All Species" # Filter if the species was specified if (!missing(spec) ) { title <- paste0("Only the '", spec, "' Species") myData <- iris %>% filter(Species == spec) } p <- ggplot(myData) + geom_point(aes(Sepal.Length, Petal.Length)) + labs(title = title, x = "Sepal Length", y = "Petal Length") tmp <- tempfile() ggsave(filename = tmp, plot = p, device = "png") readBin(tmp, 'raw', n = file.info(tmp)$size) } |
Powyższy skrypt definiuje nam metody dostępne przez API. W tym przypadku mamy trzy:
- /echo
- /irisclass
- /irisplot (o niej za chwilę)
Drugi element to uruchomienie web service’u. Wystarczą trzy linijki kodu:
1 2 3 4 5 |
library(plumber) r <- plumb("plumber.R") r$run(host = "0.0.0.0", port = 8080, swagger = FALSE) # domyślnie host = "127.0.0.1" |
Host “0.0.0.0” pozwala nam na obsługę zapytań spływających z internetu. Domyślnie (bez tego parametru) obsługiwane są tylko zapytania z lokalnej maszyny. Możesz więc pominą ten parametr, uruchomić powyższy kod i w przeglądarce wpisać:
1 |
http://localhost:8080/irisplot/setosa |
powinieneś w odpowiedzi dostać wykres:
<
Te trzy linijki kodu możemy oczywiście zapisać do pliku (np. “run_api.R”) i uruchomić taki skrypt przez
1 |
Rscript run_api.R |
Dopóki nie przerwiemy wykonywania skryptu nasze API będzie działało.
Wróćmy do /irisplot – co to robi? Rysuje wykres i zwraca go jako obrazek. Czytelnicy widzą zapewne, że jest to fragment zbioru iris (konkretny gatunek irysów). Różnica tej metody to inny serializer zdefiniowany w linii:
1 |
#* @serializer contentType list(type='image/png') |
Funkcja przygotowuje obrazek, zapisuje go do tymczasowego pliku, a później zwraca ten plik jako ciąg bajtów (poprzez readBin()), bez żadnych dodatkowych informacji jednocześnie mówiąc przeglądarce (w nagłówku) że to obrazek w formacie png.
Na początek budowania API w R to powinno wystarczyć. Więcej informacji znajdziecie w dokumentacji pakietu plumber.
Panie Łukaszu, jaką to będzie miało wydajność w porównaniu np. do Pythona. Pytam nie o przygotowanie działającego modelu a możliwościach wykorzystania modelu w produkcji (podejście praktyczne a nie naukowe).
Nie próbowałem. Kiedy pisałem ten tekst nie robiłem niczego w Pythonie, ale teraz – dzięki Pańskiemu pytaniu – może pokuszę się i sprawdzenie wydajności? Plumber w R wersus Flask w Pythonie, a za nimi jakieś modele zwracajace gatunek irysa w różnych kombinacjach (drzewo, las w sklearn, może prosta sieć w tensorflow plus odpowiedniki w R)