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.
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:
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?
# 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.
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?
# 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.
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:
Normalised ABV — rescaled to [0, 1] so it’s on the same footing as IBU
Normalised IBU — same
Style group dummies — one-hot encoded, so “IPA” becomes a 1 in the IPA column and 0 everywhere else
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:
# 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
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.