Fortschrittsbalken anzeigen und Code parallelisieren in R: progressr und future

Parallelisierung in R - Symbolbild parallele Züge

Heute will ich zwei Fliegen mit einer Klappe schlagen:

  • Einen Fortschrittsbalken in R implementieren
  • R-Code parallel ausführen (d. h. auf mehreren Prozessorkernen gleichzeitig)

R-Pakete: progressr und future

Für die Umsetzung des Fortschrittsbalkens habe ich mich für progressr von Henrik Bengtsson entschieden. Es bietet eine leistungsfähige API (Schnittstelle), sodass man nicht nur im Paket enthaltene Fortschrittsbalken (Text-Balken), sondern auch externe Signale umsetzen kann, wie etwa akustische Signale mittels des beepr-Pakets.

Die Parallelisierung des R-Codes setze ich mit dem future-Konzept (framework) und insbesondere dem future.apply-Paket um, die ebenfalls von Henrik Bengtsson entwickelt wurden. Die große Stärke des future-Ansatzes liegt darin, dass der R-Code, der die Kernaufgabe ausführt (z. B. Berechnungen), nicht verändert werden muss, wenn sich die Art der Evaluierung (das Backend) ändert. Beispiel: Der Wechsel von sequentieller Code-Evaluation (Standard in R sowie auf Computern mit nur ein oder zwei Prozessorkernen) zur Parallelisierung (Nutzung mehrerer Prozessorkerne gleichzeitig).

Den R-Code gibt es hier: https://github.com/fjodor/parallelization

Anwendungsbeispiel: Viele Regressionsmodelle

Im Anwendungsbeispiel geht es darum, viele Regressionsmodelle aufzustellen und die Modellzusammenfassungen (summary) zu erhalten.

Die Daten für dieses Beispiel stammen aus einer Zufalls-Simulation.

library(progressr)

n <- 500

set.seed(2021)
biomarker <- data.frame(replicate(n, sample(0:100, n/2, replace = TRUE)))
biomarker$result <- sample(1:50, size = n/2, replace = TRUE)

Wir erhalten 500 Zufallsvariablen, die einfach von X1 bis X500 benannt sind. Dazu gibt es eine abhängige Variable result.

Ziel: Für jeden Prädiktor ein separates, simples lineares Modell aufstellen:

result ~ X

Benutzerdefinierte Funktion für Regressionsmodelle

Nächster Schritt: Eine benutzerdefinierte Funktion, die eine unabhängige Variable (Prädiktor) als Argument annimmt und das summary des jeweiligen Regressionsmodells zurückgibt:

# Vektor mit den unabhängigen Variablen (IV = independent variable) bilden
IVs <- names(biomarker[-length(biomarker)])

# Regressionsfunktion definieren

biomarker_reg <- function(IV) {
model <- as.formula(paste("result ~", IV))
summary(lm(model, data = biomarker))
}

Die Modellformel ist ein eigener Objekttyp in R, daher as.formula().

Nun können wir die Funktion anwenden:

# Einzelnes Modell
biomarker_reg("X1")

# Alle Modelle: lapply
lapply(IVs, biomarker_reg)

result <- lapply(IVs, biomarker_reg)

Modellergebnisse abfragen und aufbereiten: Base R und broom

Wie kommen wir an die Modellergebnisse?

# Base R Abfrage
result[[n]]

summary des Regressionsmodells: summary(lm(…))
# Modellgüte, als tidy data aufbereitet
broom::glance(result[[n]])

Gütemaße des Regressionsmodells, bequem aufbereitet mit broom::glance()
Gütemaße des Regressionsmodells, bequem aufbereitet mit broom::glance()
# Koeffizienten, als tidy data aufbereitet
broom::tidy(result[[n]])

Regressionskoeffizienten, bequem aufbereitet mit broom::tidy()
Regressionskoeffizienten, bequem aufbereitet mit broom::tidy()

 

 

 

Die Funktion für den Fortschrittsbalken vorbereiten

biomarker_reg <- function(IVs) {

p <- progressr::progressor(along = IVs)
# p <- progressor(steps = length(IVs))
# p <- progressr::progressor(steps = length(IVs) / 10)

lapply(IVs, function(x) {
   model <- as.formula(paste("result ~", x))
   Sys.sleep(0.001)
   p() # Hier, beim "Zählen", kann man eine Nachricht einfügen
   summary(lm(model, data = biomarker))
  })
}

progressr muss wissen, wann in der Rechenaufgabe Fortschritt erzielt wird. Das regelt der progressor, hier das p(). Der Fortschrittszähler wird mit progressor() initiert und muss hier innerhalb des lapply-Aufrufs mit p() weitergezählt werden. lapply ruft hier eine anonyme Funktion auf (unbenannte Funktion; sie wird nur innerhalb von lapply definiert und keinem Objekt zugewiesen; auch lambda-Funktion genannt), die sich über mehrere Programmzeilen erstreckt und mit den geschweiften Klammern {} zu einem zusammengehörigen Ausdruck verbunden wird.

Den Fortschrittsbalken anzeigen

Es gibt zwei Möglichkeiten, den Fortschrittsbalken anzuzeigen:

  • mit with_progress()
  • mit globalen Einstellungen: Handhabung per handlers()
# Variante 1:

with_progress(
result <- biomarker_reg(IVs)
)

# Variante 2:

handlers(global = TRUE)
result <- biomarker_reg(IVs)

# Alternatives Fortschrittssignal
# Achtung: In diesem Beispiel nervig, da viele Modelle,
# viele Signale, anstrengender Piepton ...
# Bei länger laufenden Berechnungen nützlicher

handlers("beepr")
result <- biomarker_reg(IVs)

Parallelisierung mit future.apply

Bisher laufen die Berechnungen noch sequentiell, d. h. egal ob mit oder ohne Fortschrittsbalken – es wird immer nur ein Prozessorkern bzw. Arbeiter genutzt.

Für die Parallelisierung mit dem future-Konzept ersetzen wird einfach lapply durch future_lapply.

handlers(global = TRUE)
handlers("txtprogressbar")

library(future.apply)

biomarker_reg_p <- function(IVs) {

  p <- progressr::progressor(along = IVs)

  future_lapply(IVs, function(x) {
    model <- as.formula(paste("result ~", x))
    Sys.sleep(0.001)
    p()
    summary(lm(model, data = biomarker))
  })
}

Nun können wir an separaten Stellen im Code entscheiden, mit welchem Backend wir die Funktion evaluieren, also auswerten / berechnen lassen wollen:

# Sequentiell, wie bisher
plan(sequential)
result <- biomarker_reg_p(IVs)
broom::glance(result[[n]])

# Parallel: Mehrere R-Sessions
plan(multisession)
result <- biomarker_reg_p(IVs)
broom::glance(result[[n]])

# Parallel: Mehrere Arbeiter, hier: Kerne
cl <- parallelly::availableCores(omit = 1)

plan(cluster)
result <- biomarker_reg_p(IVs)
broom::glance(result[[n]])
# Arbeiter abmelden
stopCluster(cl)

Das Schöne an dem future-Konzept ist, dass der Funktionscode selbst nicht verändert werden muss, wenn man von sequentieller Ausführung auf Parallelisierung umstellt. Lediglich der plan() ändert sich.

Um die Anzahl der Prozessorkerne zu ermitteln, habe ich früher parallel::detectCores() verwendet und einen abgezogen (-1): Es ist gute Praxis, nicht alle Kerne zu blockieren, sondern dem Betriebssystem einen zu lassen, damit die Maschine stabil bleibt. Gefahr: Bei Rechnern mit nur einem Kern kann dieser Ausdruck Null werden. parallelly::availableCores(omit = 1) stellt hingegen ein Minimum von 1 sicher – ein Kern wird auf jeden Fall für R zur Verfügung gestellt. Zudem ist availableCores() nützlich auf Serverumgebungen, wo Umgebungsvariablen angegeben werden können. Beispiel: Admin legt fest, dass bestimmte Teams maximal über 8 Kerne verfügen auf einer 64-Kern-Maschine. Man kann sich vorstellen, dass es nicht effizient wäre, wenn mehrere Teams gleichzeitig jeweils das Maximum an verfügbaren Kernen ausreizen wollten.

Zusammenfassung / Fazit

Es gibt mehrere R-Pakete, die Fortschrittsbalken implementieren. Ich fand das Konzept von progressr ansprechend, weil es auch Fortschrittssignale aus anderen Paketen nutzen kann, wie etwa Piepstöne von beepr (gut, wenn man ein Stück vom Rechner entfernt an der Kaffeemaschine steht!).

Die Umsetzung erfolgt mittels einer anonymen Funktion in lapply, wo innerhalb des Funktionsablaufs ein Fortschrittszähler initiiert wird (siehe oben: das p()).

Dieser Ansatz ist gut mit Parallelisierung verknüpfbar. Man muss lediglich das lapply() durch future_lapply() ersetzen. Dann kann man separat mit plan() verschiedene Auswertungsstrategien einsetzen, wie sequentiell oder parallel mit unterschiedlichen Backends, ohne den Code der eigentlichen Berechnungsfunktion umstellen zu müssen.

Im Video oben sieht man die Fortschrittsbalken in Aktion. Bei den Parallelisierungsvarianten sind sie schneller, aber auch ruckeliger als bei der kontinuierlichen sequentiellen Abarbeitung.

Viel Erfolg mit Euren R-Projekten! Wenn Ihr Erfahrung mit Fortschrittsbalken und / oder Parallelisierung habt, lasst es mich gern in den Kommentaren wissen.

Freue mich über Kommentare!