Travel on Tap: How R Can Help Recommend Us a Beer While On The Road

A data science walkthrough — missing data, cosine similarity, and a Shiny app for travelling beer lovers

R
Shiny
data-science
beer
Author

Peter Fortunato

Published

March 17, 2026

The Idea

I travel a fair amount, and one of my favorite things to do in a new city is walk into a craft brewery and order something local. The problem: I never know what to order. I know I like Boulevard Wheat and Stella Artois — light, low-bitterness, easy-drinking — but I have no idea whether the brewery’s flagship IPA is going to knock me sideways or whether their wheat ale is worth trying.

So I built Travel On Tap: a Shiny app that takes your taste preferences (alcohol level, bitterness, style) and recommends similar craft beers available in whatever U.S. state you’re visiting. This post walks through the data science behind it — including what turned out to be a genuinely interesting missing data problem.


The Data

The backbone of this project is the craft-beers-dataset by nickhould, scraped from a canned craft beer catalogue circa 2017. It ships as two CSVs that join on brewery_id:

  • beers.csv — ~2,410 rows: beer name, style, ABV (alcohol by volume), IBU (bitterness), brewery ID
  • breweries.csv — ~558 rows: brewery name, city, U.S. state

A few things to know upfront about this dataset’s sampling frame — something worth always establishing before you start modelling:

  • It’s canned craft beer only. Major commercial brands (Stella Artois, Boulevard Wheat, most imports) are absent.
  • It’s a 2017 snapshot. Breweries that opened or closed since then won’t be reflected.
  • It skews toward independent U.S. craft breweries, which is exactly what we want for a travel recommendation tool.
Show code
beers <- read_csv(
  "https://raw.githubusercontent.com/nickhould/craft-beers-dataset/master/data/processed/beers.csv",
  show_col_types = FALSE
)

breweries <- read_csv(
  "https://raw.githubusercontent.com/nickhould/craft-beers-dataset/master/data/processed/breweries.csv",
  show_col_types = FALSE
)

Joining the tables

The join requires a little care. Both CSVs have columns named name and id, but they mean different things in each table. Rename before joining to avoid silent column collisions:

Show code
df <- beers |>
  rename(beer_name = name, beer_id = id) |>
  left_join(
    breweries |> rename(brewery_name = name),
    by = c("brewery_id" = "id")
  ) |>
  select(!c("...1.x", "...1.y")) |>
  mutate(
    state = str_trim(state),
    style = str_trim(style)
  )

A quick missingness check confirms the join worked — city and state are fully populated:

Show code
DataExplorer::profile_missing(df)
# A tibble: 10 × 3
   feature      num_missing pct_missing
   <fct>              <int>       <dbl>
 1 abv                   62     0.0257 
 2 ibu                 1005     0.417  
 3 beer_id                0     0      
 4 beer_name              0     0      
 5 style                  5     0.00207
 6 brewery_id             0     0      
 7 ounces                 0     0      
 8 brewery_name           0     0      
 9 city                   0     0      
10 state                  0     0      

The Missing Data Problem

Right away, one column stands out: IBU is 41.7% missing.

That’s not a rounding error or a data entry oversight — it’s a structural feature of the dataset. Before imputing a single value, the right move is to diagnose why it’s missing. There are three possibilities, and they lead to very different solutions:

Mechanism What it means Implication
MCAR — Missing Completely at Random Missingness is unrelated to any variable Simple mean/median imputation is unbiased
MAR — Missing at Random Missingness depends on observed variables Impute using the variables it depends on
MNAR — Missing Not at Random Missingness depends on the missing value itself Needs careful modelling; can’t be fully fixed

We ran three diagnostic checks.

Check 1: Is missingness associated with beer style?

Show code
ibu_missing_by_style <- df |>
  mutate(ibu_missing = is.na(ibu)) |>
  group_by(style) |>
  summarise(
    n_beers       = n(),
    n_missing_ibu = sum(ibu_missing),
    pct_missing   = mean(ibu_missing)
  ) |>
  arrange(desc(pct_missing))

ibu_missing_by_style |> print(n = 20)
# A tibble: 100 × 4
   style                            n_beers n_missing_ibu pct_missing
   <chr>                              <int>         <int>       <dbl>
 1 American Malt Liquor                   1             1       1    
 2 Braggot                                1             1       1    
 3 Cider                                 37            37       1    
 4 Flanders Red Ale                       1             1       1    
 5 Kristalweizen                          1             1       1    
 6 Low Alcohol Beer                       1             1       1    
 7 Mead                                   5             5       1    
 8 Rauchbier                              2             2       1    
 9 Shandy                                 3             3       1    
10 Belgian IPA                           18            15       0.833
11 Belgian Strong Dark Ale                6             5       0.833
12 Light Lager                           12             9       0.75 
13 Quadrupel (Quad)                       4             3       0.75 
14 Belgian Dark Ale                      11             8       0.727
15 Belgian Strong Pale Ale                7             5       0.714
16 Bière de Garde                         7             5       0.714
17 Doppelbock                             7             5       0.714
18 American Double / Imperial Stout       9             6       0.667
19 American Wild Ale                      6             4       0.667
20 Belgian Pale Ale                      24            16       0.667
# ℹ 80 more rows

The pattern is immediate and informative. Cider, Mead, Shandy, and Braggot are 100% missing — but these aren’t really beers in the traditional sense, so IBU is conceptually inapplicable. Beyond those, Belgian styles, light lagers, and specialty styles show 70–83% missingness.

This is a MAR signal: missingness is systematically related to the style variable, which we can observe.

Check 2: Is missingness associated with ABV?

Show code
df |>
  mutate(ibu_missing = is.na(ibu)) |>
  group_by(ibu_missing) |>
  summarise(
    n          = n(),
    mean_abv   = mean(abv, na.rm = TRUE),
    median_abv = median(abv, na.rm = TRUE)
  )
# A tibble: 2 × 4
  ibu_missing     n mean_abv median_abv
  <lgl>       <int>    <dbl>      <dbl>
1 FALSE        1405   0.0599      0.057
2 TRUE         1005   0.0596      0.056

This is almost perfectly flat — the mean ABV difference between missing and non-missing groups is less than 0.0003. ABV does not predict IBU missingness. This rules out a simple IBU ~ ABV regression imputation, since ABV adds essentially no information once style is accounted for.

Check 3: Is missingness associated with geography?

Show code
df |>
  mutate(ibu_missing = is.na(ibu)) |>
  group_by(state) |>
  summarise(
    n_beers     = n(),
    pct_missing = mean(ibu_missing)
  ) |>
  arrange(desc(pct_missing)) |>
  print(n = 15)
# A tibble: 51 × 3
   state n_beers pct_missing
   <chr>   <int>       <dbl>
 1 SD          7       1    
 2 AR          5       0.8  
 3 CT         27       0.778
 4 MI        162       0.765
 5 NH          8       0.75 
 6 ME         27       0.741
 7 SC         14       0.643
 8 NE         25       0.64 
 9 UT         26       0.577
10 IL         91       0.571
11 NM         14       0.571
12 GA         16       0.562
13 PA        100       0.53 
14 MD         21       0.524
15 WI         87       0.517
# ℹ 36 more rows

Some states look alarming — South Dakota is 100% missing, Michigan is 76%. But this is almost certainly confounding: states that happen to have more Belgian or specialty-style breweries will look like geography matters when style is actually the driver.

Diagnosis: MAR by style group

The evidence points clearly to Missing At Random, primarily by style. That has a concrete implication: if we impute IBU using style-group medians, we’re using exactly the variable that explains the missingness pattern. This is principled, not just convenient.

Three approaches we considered:

Approach Verdict
Global mean/median imputation ❌ Ignores style — a high-IBU IPA would get the same fill as a low-IBU wheat ale
mice / multiple imputation ❌ Overkill — ABV has no marginal predictive value, so there’s nothing for a model to learn
Style-group median imputation ✅ Simple, defensible, matches the missingness mechanism

One honest caveat: median imputation collapses within-group variance — every imputed beer in a style group gets the same IBU value. For a recommender, this is fine. For inference (e.g. a hypothesis test about IBU), it would introduce bias. Know your downstream use case before choosing an imputation strategy.


Data Cleaning

Remove non-beer styles

Cider, Mead, Shandy, Braggot, Low Alcohol Beer, and American Malt Liquor are removed — IBU is not meaningful for these categories, and they’d pollute beer-to-beer similarity calculations.

Show code
non_beers <- c("Cider", "Mead", "Shandy", "Braggot",
               "Low Alcohol Beer", "American Malt Liquor")

df_clean <- df |>
  filter(!style %in% non_beers, !is.na(style))

Build style groups

The raw style column has ~100 distinct values. For the recommender, we collapse these into 10 interpretable groups using str_detect() on keywords:

Show code
df_clean <- df_clean |>
  mutate(style_group = case_when(
    str_detect(style, "IPA|India Pale")                       ~ "IPA",
    str_detect(style, "Stout|Porter")                         ~ "Stout / Porter",
    str_detect(style, "Wheat|Wit|Hefeweizen|Weizen")          ~ "Wheat / Wit",
    str_detect(style, "Lager|Pilsner|Pilsener|Kölsch|Kolsch") ~ "Lager / Pilsner",
    str_detect(style, "Pale Ale|APA")                         ~ "Pale Ale",
    str_detect(style, "Amber|Red Ale|Red Lager")              ~ "Amber / Red",
    str_detect(style, "Sour|Lambic|Gose|Berliner")            ~ "Sour / Wild",
    str_detect(style, "Saison|Farmhouse|Belgian")             ~ "Belgian / Saison",
    str_detect(style, "Barleywine|Imperial|Strong")           ~ "Strong / Barleywine",
    str_detect(style, "Blonde|Golden")                        ~ "Blonde / Golden",
    TRUE                                                       ~ "Other"
  ))

Impute IBU and ABV

Show code
# IBU — style-group median
style_ibu_medians <- df_clean |>
  group_by(style_group) |>
  summarise(ibu_median = median(ibu, na.rm = TRUE), .groups = "drop")

df_imputed <- df_clean |>
  left_join(style_ibu_medians, by = "style_group") |>
  mutate(
    ibu_imputed = is.na(ibu),
    ibu         = coalesce(ibu, ibu_median)
  ) |>
  select(-ibu_median)

# ABV — same approach (only 2.6% missing, MCAR, scattered across all style groups)
style_abv_medians <- df_imputed |>
  group_by(style_group) |>
  summarise(abv_median = median(abv, na.rm = TRUE), .groups = "drop")

df_imputed <- df_imputed |>
  left_join(style_abv_medians, by = "style_group") |>
  mutate(
    abv_imputed = is.na(abv),
    abv         = coalesce(abv, abv_median)
  ) |>
  select(-abv_median)

A quick sanity check — do the imputed IBU values make domain sense?

Show code
df_imputed |>
  group_by(style_group, ibu_imputed) |>
  summarise(
    n          = n(),
    mean_ibu   = round(mean(ibu), 1),
    median_ibu = round(median(ibu), 1),
    .groups    = "drop"
  ) |>
  arrange(style_group, ibu_imputed)
# A tibble: 22 × 5
   style_group      ibu_imputed     n mean_ibu median_ibu
   <chr>            <lgl>       <int>    <dbl>      <dbl>
 1 Amber / Red      FALSE          85     35.5         30
 2 Amber / Red      TRUE           61     30           30
 3 Belgian / Saison FALSE          27     30.1         30
 4 Belgian / Saison TRUE           42     30           30
 5 Blonde / Golden  FALSE          61     21           20
 6 Blonde / Golden  TRUE           47     20           20
 7 IPA              FALSE         395     71.9         70
 8 IPA              TRUE          179     70           70
 9 Lager / Pilsner  FALSE         160     24.6         22
10 Lager / Pilsner  TRUE          128     22           22
# ℹ 12 more rows

IPAs at 70 IBU, wheats at 18, sours at 8.5 — anyone who drinks craft beer will nod at these numbers.

Deduplicate

The same beer sometimes appears in multiple can sizes (12 oz and 16 oz). We keep the first occurrence per beer_name + brewery_id:

Show code
df_imputed <- df_imputed |>
  distinct(beer_name, brewery_id, .keep_all = TRUE)

This brings us to ~2,100 unique beers across ~550 U.S. breweries in 51 states.

Dataset summary

Show code
df_imputed |>
  group_by(style_group) |>
  summarise(
    n_beers     = n(),
    n_breweries = n_distinct(brewery_id),
    n_states    = n_distinct(state),
    mean_abv    = round(mean(abv) * 100, 1),
    mean_ibu    = round(mean(ibu), 1),
    pct_ibu_imp = round(mean(ibu_imputed) * 100, 1)
  ) |>
  arrange(desc(n_beers))
# A tibble: 11 × 7
   style_group        n_beers n_breweries n_states mean_abv mean_ibu pct_ibu_imp
   <chr>                <int>       <int>    <int>    <dbl>    <dbl>       <dbl>
 1 IPA                    554         338       49      6.9     71.3        31.6
 2 Other                  441         238       47      6.1     26.1        44.7
 3 Pale Ale               278         207       47      5.6     42.4        42.8
 4 Lager / Pilsner        272         190       44      5       23.6        45.2
 5 Wheat / Wit            177         140       38      5.1     18.8        42.9
 6 Stout / Porter         172         131       43      6.6     37.6        47.1
 7 Amber / Red            145         120       37      5.7     33.3        42.1
 8 Blonde / Golden        104          88       35      5       20.6        44.2
 9 Belgian / Saison        69          47       25      6.5     30.1        60.9
10 Strong / Barleywi…      42          37       21      7.3     59.4        23.8
11 Sour / Wild             21          18       14      4.4      8.6        42.9

IPA is dominant — 574 beers represented in 49 states, which tells you everything about American craft beer culture in 2017.


Building the Recommender

The intuition: beers as vectors

The recommender works by representing each beer as a point in a multi-dimensional “flavour space”. Two beers that are close together in that space are similar; two beers far apart are not.

Each beer gets three types of features:

  1. Normalised ABV — rescaled to [0, 1] so it’s on the same footing as IBU
  2. Normalised IBU — same
  3. Style group dummies — one-hot encoded, so “IPA” becomes a 1 in the IPA column and 0 everywhere else
Show code
df_features <- df_imputed |>
  mutate(
    abv_scaled  = rescale(abv),
    ibu_scaled  = rescale(ibu),
    style_dummy = 1
  ) |>
  pivot_wider(
    names_from   = style_group,
    values_from  = style_dummy,
    values_fill  = 0,
    names_prefix = "style_"
  )

feature_matrix <- df_features |>
  select(beer_id, abv_scaled, ibu_scaled, starts_with("style_")) |>
  column_to_rownames("beer_id") |>
  as.matrix()

dim(feature_matrix)
[1] 2275   13

Cosine similarity

We measure similarity using cosine similarity — the cosine of the angle between two vectors. A score of 1.0 means the beers point in exactly the same direction in flavour space (identical profile). A score of 0 means they’re orthogonal (no similarity).

\[\text{similarity}(A, B) = \frac{A \cdot B}{\|A\| \cdot \|B\|}\]

Why cosine rather than Euclidean distance? Cosine similarity is scale-invariant — it measures the direction of the flavour profile, not the magnitude. Two beers can have different absolute ABV values but still be “pointing the same direction” if their overall profile (light + crisp + wheat-forward) matches.

The recommender function

Rather than searching by beer name (which breaks down quickly once you realise the dataset only covers canned craft beers), we let users describe their own taste profile directly via sliders — ABV, IBU, and preferred style. We synthesise a virtual “target vector” from those inputs and find the nearest neighbours:

Show code
get_recommendations_by_profile <- function(
    target_abv,
    target_ibu,
    target_style,
    top_n          = 10,
    filter_state   = NULL,
    min_similarity = 0.90
) {

  # Build synthetic feature vector from user inputs
  target_vec <- c(
    abv_scaled = rescale(target_abv, from = range(df_imputed$abv)),
    ibu_scaled = rescale(target_ibu, from = range(df_imputed$ibu)),
    setNames(
      as.integer(
        paste0("style_", unique(df_imputed$style_group)) == paste0("style_", target_style)
      ),
      paste0("style_", unique(df_imputed$style_group))
    )
  )

  target_vec <- target_vec[colnames(feature_matrix)]

  # Cosine similarity: target vs every beer in the dataset
  sims <- apply(feature_matrix, 1, function(x) {
    sum(target_vec * x) / (sqrt(sum(target_vec^2)) * sqrt(sum(x^2)))
  })

  result <- df_imputed |>
    mutate(similarity = sims[as.character(beer_id)]) |>
    arrange(desc(similarity)) |>
    filter(similarity >= min_similarity)

  if (!is.null(filter_state)) {
    result <- result |> filter(state %in% filter_state)
  }

  result |>
    select(beer_name, brewery_name, city, state,
           style, abv, ibu, ibu_imputed, similarity) |>
    slice_head(n = top_n)
}

The min_similarity threshold is important — without it, the function will return beers even when the dataset has no good matches for a given state, producing nonsensical recommendations (we saw it return Imperial IPAs for someone who wanted a wheat ale simply because nothing else was left). A threshold of 0.90 keeps the results honest.

Test drive: “What should I drink in Oregon?”

I like Boulevard Wheat (~4.4% ABV, ~14 IBU) and Stella Artois (~5.0% ABV, ~24 IBU). Averaging those and picking Wheat / Wit as the style:

Show code
get_recommendations_by_profile(
  target_abv   = 0.047,
  target_ibu   = 19,
  target_style = "Wheat / Wit",
  top_n        = 10,
  filter_state = "OR"
)
# A tibble: 8 × 9
  beer_name    brewery_name city  state style   abv   ibu ibu_imputed similarity
  <chr>        <chr>        <chr> <chr> <chr> <dbl> <dbl> <lgl>            <dbl>
1 Occidental … Occidental … Port… OR    Amer… 0.047    18 TRUE             1.000
2 Lost Meridi… Base Camp B… Port… OR    Witb… 0.05     20 FALSE            1.000
3 Quick Wit B… Fort George… Asto… OR    Witb… 0.052    18 TRUE             0.999
4 Wheat the P… Coalition B… Port… OR    Amer… 0.044    13 FALSE            0.999
5 Nonstop Hef… Hopworks Ur… Port… OR    Amer… 0.039    20 FALSE            0.997
6 Hefe Black   Widmer Brot… Port… OR    Hefe… 0.049    30 FALSE            0.997
7 Widmer Brot… Widmer Brot… Port… OR    Hefe… 0.049    30 FALSE            0.997
8 Sweet As Pa… Good Life B… Bend  OR    Amer… 0.06     18 FALSE            0.993

Eight solid results — Hefeweizens and Witbiers from Portland and Bend, all within a tight similarity band. The threshold correctly excluded the Imperial IPAs that appeared before we added it.


The Shiny App

All of this wrangling and the recommender function live in global.R, which Shiny runs once at startup before any user session begins. This is the right pattern for expensive data prep — load once, serve many.

The app itself has three panels:

  • Recommendations — a sortable DT table with all results, including an est badge on beers where IBU was imputed
  • Flavour Map — a Plotly scatter of ABV vs IBU, showing the full style-group cloud in grey, your recommendations in copper, and your taste profile as a gold star
  • About — methodology notes including the IBU imputation story

Please find the Shiny App beer recommender here.


What I’d Do Differently (or Next)

A few honest limitations worth noting, and natural extensions if you want to take this further:

Data vintage — The dataset is from 2017. A lot has changed in the craft beer world since then. A live data source (e.g. Untappd API, BreweryDB) would make this genuinely useful rather than a learning exercise.

Feature weighting — Right now ABV, IBU, and style group are all weighted equally in the cosine similarity. In practice, style group probably matters more than a 0.5% ABV difference. Adding weights to the feature vector is a one-line change and worth experimenting with.

Multi-beer input — Instead of asking users to input a taste profile, let them name multiple beers and average their feature vectors. This was the original idea, but it requires every reference beer to be in the dataset — which ruled out Stella and Boulevard Wheat immediately.

The coffee version — The same architecture (feature vectors + cosine similarity) works perfectly for coffee. ABV → caffeine level, IBU → acidity, style → roast profile. That’s the next post.


Wrapping Up

What started as “I want to find good beer when I travel” turned into a surprisingly satisfying data science problem. The missing IBU data alone gave us a clean worked example of MCAR vs MAR diagnosis, the limits of global imputation, and why knowing your missingness mechanism matters before you touch a single coalesce().

The full code — global.R, ui.R, server.R — will be available on GitHub coming soon. If you find a great wheat ale in Oregon because of this, let me know.


Dataset: nickhould/craft-beers-dataset · Built with R, Shiny, Plotly, and a healthy appreciation for low-IBU beers.