R Recap — the Tidyverse Way

Applied Data Science - Session 2

Prof. Dr. Claudius Gräbner-Radkowitsch

Europa-Universität Flensburg

2026-09-21

Debrief

Last week

  • R is running on every laptop — assumed from here on
  • The tutorial: worked through
  • The forum: no questions posed

So: exercises done, everything clear.

Post what is genuinely open

Dragged-along problems accumulate — and end in frustration.

What the diagnostic told me

  • Three answers came back — too few to read anything into
  • p-values, confidence intervals, uncertainty appear to be an issue

Please complete the survey this week

Too few responses — I cannot see where to start.

Today’s question

Back to the puzzle

Is the centre expensive because it is central — or because that is where the good hotels are?

What you need to be able to do

  • Pick out the rows that matter
  • Create a variable that does not exist yet
  • Group by something
  • Summarise each group
  • Sort the result

Five verbs at the bottom of 90% of data preparation!

A quick recap of R itself

You have seen this before

  • R in Statistik I/II
  • Same operations, different writing
hotels$price[hotels$stars == 5]   # what you learned
filter(hotels, stars == 5)        # what we write today
  • Dialects: base · tidyverse · data.table

What can an object be?

“Everything that exists is an object, everything that happens is a function call”.

– John Chambers

Vectors Functions Atomic vectors Lists logical integer double character numeric
  • Today: the highlighted branch

The four atomic types

price <- 240
name  <- "Hotel Sacher"
central <- TRUE
Type Example What it is
double 240, 3.5, 7.0 decimal numbers
integer 7L, 120L whole numbers
character "Hotel Sacher" text, always in quotes
logical TRUE, FALSE yes/no

typeof() tells you what is stored in memory, class() the high-level type.

Why types bite you

"240" + 10
Error in `"240" + 10`:
! non-numeric argument to binary operator
  • "240" — text that looks like a number
  • One stray n/a in an Excel export → whole column arrives as text

Vectors and vectorization

  • atomic (c()) vectors: all elements are of the same type
  • Attractive because they allow for vectorization:
prices <- c(240, 180, 95, 310)
prices * 1.19
[1] 285.60 214.20 113.05 368.90

Vectorization

One operation, every element at once — no loop.

  • Does not work for lists: R needs to guess type for each of its elements

Data frames

data frame: rows = observations, columns = variables

  • Special list where every element is an atomic vector of the same length
df <- data.frame("a"=c(1,2), "b"= c("a", "b"))
typeof(df)
[1] "list"
class(df)
[1] "data.frame"

Reading a file

hotels <- read_csv("data/hotels-vienna.csv")
  • read_csv() — comma-separated files, the common case
  • read_csv2() — semicolons and decimal commas, i.e. German Excel exports
  • readxl::read_excel() — .xlsx directly

package::function()

Reaches into a package without library(). Worth it for one function used once.

Look before you use it

glimpse() — ninety percent of import bugs show in the first three rows.

When a number is really a category

count(hotels, stars)
# A tibble: 8 × 2
  stars     n
  <dbl> <int>
1   1       1
2   2      47
3   2.5     5
4   3     140
5   3.5    57
6   4     143
7   4.5     8
8   5      27
  • Half stars → eight groups, several far too small
  • stars is a number → R will report a mean of 3.43
  • a rating no hotel has

Fix both at once: round, then label

star_tier <- factor(
  floor(hotels$stars + 0.5),      # round half UP
  levels = 1:5,
  labels = paste(1:5, "stars")    # nice labels
)
# A tibble: 5 × 2
  star_tier     n
  <fct>     <int>
1 1 stars       1
2 2 stars      47
3 3 stars     145
4 4 stars     200
5 5 stars      35

Eight ragged groups become five.

Careful: round() does not do what you think

round(2.5)
[1] 2
round(3.5)
[1] 4
round(4.5)
[1] 4

Banker’s rounding

Halves go to the nearest even number. Silent, and inconsistent for binning.

floor(x + 0.5) rounds halves up, every time.

Why a factor, and not just a number?

  1. No accidental mean
  2. Order fixed — 1, 2, 3, 4, 5, not alphabetical
  3. R knows which categories could exist

The five verbs

The tidyverse idea

Almost everything you do to a table is one of five things:

Verb What it does
select() keep some columns
filter() keep some rows
mutate() add a new column
arrange() sort the rows
summarise() collapse rows to a single value

Plus group_by(), which makes summarise() and mutate() work per group.

select() — keep some columns

select(hotels, hotel_id, price, distance, stars)
# A tibble: 428 × 4
   hotel_id price distance stars
      <dbl> <dbl>    <dbl> <dbl>
 1    21894    81      2.7     4
 2    21897    81      1.7     4
 3    21901    85      1.4     4
 4    21902    83      1.7     3
 5    21903    82      1.2     4
 6    21904   229      0.9     5
 7    21906   103      0.9     4
 8    21907   150      1       4
 9    21908    80      0.7     2
10    21910   153      1.5     3
# ℹ 418 more rows

Four columns out of 24. Nothing is deleted — the result is a new table.

filter() — keep some rows

hotels_small <- select(hotels, hotel_id, price, distance, stars)
filter(hotels_small, stars == 5)
# A tibble: 27 × 4
   hotel_id price distance stars
      <dbl> <dbl>    <dbl> <dbl>
 1    21904   229      0.9     5
 2    21967   299      0.1     5
 3    21969   513      0.3     5
 4    21976   806      0.3     5
 5    21989   261      0.3     5
 6    21999   203      0.3     5
 7    22006   544      0.5     5
 8    22007   252      0.5     5
 9    22008   323      0.5     5
10    22020   200      0.4     5
# ℹ 17 more rows

== equal · != not equal · > < >= <= · , and · | or

= is not ==

= assigns, == compares. Mixing them up is a rite of passage.

The pipe: |>

Two steps cost us a junk object, hotels_small. Two ways to avoid it.

Nest — and read inside-out:

filter(select(hotels, hotel_id, price, distance, stars), stars == 5)

Pipe — and read top to bottom:

hotels |>
  select(hotel_id, price, distance, stars) |>
  filter(stars == 5)

Read |> as “and then”. Shortcut: Ctrl/Cmd + Shift + M.

mutate() — add a column

hotels |>
  mutate(price_per_star = price / stars) |>
  select(price, stars, price_per_star)
# A tibble: 428 × 3
   price stars price_per_star
   <dbl> <dbl>          <dbl>
 1    81     4           20.2
 2    81     4           20.2
 3    85     4           21.2
 4    83     3           27.7
 5    82     4           20.5
 6   229     5           45.8
 7   103     4           25.8
 8   150     4           37.5
 9    80     2           40  
10   153     3           51  
# ℹ 418 more rows

Nothing is lost

Computed row by row — nothing is overwritten unless you reuse a name.

This is also how star_tier becomes a column.

Empty groups vanish silently

group_by(band)                 # empty bands disappear
group_by(band, .drop = FALSE)  # empty bands show up with n = 0

Only a factor can bring them back

A category with zero observations looks exactly like one you never had.

group_by() + summarise()

only_hotels |>
  group_by(star_tier) |>
  summarise(n = n(), median_price = median(price))
# A tibble: 5 × 3
  star_tier     n median_price
  <fct>     <int>        <dbl>
1 1 stars       1         81  
2 2 stars      14         66.5
3 3 stars      84         84  
4 4 stars     125        115  
5 5 stars      29        226  

Nine tenths of business reporting is a group_by() then a summarise()

Reading a pipeline out loud

hotels |>
  filter(city_actual == "Vienna") |>
  group_by(accommodation_type) |>
  summarise(median_price = median(price)) |>
  arrange(desc(median_price))

Take the hotels, and then keep only the ones actually in Vienna, and then group by type of accommodation, and then work out the median price in each group, and then sort from most to least expensive.

If you can say it, you can write it.

Two things that might cause you trouble

Missing values

mean(hotels$rating)
[1] NA
  • NA: we do not know.
  • The average of something that includes an unknown is unknown.
mean(hotels$rating, na.rm = TRUE)
[1] 3.970992

na.rm = TRUE

Discards, does not fix. Fine if missing at random. Always report how many you dropped.

Groups too small to compare

How much more can a central five-star hotel charge?

only_hotels |>
  filter(star_tier == "5 stars") |>
  group_by(band, .drop = FALSE) |>
  summarise(n = n(), median_price = median(price), .groups = "drop")
# A tibble: 4 × 3
  band       n median_price
  <fct>  <int>        <dbl>
1 <1 km     25         252 
2 1-2 km     2         114.
3 2-3 km     2         128 
4 >3 km      0          NA 

Look at n before you look at the price.

The answer is: we cannot say

  • 25 of 29 five-star hotels within one kilometre
  • The outer bands: 2, 2, 0
  • A median over two hotels — two buildings, not a pattern

Always print n next to your summary statistic

A median from two observations looks exactly like a median from two hundred.

Live coding

Now me

We answer the opening question together:

  1. Clean the data — the problems we found last week
  2. Turn the half stars into tiers
  3. Establish the raw distance effect
  4. Show that stars are tangled up in it
  5. Compare like with like, and see what survives
  6. Try the same for five-star hotels — and find out we cannot

Step by step

Build pipelines line by line. Run, look, add the next line.

Exercise

Your task

Four business questions about the Vienna hotel market, answered with the five verbs — then one question of your own.

Write it up as a Quarto report with a chart at the end.

Take-home until session 3

Session 3: Quarto properly — and Report 1 is issued at the end of it.