tidymodels

Auch wenn der Workshop vielleicht bisher nicht den Eindruck gemacht hat - R ist natürlich vor allem als Sprache zur Vorbereitung und Durchführung von statistischen Analysen gedacht.

Die meisten grundlegenden Analysen lassen sich natürlich - genau wie das meiste der Inhalte die bisher Thema waren - mit base-R durchführen.

Im Kosmos des tidyverse hat sich aber auch hier eine Reihe an Paketen angesammelt, die viele Auswertungen streamlinen, leichter lesbarer machen und in einer gemeinsamen Sprache ausdrücken. Die unter dem Namen tidymodels gesammelten Pakete bieten dabei Helper für Inferenz-Statistik und Machine Learning-Anwendungen in der uns jetzt ja schon bekannten tidy-Schreibweise - also als eine Reihe von in pipelines verkett-baren Verben. Wie im tidyverse lässt sich auch diese Funktionssammlung mit einem Call laden:

library(tidymodels)
── Attaching packages ────────────────────────────────────── tidymodels 1.1.1 ──
✔ broom        1.0.5     ✔ recipes      1.0.9
✔ dials        1.2.0     ✔ rsample      1.2.0
✔ dplyr        1.1.2     ✔ tibble       3.2.1
✔ ggplot2      3.4.4     ✔ tidyr        1.3.0
✔ infer        1.0.6     ✔ tune         1.1.2
✔ modeldata    1.3.0     ✔ workflows    1.1.3
✔ parsnip      1.1.1     ✔ workflowsets 1.0.1
✔ purrr        1.0.2     ✔ yardstick    1.3.0
── Conflicts ───────────────────────────────────────── tidymodels_conflicts() ──
✖ purrr::discard() masks scales::discard()
✖ dplyr::filter()  masks stats::filter()
✖ dplyr::lag()     masks stats::lag()
✖ recipes::step()  masks stats::step()
• Learn how to get started at https://www.tidymodels.org/start/

Für schon routinierte Nutzer von R ist dabei die größte Umstellung, dass Modelle nicht mehr wie folgt in einer Zeile definiert werden. Im datasets-Paket wird der Datensatz Sacramento mitgeliefert, der Wohnungspreise in Sacramento beinhaltet:

head(Sacramento)
# A tibble: 6 × 9
  city       zip     beds baths  sqft type        price latitude longitude
  <fct>      <fct>  <int> <dbl> <int> <fct>       <int>    <dbl>     <dbl>
1 SACRAMENTO z95838     2     1   836 Residential 59222     38.6     -121.
2 SACRAMENTO z95823     3     1  1167 Residential 68212     38.5     -121.
3 SACRAMENTO z95815     2     1   796 Residential 68880     38.6     -121.
4 SACRAMENTO z95815     2     1   852 Residential 69307     38.6     -121.
5 SACRAMENTO z95824     2     1   797 Residential 81900     38.5     -121.
6 SACRAMENTO z95841     3     1  1122 Condo       89921     38.7     -121.

Wenn wir in Base-R eine Regression der Wohnungsgröße und der Anzahl der Schlafzimmer auf den Preis durchführen wollen, sähe das wie folgt aus:

lm(price ~ sqft + beds, data = Sacramento)

Call:
lm(formula = price ~ sqft + beds, data = Sacramento)

Coefficients:
(Intercept)         sqft         beds  
    60850.5        161.3     -26035.1  

Und um inferenzstatistische Kennwerte zu erhalten könnten wir zum Beispiel summary aufrufen:

lm(price ~ sqft + beds, data = Sacramento) %>% 
  summary()

Call:
lm(formula = price ~ sqft + beds, data = Sacramento)

Residuals:
    Min      1Q  Median      3Q     Max 
-200938  -52196   -9148   37268  620281 

Coefficients:
              Estimate Std. Error t value Pr(>|t|)    
(Intercept)  60850.544  10424.530   5.837 7.33e-09 ***
sqft           161.336      5.339  30.218  < 2e-16 ***
beds        -26035.145   4366.612  -5.962 3.53e-09 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 82610 on 929 degrees of freedom
Multiple R-squared:  0.604, Adjusted R-squared:  0.6031 
F-statistic: 708.5 on 2 and 929 DF,  p-value: < 2.2e-16

Die Logik von tidymodels ist hierbei näher an ggplot2. Wir initiieren ein Modell aus der Familie, das wir fitten wollen und geben dann an, auf welche Daten wir dieses fitten wollen. Dazu übergeben wir die erstellte Modell-Instanz an die fit-Funktion mit den gesetzten Parametern:

lm_model <- linear_reg()

lm_fit <- lm_model %>% 
  fit(price ~ sqft + beds, data = Sacramento)

lm_fit
parsnip model object


Call:
stats::lm(formula = price ~ sqft + beds, data = data)

Coefficients:
(Intercept)         sqft         beds  
    60850.5        161.3     -26035.1  

Das Ergebnis ließe sich wieder mit summary zusammenfassen, in tidymodels gibt es dafür aber das Paket broom mit ein paar netteren Wrappern:

lm_fit %>% 
  tidy()
# A tibble: 3 × 5
  term        estimate std.error statistic   p.value
  <chr>          <dbl>     <dbl>     <dbl>     <dbl>
1 (Intercept)   60851.  10425.        5.84 7.33e-  9
2 sqft            161.      5.34     30.2  2.95e-140
3 beds         -26035.   4367.       -5.96 3.53e-  9
lm_fit %>% 
  glance()
# A tibble: 1 × 12
  r.squared adj.r.squared  sigma statistic   p.value    df  logLik    AIC    BIC
      <dbl>         <dbl>  <dbl>     <dbl>     <dbl> <dbl>   <dbl>  <dbl>  <dbl>
1     0.604         0.603 82605.      708. 1.35e-187     2 -11873. 23754. 23773.
# ℹ 3 more variables: deviance <dbl>, df.residual <int>, nobs <int>

Der Schritt der anfänglichen Modelldefinition scheint erstmal mehr Aufwand zu sein - der Vorteil wird aber deutlich, wenn ich mich entscheide eine andere Modell-Architektur zur Lösung der Regression nutzen möchte.

Um statt einer least-squares-Regression eine regularisiertes Regressionsmodell einzusetzen, da ich befürchte dass Anzahl der Schlafzimmer und Größe der Wohnung korreliert sind, muss ich nur eine Kleinigkeit am Modell ändern:

glmnet_model <- linear_reg(penalty = 0.95, mixture = 0.5) %>% 
  set_engine('glmnet')


glmnet_fit <- glmnet_model %>% 
  fit(price ~ sqft + beds, data = Sacramento)

glmnet_fit %>% 
  tidy()
Loading required package: Matrix

Attaching package: 'Matrix'
The following objects are masked from 'package:tidyr':

    expand, pack, unpack
Loaded glmnet 4.1-8
# A tibble: 3 × 3
  term        estimate penalty
  <chr>          <dbl>   <dbl>
1 (Intercept)   59837.    0.95
2 sqft            160.    0.95
3 beds         -25066.    0.95

Oder aber ich überlege mir, dass die Zusammenhänge sicher deutlich komplizierter sind und sich das Problem nur mit einem Deep-Learning-Ansatz1 lösen lässt - also nehme ich Keras als engine:

1 wenn man einen Hidden Layer mit einem Neuron denn Deep nennen kann

keras_model <- linear_reg(penalty = 0.01) %>% 
  set_engine('keras')


keras_fit <- keras_model %>% 
  fit(price ~ sqft + beds, data = Sacramento)
Epoch 1/20
30/30 - 1s - loss: 76832931840.0000 - 714ms/epoch - 24ms/step
Epoch 2/20
30/30 - 0s - loss: 76768526336.0000 - 77ms/epoch - 3ms/step
Epoch 3/20
30/30 - 0s - loss: 76701777920.0000 - 49ms/epoch - 2ms/step
Epoch 4/20
30/30 - 0s - loss: 76632064000.0000 - 47ms/epoch - 2ms/step
Epoch 5/20
30/30 - 0s - loss: 76558123008.0000 - 47ms/epoch - 2ms/step
Epoch 6/20
30/30 - 0s - loss: 76483477504.0000 - 44ms/epoch - 1ms/step
Epoch 7/20
30/30 - 0s - loss: 76406882304.0000 - 50ms/epoch - 2ms/step
Epoch 8/20
30/30 - 0s - loss: 76327763968.0000 - 53ms/epoch - 2ms/step
Epoch 9/20
30/30 - 0s - loss: 76246908928.0000 - 46ms/epoch - 2ms/step
Epoch 10/20
30/30 - 0s - loss: 76160606208.0000 - 42ms/epoch - 1ms/step
Epoch 11/20
30/30 - 0s - loss: 76072050688.0000 - 49ms/epoch - 2ms/step
Epoch 12/20
30/30 - 0s - loss: 75983183872.0000 - 47ms/epoch - 2ms/step
Epoch 13/20
30/30 - 0s - loss: 75888345088.0000 - 44ms/epoch - 1ms/step
Epoch 14/20
30/30 - 0s - loss: 75791106048.0000 - 48ms/epoch - 2ms/step
Epoch 15/20
30/30 - 0s - loss: 75691532288.0000 - 49ms/epoch - 2ms/step
Epoch 16/20
30/30 - 0s - loss: 75588255744.0000 - 47ms/epoch - 2ms/step
Epoch 17/20
30/30 - 0s - loss: 75481128960.0000 - 44ms/epoch - 1ms/step
Epoch 18/20
30/30 - 0s - loss: 75370840064.0000 - 49ms/epoch - 2ms/step
Epoch 19/20
30/30 - 0s - loss: 75260059648.0000 - 42ms/epoch - 1ms/step
Epoch 20/20
30/30 - 0s - loss: 75148574720.0000 - 57ms/epoch - 2ms/step
keras_fit
parsnip model object

Model: "sequential"
________________________________________________________________________________
 Layer (type)                       Output Shape                    Param #     
================================================================================
 dense (Dense)                      (None, 1)                       3           
 dense_1 (Dense)                    (None, 1)                       2           
================================================================================
Total params: 5 (20.00 Byte)
Trainable params: 5 (20.00 Byte)
Non-trainable params: 0 (0.00 Byte)
________________________________________________________________________________

Aber wenn ich schon bei Keras bin, warum dann nicht mit einem größeren Layer? Und warum lineare Aktivierung, wenn ich schon ein Deep-Learning-Backend bemühe?

keras_model <- linear_reg(penalty = 0.01) %>% 
  set_engine('keras', hidden_units = 128, act = 'relu')


keras_fit <- keras_model %>% 
  fit(price ~ sqft + beds, data = Sacramento)
Epoch 1/20
30/30 - 1s - loss: 77915463680.0000 - 692ms/epoch - 23ms/step
Epoch 2/20
30/30 - 0s - loss: 77509935104.0000 - 54ms/epoch - 2ms/step
Epoch 3/20
30/30 - 0s - loss: 77068238848.0000 - 54ms/epoch - 2ms/step
Epoch 4/20
30/30 - 0s - loss: 76526198784.0000 - 57ms/epoch - 2ms/step
Epoch 5/20
30/30 - 0s - loss: 75861204992.0000 - 48ms/epoch - 2ms/step
Epoch 6/20
30/30 - 0s - loss: 75070914560.0000 - 48ms/epoch - 2ms/step
Epoch 7/20
30/30 - 0s - loss: 74100654080.0000 - 40ms/epoch - 1ms/step
Epoch 8/20
30/30 - 0s - loss: 72966782976.0000 - 46ms/epoch - 2ms/step
Epoch 9/20
30/30 - 0s - loss: 71625867264.0000 - 56ms/epoch - 2ms/step
Epoch 10/20
30/30 - 0s - loss: 70042820608.0000 - 50ms/epoch - 2ms/step
Epoch 11/20
30/30 - 0s - loss: 68279267328.0000 - 58ms/epoch - 2ms/step
Epoch 12/20
30/30 - 0s - loss: 66348236800.0000 - 52ms/epoch - 2ms/step
Epoch 13/20
30/30 - 0s - loss: 64253865984.0000 - 53ms/epoch - 2ms/step
Epoch 14/20
30/30 - 0s - loss: 61987528704.0000 - 57ms/epoch - 2ms/step
Epoch 15/20
30/30 - 0s - loss: 59563237376.0000 - 45ms/epoch - 1ms/step
Epoch 16/20
30/30 - 0s - loss: 56993083392.0000 - 46ms/epoch - 2ms/step
Epoch 17/20
30/30 - 0s - loss: 54320918528.0000 - 45ms/epoch - 1ms/step
Epoch 18/20
30/30 - 0s - loss: 51583815680.0000 - 46ms/epoch - 2ms/step
Epoch 19/20
30/30 - 0s - loss: 48807002112.0000 - 51ms/epoch - 2ms/step
Epoch 20/20
30/30 - 0s - loss: 46015004672.0000 - 46ms/epoch - 2ms/step
keras_fit
parsnip model object

Model: "sequential_1"
________________________________________________________________________________
 Layer (type)                       Output Shape                    Param #     
================================================================================
 dense_2 (Dense)                    (None, 128)                     384         
 dense_3 (Dense)                    (None, 1)                       129         
================================================================================
Total params: 513 (2.00 KB)
Trainable params: 513 (2.00 KB)
Non-trainable params: 0 (0.00 Byte)
________________________________________________________________________________

Je nach Modell sind die daraus resultierenden, interpretier- und testbaren Parameter natürlich andere.

Modelltypen, Modes und Engines

Die Modelldefinition funktioniert in tidymodels über das parsnip-Paket. Die Syntax besteht auf der Modellseite dabei immer aus einem Modelltyp, im Beispiel linear_reg.

Dabei gibt es für so gut wie jeden Anwendungsfall von statistischer oder ML-Modellierung einen Modelltyp, der das Problem abzubilden versucht. 2

2 Im mit parsnip gelieferten Datensatz model_db ist eine (so weit ich das richtig sehe) nicht vollständige Liste der möglichen Modelle - hier werden allein 37 verschiedene Modelle gelistet.

3 Für die partykit-engine sind die Zusatzpakete partykit und bonsai nötig.

Ein anderes Beispiel für einen Modelltypen ist ein rand_forest. Um unser Preis-Problem zu lösen könnten wir den Aufruf wie folgt gestalten: 3

library(bonsai)
partykit_model <- rand_forest(trees = 500) %>% 
  set_mode('regression') %>% 
  set_engine('partykit')


partykit_fit <- partykit_model %>% 
  fit(price ~ sqft + beds, data = Sacramento)

partykit_fit$fit %>% 
  partykit::varimp()
       sqft        beds 
18570726692   663613243 

Random Forests können sowohl Klassifikations- als auch Regressions-Probleme lösen. Mit dem Zusatz set_mode zur Pipeline können wir deshalb spezifizieren, welche Art von Problem mit dem Modell gefittet werden soll.

Nach Definition von Modelltyp und mode folgt eine engine, mit der das Problem gelöst werden soll. In den Beispielen waren die engines erst lm aus stats, dann glmnet aus dem gleichnamigen Paket und abschließend der Wrapper keras_mlp um ein Keras-Netz aus dem parsnip-Paket. Zuletzt kam jetzt noch ein cforest aus dem partykit dazu.

parsnip macht mit den Anweisungen nichts anderes, als daraus einen dem Paket angemessenen Template-Call zu formulieren. Diesen template-Call kann man sich exemplarisch für das zweite Beispiel wie folgt angucken:

glmnet_model %>% 
  translate()
Linear Regression Model Specification (regression)

Main Arguments:
  penalty = 0.95
  mixture = 0.5

Computational engine: glmnet 

Model fit template:
glmnet::glmnet(x = missing_arg(), y = missing_arg(), weights = missing_arg(), 
    alpha = 0.5, family = "gaussian")

Für x, y und weights werden Platzhalter eingefügt, die dann im fit-Aufruf aufgefüllt werden.

Im Output sieht man auch, welche Werte für die so genannten Main-Argumente gesetzt sind. parsnip unterscheidet nämlich zwischen denjenigen Parametern, die für viele oder gar alle einem Modelltyp angehörigen Modelle nötig sind und vereinheitlicht deren Aufruf. Diese verpflichtenden Modellparameter, z.B. der Grad der Regularisierung bei der linearen Regression, oder auch die Anzahl der zu trainierenden Bäume in einem Random Forest, werden dann direkt im ersten Modell-Call angegeben.

Dann gibt es aber noch für jede engine spezifische Argumente, deren Setzen für einen Großteil der anderen engines keinen Sinn ergäbe. Diese Engine-arguments können im set_engine-Call übergeben werden. So können wir unserer glmnet-Regression zum Beispiel die (zumindest diskutable) Anweisung auf den Weg geben, die Mietpreise als poisson-verteilt anzunehmen:

glmnet_model <- linear_reg(penalty = 0.95, mixture = 0.5) %>% 
  set_engine('glmnet', family = 'poisson')


glmnet_fit <- glmnet_model %>% 
  fit(price ~ sqft + beds, data = Sacramento)

tidy(glmnet_fit)
# A tibble: 3 × 3
  term         estimate penalty
  <chr>           <dbl>   <dbl>
1 (Intercept) 11.7         0.95
2 sqft         0.000493    0.95
3 beds        -0.0556      0.95

Alle standardmäßig implementierten möglichen Kombinationen von Modelltypen, Engines und Modes können in dieser Tabelle gefunden werden.

Aufgabe

Für diese Aufgaben benötigen Sie wieder den Datensatz der Worldbank, der Import lief so:

library(readxl)
worldbank_indicators <- read_excel("data/worldbank_indicators.xlsx")

Filtern Sie den Datensatz so, dass nur das Jahr 2019 vorliegt.

Fitten Sie die Daten mit zwei linearen Regressionsmodellen, einmal mit lm und einmal mit glm als engine. Setzen Sie für das quasi-Modell die family auf “quasi”. Dabei soll in beiden Fällen die mittlere Lebenserwartung als Kriterium mit dem Alkoholkonsum, dem GDP und dem Zugang zu Elektrizität vorhergesagt werden.

fit_data <- worldbank_indicators %>% 
  filter(Year == 2019) %>% 
  select(Lebenserwartung = `Life expectancy at birth, total (years)`,
        GDP = `GDP per capita (current US$)`,
        Zugang = `Access to electricity (% of population)`,
        Alkoholkonsum = `Total alcohol consumption per capita (liters of pure alcohol, projected estimates, 15+ years of age)`)

lm_model <- linear_reg()

lm_fit <- lm_model %>% 
  fit(Lebenserwartung ~ Zugang + GDP + Alkoholkonsum, data = fit_data)

tidy(lm_fit)
# A tibble: 4 × 5
  term             estimate  std.error statistic p.value
  <chr>               <dbl>      <dbl>     <dbl>   <dbl>
1 (Intercept)   -90.9       75.1          -1.21   0.244 
2 Zugang          1.66       0.758         2.19   0.0437
3 GDP             0.0000978  0.0000366     2.67   0.0167
4 Alkoholkonsum   0.207      0.222         0.931  0.365 
glm_model <- linear_reg() %>% 
  set_engine('glm', family = 'quasi')

glm_fit <- glm_model %>% 
  fit(Lebenserwartung ~ Zugang + GDP + Alkoholkonsum, data = fit_data)

tidy(glm_fit)
# A tibble: 4 × 5
  term             estimate  std.error statistic p.value
  <chr>               <dbl>      <dbl>     <dbl>   <dbl>
1 (Intercept)   -90.9       75.1          -1.21   0.244 
2 Zugang          1.66       0.758         2.19   0.0437
3 GDP             0.0000978  0.0000366     2.67   0.0167
4 Alkoholkonsum   0.207      0.222         0.931  0.365 

Antwort aufdecken

Fitten Sie die Daten mit einem Random Forest mit randomForest als engine und 200 gefitteten Bäumen.

rf_model <- rand_forest(trees = 200) %>% 
  set_mode('regression') %>% 
  set_engine('randomForest')

rf_fit <- rf_model %>% 
  fit(Lebenserwartung ~ Zugang + GDP + Alkoholkonsum, data = fit_data)

rf_fit
parsnip model object


Call:
 randomForest(x = maybe_data_frame(x), y = y, ntree = ~200) 
               Type of random forest: regression
                     Number of trees: 200
No. of variables tried at each split: 1

          Mean of squared residuals: 4.709591
                    % Var explained: 75.31

Antwort aufdecken

Fügen Sie mit predict(<Ihr Modell-fit>, new_data=<Datensatz>) und mutate drei Spalten an Ihren Datensatz an, in denen die jeweiligen Modellvorhersagen angegeben sind. Eventuell müssen Sie das Ergebnis der predict-Funktion mit unlist in einen Vektor überführen.

fit_data <- fit_data %>% 
  mutate(lm = unlist(predict(lm_fit, fit_data)),
         glm = unlist(predict(glm_fit, fit_data)),
         rf = unlist(predict(rf_fit, fit_data)))

Antwort aufdecken

Pivotieren Sie den Datensatz ins long-Format, so dass alle Prognosen in einer Spalte vorliegen.

fit_data <- fit_data %>% 
  pivot_longer(lm:rf,
               values_to = 'Prognose',
               names_to = 'Model')

Antwort aufdecken

Erstellen Sie einen ggplot, der auf der x-Achse die Prognosen und auf der y-Achse die tatsächlichen Werte abträgt. Färben Sie die Punkte nach dem GDP ein, skalieren Sie auc hdie Größe der Punkte nach dem GDP. Facettieren Sie den Plot nach den drei Modellen. Fügen Sie der Grafik mit geom_abline eine schwarze Linie hinzu, die mit einer Steigung von 1 und einem Intercept von 0 eine Diagonale einzeichnet.

fit_data %>% 
  ggplot(aes(x = Prognose, y = Lebenserwartung, color = GDP, size = GDP)) +
  geom_abline(intercept = 0, slope = 1) +
  geom_point() +
  facet_wrap(~Model) 

Antwort aufdecken

Recipes und Workflows

Recipes

Neben dem Erstellen der Modelle bietet tidymodels auch pipeline-Interfaces für das standardisieren von der Vor- und Nachbereitung von Analysen. Das Vorbereiten können dabei relativ aufwändige Aufgaben des feature-Engineerings wie Methoden zur Dimensionsreduktion sein, aber auch für den inferenzstatistischen Alltag relevantere Schritte wie die Logarithmierung oder Dummyfizierung von Variablen.

Das dafür genutzte Paket heißt passenderweise recipes. Wenn wir in unserem Wohnungs-Beispiel vor der Analyse die Prädiktoren standardisieren wollen, fangen wir mit der Definition eines Rezeptes an:

my_recipe <- recipe(price ~ sqft + beds,
                    data = Sacramento)

Das Rezept können wir nun schrittweise ergänzen. Wenn alle Prädiktoren und vielleicht auch das Kriterium standardisieren wollen, müssen wir zuerst die Daten zentrieren und dann skalieren.

Diese Schritte sind in recipes mit den angemessen als step-Funktionen benannten Anweisungen implementiert. Für die Schritte muss jeweils angegeben werden, auf welche Teile der Designmatrix die Operationen angewandt werden sollen.

Dazu können wir die schon bekannten tidy-select-helper nutzen, wir können aber auch die sepzifischen, von recipes gelieferten Auswahl-Helfer genutzt werden. Auf der mit ?selections aufrufbaren Hifleseite sind alle aufgelistet.

Wir wollen wie gesagt alle numerischen Variablen z-transformieren, ergänzen unser Rezept also wie folgt:

my_recipe <- recipe(price ~ sqft + beds,
                    data = Sacramento) %>% 
  step_center(all_numeric()) %>% 
  step_scale(all_numeric())

Wenn wir das Recipe aufrufen, wird uns eine Zusammenfassung unserer Vorbereitungsschritte angezeigt:

my_recipe
── Recipe ──────────────────────────────────────────────────────────────────────
── Inputs 
Number of variables by role
outcome:   1
predictor: 2
── Operations 
• Centering for: all_numeric()
• Scaling for: all_numeric()

Neben diesen Einfachen steps gibt es natürlich auch wesentlich kompliziertere, unter diesem Link sind alle implementierten Steps aufgelistet.

Die für das Rezept nötigen “Zutaten” können wir uns mit prep vorbereiten lassen:

my_prep <- my_recipe %>% 
  prep(Sacramento)

my_prep
── Recipe ──────────────────────────────────────────────────────────────────────
── Inputs 
Number of variables by role
outcome:   1
predictor: 2
── Training information 
Training data contained 932 data points and no incomplete rows.
── Operations 
• Centering for: sqft, beds, price | Trained
• Scaling for: sqft, beds, price | Trained

Und mit bake können wir das Rezept abschließend anwenden:

scaled_data <- my_prep %>% 
  bake(Sacramento)

scaled_data
# A tibble: 932 × 3
     sqft   beds price
    <dbl>  <dbl> <dbl>
 1 -1.16  -1.44  -1.43
 2 -0.707 -0.311 -1.36
 3 -1.22  -1.44  -1.36
 4 -1.14  -1.44  -1.35
 5 -1.22  -1.44  -1.26
 6 -0.769 -0.311 -1.20
 7 -0.794 -0.311 -1.19
 8 -0.693 -0.311 -1.19
 9 -1.02  -1.44  -1.16
10 -0.736 -0.311 -1.13
# ℹ 922 more rows

Diesen Datensatz können wir dann wieder nutzen, um eine Regression zu fitten:

lm_model <- linear_reg() 


lm_fit <- lm_model %>% 
  fit(price ~ sqft + beds, data = scaled_data)

lm_fit %>% 
  tidy()
# A tibble: 3 × 5
  term         estimate std.error statistic   p.value
  <chr>           <dbl>     <dbl>     <dbl>     <dbl>
1 (Intercept)  3.04e-16    0.0206  1.47e-14 1.00e+  0
2 sqft         8.94e- 1    0.0296  3.02e+ 1 2.95e-140
3 beds        -1.76e- 1    0.0296 -5.96e+ 0 3.53e-  9

Workflows

Rezepte, Modelldefinition und -fit lassen sich im tidymodels-framework auch zu einer pipeline zusammenfügen.

Wie bei parsnip und recipes beginnt die Definition eines sogenannten Workflows aus workflows mit einer Objektdefinition.

my_wf <- workflow()

Diesem Workflow können wir dann Rezept und Modell hinzufügen:

my_wf <- my_wf %>% 
  add_recipe(my_recipe) %>% 
  add_model(lm_model)

Anschließend können wir den ganzen Workflow fitten:

my_wf %>% 
  fit(data = Sacramento) %>% 
  tidy()
# A tibble: 3 × 5
  term         estimate std.error statistic   p.value
  <chr>           <dbl>     <dbl>     <dbl>     <dbl>
1 (Intercept)  3.04e-16    0.0206  1.47e-14 1.00e+  0
2 sqft         8.94e- 1    0.0296  3.02e+ 1 2.95e-140
3 beds        -1.76e- 1    0.0296 -5.96e+ 0 3.53e-  9

Wozu ist das jetzt nützlich?

Nützlich wird das, wenn wir eine Analyse z.B. auf mehreren Datensätzen durchführen wollen.

Oder einen Workflow einmal mit und einmal ohne Logarithmierung des Kriteriums ausprobieren wollen.

Mit update_recipe können wir das ganz einfach testen:

my_wf %>%
  update_recipe(my_recipe %>%  step_log(all_outcomes())) %>% 
  fit(data = Sacramento) %>% 
  tidy()
Warning in bake.step_log(x$steps[[i]], new_data = training): NaNs produced
# A tibble: 3 × 5
  term        estimate std.error statistic  p.value
  <chr>          <dbl>     <dbl>     <dbl>    <dbl>
1 (Intercept)   -1.09     0.0724    -15.1  2.51e-40
2 sqft           0.803    0.0746     10.8  1.20e-23
3 beds          -0.303    0.0806     -3.76 2.01e- 4

Außerdem lässt sich natürlich das Modell austauschen, wenn ich das möchte:

my_wf %>%
  update_model(keras_model) %>% 
  fit(data = Sacramento)
Epoch 1/20
30/30 - 1s - loss: 0.7295 - 818ms/epoch - 27ms/step
Epoch 2/20
30/30 - 0s - loss: 0.5119 - 97ms/epoch - 3ms/step
Epoch 3/20
30/30 - 0s - loss: 0.4602 - 58ms/epoch - 2ms/step
Epoch 4/20
30/30 - 0s - loss: 0.4416 - 56ms/epoch - 2ms/step
Epoch 5/20
30/30 - 0s - loss: 0.4305 - 50ms/epoch - 2ms/step
Epoch 6/20
30/30 - 0s - loss: 0.4273 - 49ms/epoch - 2ms/step
Epoch 7/20
30/30 - 0s - loss: 0.4260 - 54ms/epoch - 2ms/step
Epoch 8/20
30/30 - 0s - loss: 0.4209 - 52ms/epoch - 2ms/step
Epoch 9/20
30/30 - 0s - loss: 0.4213 - 52ms/epoch - 2ms/step
Epoch 10/20
30/30 - 0s - loss: 0.4174 - 50ms/epoch - 2ms/step
Epoch 11/20
30/30 - 0s - loss: 0.4158 - 46ms/epoch - 2ms/step
Epoch 12/20
30/30 - 0s - loss: 0.4149 - 47ms/epoch - 2ms/step
Epoch 13/20
30/30 - 0s - loss: 0.4154 - 46ms/epoch - 2ms/step
Epoch 14/20
30/30 - 0s - loss: 0.4116 - 46ms/epoch - 2ms/step
Epoch 15/20
30/30 - 0s - loss: 0.4115 - 53ms/epoch - 2ms/step
Epoch 16/20
30/30 - 0s - loss: 0.4090 - 43ms/epoch - 1ms/step
Epoch 17/20
30/30 - 0s - loss: 0.4082 - 41ms/epoch - 1ms/step
Epoch 18/20
30/30 - 0s - loss: 0.4105 - 45ms/epoch - 2ms/step
Epoch 19/20
30/30 - 0s - loss: 0.4083 - 46ms/epoch - 2ms/step
Epoch 20/20
30/30 - 0s - loss: 0.4079 - 47ms/epoch - 2ms/step
══ Workflow [trained] ══════════════════════════════════════════════════════════
Preprocessor: Recipe
Model: linear_reg()

── Preprocessor ────────────────────────────────────────────────────────────────
2 Recipe Steps

• step_center()
• step_scale()

── Model ───────────────────────────────────────────────────────────────────────
Model: "sequential"
________________________________________________________________________________
 Layer (type)                       Output Shape                    Param #     
================================================================================
 dense (Dense)                      (None, 128)                     384         
 dense_1 (Dense)                    (None, 1)                       129         
================================================================================
Total params: 513 (2.00 KB)
Trainable params: 513 (2.00 KB)
Non-trainable params: 0 (0.00 Byte)
________________________________________________________________________________

Aufgabe

Sie benötigen wieder den Datensatz der Worldbank. Erstellen Sie damit zwei Datensätze, einen mit den Daten zum Jahr 2019 und einen zum Jahr 2015. Beide Datensätze sollen die Gesamt-Lebenserwartung als Spalte mit dem Namen “target” enthalten, außerdem alle Variablen mit Ausnahme der Jahreszahl und der spezifischen Lebenserwartungen.

worldbank_indicators <- read_excel("data/worldbank_indicators.xlsx")

fit_data <- worldbank_indicators %>% 
  select(target = `Life expectancy at birth, total (years)`,
         everything(),
         - matches('ale'))

fit_data_2015 <- fit_data %>% 
  filter(Year == 2015) %>% 
  select(-Year)

fit_data_2019 <- fit_data %>% 
  filter(Year == 2019) %>% 
  select(-Year)

Antwort aufdecken

Erstellen Sie einen Workflow, mit dem für alle numerische Variablen alle fehlenden Werte durch die Spalten-Mittelwerte ersetzt werden. Außerdem sollen alle numerischen Werte auf einen Wertebereich von 0 bis 1 skaliert werden. Die Liste aller Steps kann Ihnen dabei helfen. Entfernen Sie außerdem mit step_zv alle Spalten, die nur einen Wert enthalten. Der Workflow soll dann alle Variablen als Kriterium nutzen um die Variable target vorherzusagen. Anfänglich soll der Workflow eine lm-engine dazu nutzen.

my_recipe <- recipe(target ~ ., data = fit_data_2015) %>% 
  step_impute_mean(all_numeric_predictors()) %>% 
  step_range(all_numeric_predictors()) %>% 
  step_zv(all_predictors())

lm_model <- linear_reg() 

my_wf <- workflow() %>% 
  add_recipe(my_recipe) %>% 
  add_model(lm_model)

Antwort aufdecken

Fitten Sie den Workflow auf die Datensätze zu den Jahren 2015 und 2019.

lm_fit_2015 <- my_wf %>% 
  fit(data = fit_data_2015)
Warning: Column `Electric power consumption (kWh per capita)` returned NaN. Consider
using `step_zv()` to remove variables containing only a single value.
lm_fit_2019 <- my_wf %>% 
  fit(data = fit_data_2019)
Warning: Column `Electric power consumption (kWh per capita)` returned NaN. Consider
using `step_zv()` to remove variables containing only a single value.

Antwort aufdecken

Tauschen Sie das Modell gegen einen randomForest mit Regressions-Mode aus und fitten Sie wieder beide Modelle.

rf_model <- rand_forest() %>% 
  set_mode('regression') %>% 
  set_engine('randomForest')

my_wf <- my_wf %>% 
  update_model(rf_model)

rf_fit_2015 <- my_wf %>% 
  fit(data = fit_data_2015)
Warning: Column `Electric power consumption (kWh per capita)` returned NaN. Consider
using `step_zv()` to remove variables containing only a single value.
rf_fit_2019 <- my_wf %>% 
  fit(data = fit_data_2019)
Warning: Column `Electric power consumption (kWh per capita)` returned NaN. Consider
using `step_zv()` to remove variables containing only a single value.

Antwort aufdecken

Erstellen Sie einen Datensatz mit allen Prognosen und targets. Pivotieren Sie diesen So, dass die targets in einer und die Prognosen in einer zweiten Spalte stehen. Nutzen Sie dazu zuerst das names_pattern-Argument der pivot_longer-Funktion und den .value-Platzhalter des names_to-Arguments. Anschließend müssen Sie vielleicht ein zweites Mal pivotieren. Lesen Sie die Hilfeseite der mape-Funktion aus dem yardstick Paket und nutzen Sie diese um die Modelle oberflächlich zu vergleichen.

results = tibble(target_2015 = fit_data_2015$target,
                 target_2019 = fit_data_2019$target,
                 lm_2015 = unlist(predict(lm_fit_2015, fit_data_2015)),
                 lm_2019 = unlist(predict(lm_fit_2019, fit_data_2019)),
                 rf_2015 = unlist(predict(rf_fit_2015, fit_data_2015)),
                 rf_2019 = unlist(predict(rf_fit_2019, fit_data_2019))) %>% 
  pivot_longer(everything(),
               names_pattern = '(.+)_(.+)',
               names_to = c('.value', 'year')) %>% 
  pivot_longer(c(lm, rf))
Warning in predict.lm(object = object$fit, newdata = new_data, type =
"response", : prediction from rank-deficient fit; consider predict(.,
rankdeficient="NA")

Warning in predict.lm(object = object$fit, newdata = new_data, type =
"response", : prediction from rank-deficient fit; consider predict(.,
rankdeficient="NA")
results %>% 
  group_by(name) %>% 
  mape(target, value)
# A tibble: 2 × 4
  name  .metric .estimator .estimate
  <chr> <chr>   <chr>          <dbl>
1 lm    mape    standard    5.33e-14
2 rf    mape    standard    1.44e+ 0

Antwort aufdecken

Inferenzstatistik-Pipelines mit infer

Neben der Kombination von parsnip und broom bietet tidymodels mit infer ein weiteres Paket für den inferenzstatistischen Einsatz.

infer stellt dabei ein einfaches Interface zur Formulierung von Hypothesen und deren Testung mit Bootstrapping, Permutationstests und anderen Randomisierungsverfahren zur Verfügung.

Der Ablauf sieht dabei immer wie in Abbildung 7.1 aus. Zuerst wird/werden mit specify die Variablen spezifiziert, die von Interesse sind. Daraufhin wird mit hypothesize in Textform angegeben, welche Nullhypothese man über diese Variablen testen will. Mit generate werden anschließend Daten generiert, die dieser Nullhypothese entsprechen. Abschließend kann mit calculate aus den generierten Werten eine Verteilung berechnet werden, die mit den beobachteten Werten verglichen werden kann.

Abb 7.1: Infer-Workflow - von https://infer.tidymodels.org/

Wir könnten zum Beispiel die Annahme haben, dass die Art der Mietwohnungen unterschiedlich in den verschiedenen Städten verteilt sind. Die uns interessierenden Variablen sind also type und city. Das legen wir zuallererst mit specify fest:

Sacramento %>% 
  specify(type ~ city)
Response: type (factor)
Explanatory: city (factor)
# A tibble: 932 × 2
   type        city          
   <fct>       <fct>         
 1 Residential SACRAMENTO    
 2 Residential SACRAMENTO    
 3 Residential SACRAMENTO    
 4 Residential SACRAMENTO    
 5 Residential SACRAMENTO    
 6 Condo       SACRAMENTO    
 7 Residential SACRAMENTO    
 8 Residential SACRAMENTO    
 9 Condo       RANCHO_CORDOVA
10 Residential RIO_LINDA     
# ℹ 922 more rows

Unsere Nullhypothese ist unabhängigkeit, die können wir also direkt hinzufügen:

Sacramento %>% 
  specify(type ~ city) %>% 
  hypothesize('independence')
Response: type (factor)
Explanatory: city (factor)
Null Hypothesis: independence
# A tibble: 932 × 2
   type        city          
   <fct>       <fct>         
 1 Residential SACRAMENTO    
 2 Residential SACRAMENTO    
 3 Residential SACRAMENTO    
 4 Residential SACRAMENTO    
 5 Residential SACRAMENTO    
 6 Condo       SACRAMENTO    
 7 Residential SACRAMENTO    
 8 Residential SACRAMENTO    
 9 Condo       RANCHO_CORDOVA
10 Residential RIO_LINDA     
# ℹ 922 more rows

Um nun Daten für den Test zu generieren, können wir 1000 Datensätze mit Permuationen der Reihenfolge einer der Variablen generieren:

Sacramento %>% 
  specify(type ~ city) %>% 
  hypothesize('independence') %>% 
  generate(reps = 1000, type = 'permute')
Response: type (factor)
Explanatory: city (factor)
Null Hypothesis: independence
# A tibble: 932,000 × 3
# Groups:   replicate [1,000]
   type        city           replicate
   <fct>       <fct>              <int>
 1 Residential SACRAMENTO             1
 2 Residential SACRAMENTO             1
 3 Residential SACRAMENTO             1
 4 Residential SACRAMENTO             1
 5 Residential SACRAMENTO             1
 6 Residential SACRAMENTO             1
 7 Residential SACRAMENTO             1
 8 Residential SACRAMENTO             1
 9 Residential RANCHO_CORDOVA         1
10 Residential RIO_LINDA              1
# ℹ 931,990 more rows

Zuletzt müssen wir noch eine Teststatistik berechnen, deren Verteilung wir betrachten können. Bei unserem Fall bietet sich natürlich \(\chi^2\) an:

Sacramento %>% 
  specify(type ~ city) %>% 
  hypothesize('independence') %>% 
  generate(reps = 1000, type = 'permute') %>% 
  calculate('Chisq')
Response: type (factor)
Explanatory: city (factor)
Null Hypothesis: independence
# A tibble: 1,000 × 2
   replicate  stat
       <int> <dbl>
 1         1  26.8
 2         2 121. 
 3         3  46.1
 4         4  61.5
 5         5  96.6
 6         6  36.0
 7         7  98.2
 8         8  42.0
 9         9  68.1
10        10  71.8
# ℹ 990 more rows

Die Verteilung können wir jetzt mit mitgelieferten ggplot2-Wrappern darstellen:

Sacramento %>% 
  specify(type ~ city) %>% 
  hypothesize('independence') %>% 
  generate(reps = 1000, type = 'permute') %>% 
  calculate('Chisq') %>% 
  visualize()

Und wenn wir die beobachtete Teststatistik berechnen, können wir auch eine Signifikanz-Aussage tätigen:

obs_chisq <- Sacramento %>% 
  specify(type ~ city) %>% 
  calculate('Chisq')
  
Sacramento %>% 
  specify(type ~ city) %>% 
  hypothesize('independence') %>% 
  generate(reps = 1000, type = 'permute') %>% 
  calculate('Chisq') %>% 
  visualize() +
  shade_p_value(obs_stat = obs_chisq, direction = "greater")

Aufgabe

Für diese Aufgabe benötigen Sie den attrition-Datensatz, der mit tidymodels geliefert und geladen wird.

Verschaffen Sie sich einen Überblick über den Datensatz. Wir benötigen die Variable Gender und die Variable HourlyRate.

Stellen Sie eine specify -> hypothesize -> generate-Pipeline für einen t-Test auf den Unterschied in HourlyRate zwischen den Auspärgungen von Gender auf. Lesen Sie die Hilfeseiten von hypothesize und generate um den richtigen Begriff für die angemessene Nullhypothese und das angemessene Sampling-Vorgehen zu bestimmen.

hypothesis <- attrition %>% 
  specify(HourlyRate ~ Gender) %>% 
  hypothesize('independence') %>% 
  generate(reps = 1000, type = 'bootstrap')

Antwort aufdecken

Berechnen Sie mit calculate den empirischen t-Wert und vergleichen Sie ihn mit visualize mit der simulierten Verteilung.

simulated <- attrition %>% 
  specify(HourlyRate ~ Gender) %>% 
  hypothesize('independence') %>% 
  generate(reps = 1000, type = 'permute') %>% 
  calculate('t', order = c('Male', 'Female'))

observed <- attrition %>% 
  specify(HourlyRate ~ Gender) %>% 
  calculate('t', order = c('Male', 'Female'))

simulated %>% 
  visualize() +
  shade_p_value(obs_stat = observed, direction = "two.sided")

Antwort aufdecken