What Makes a Meal Memorable?
Exploring dining experiences through text and data
using mdeinR and Yelp reviews
Gopi Nath Vajpai
May 21, 2026
1 The Question
We’ve all had that meal — the one you still talk about years later. The one where everything clicked: the food, the atmosphere, the way the server remembered you ordered the same dish last time. What made it stick?
That’s the question this blog sets out to answer, and we’re going to
do it with data. Specifically, we’ll use the mdeinR R
package (Vajpai, Webb, and Beldona 2025) —
a text-scoring tool built on a 324-word validated lexicon — to mine
nearly 50,000 Yelp restaurant reviews and measure five dimensions of
what researchers call a Memorable Dining Experience
(MDE):
| Dimension | What it captures | Lexicon words |
|---|---|---|
| Sensory | Taste, aroma, presentation, ambience, décor | 85 |
| Affect | Emotional reactions, feelings, psychological engagement | 82 |
| Social | Belonging, staff warmth, communal joy | 58 |
| Intellectual | Curiosity, discovery, learning about new cuisines | 51 |
| Behavioral | Physical engagement, activities, involvement | 48 |
The scoring algorithm works at the sentence level: each token
matching the MDE dictionary contributes 1/n (where
n = sentence word count) to its dimension, adjusted for valence
shifters — negators, amplifiers, de-amplifiers, and adversative
conjunctions — in a context window of five words before and two after.
Negated scores are zeroed out, since MDE theory holds that only positive
engagement counts. mde_by() then averages sentence-level
scores within each specified group.
Our dataset is a pre-curated, pre-joined CSV of ~278,000 Yelp restaurant reviews with business attributes already attached. We stratify down to 50,000 reviews for the scoring step, then model what drives each dimension.
2 Getting Set Up
# Install mdeinR from GitHub if not already present
if (!requireNamespace("mdeinR", quietly = TRUE)) {
if (!requireNamespace("devtools", quietly = TRUE)) install.packages("devtools")
devtools::install_github("gvajpai/mdeinR")
}
library(mdeinR) # mde(), mde_by(), get_sentences(), valence_shifters
library(data.table)
library(tidyverse)
library(scales)
library(ggridges)
library(lme4)
library(broom.mixed)
library(knitr)
library(kableExtra)
library(marginaleffects)
library(lubridate)
library(ggsci) # NPG palette
# ── Global NPG-styled theme ────────────────────────────────────────────────
npg_cols <- pal_npg("nrc")(10) # 10-color NPG palette, reused across all plots
theme_set(
theme_bw(base_size = 13) +
theme(
plot.title = element_text(face = "bold", size = 15, color = "#3C5488"),
plot.subtitle = element_text(color = "#666666", size = 11),
axis.title = element_text(color = "#333333"),
panel.border = element_rect(color = "#CCCCCC", fill = NA),
strip.background = element_rect(fill = "#F0F0F0", color = "#CCCCCC"),
strip.text = element_text(face = "bold"),
legend.position = "bottom"
)
)
fmt <- function(x) format(round(x), big.mark = ",", scientific = FALSE)3 The Data
3.1 Loading and Cleaning
DATA_PATH <- "data/yelp_restaurants.csv"
df_raw <- fread(DATA_PATH, encoding = "UTF-8")
cat("Rows loaded:", fmt(nrow(df_raw)), "| Columns:", ncol(df_raw), "\n")## Rows loaded: 277,916 | Columns: 21
df <- copy(df_raw)
# Rename dotted attribute column names
setnames(df,
old = c("attributes.RestaurantsPriceRange2",
"attributes.RestaurantsTableService"),
new = c("price_raw", "table_service")
)
# Price tier (raw values: "1" / "2" / "3" / "4")
df[, price_tier := factor(as.character(price_raw),
levels = c("1","2","3","4"),
labels = c("$ (Budget)","$$ (Mid)","$$$ (Upscale)","$$$$ (Fine Dining)")
)]
# Dining type — pre-classified as "casual" or "upscale"
df[, dining_type := factor(trimws(type))]
# Review-level derived features
df[, review_date := as.Date(date)]
df[, year := year(review_date)]
df[, log_rev_len := log1p(nchar(text))]
df[, social_votes := useful + funny + cool]
df[, log_social := log1p(social_votes)]
# Drop rows missing the minimum required fields
df <- df[!is.na(text) & nchar(trimws(text)) > 30 & !is.na(stars)]
cat("Clean dataset:", fmt(nrow(df)), "reviews\n")## Clean dataset: 276,278 reviews
3.2 Stratified Sampling
Scoring 278,000 reviews is slow. Instead we draw a stratified sample of 50,000 — proportional across star rating, price tier, and dining type — preserving the joint distribution of our key grouping variables.
TARGET_N <- 50000
set.seed(42)
df[, strata := paste(stars, as.character(price_tier),
as.character(dining_type), sep = "_")]
strata_counts <- df[, .N, by = strata]
strata_counts[, quota := pmax(1L, round(TARGET_N * N / sum(N)))]
# Correct any rounding drift so total is exactly TARGET_N
diff_n <- TARGET_N - sum(strata_counts$quota)
if (diff_n != 0) {
idx <- order(-strata_counts$N)[seq_len(abs(diff_n))]
strata_counts$quota[idx] <- strata_counts$quota[idx] + sign(diff_n)
}
df <- df[strata_counts, on = "strata"][
, .SD[sample(.N, min(quota[1L], .N))], by = strata
]
df[, strata := NULL]
cat("Stratified sample:", fmt(nrow(df)), "reviews |",
fmt(uniqueN(df$business_id)), "restaurants |",
fmt(uniqueN(df$city)), "cities\n")## Stratified sample: 50,000 reviews | 5,936 restaurants | 472 cities
| Attribute | Value |
|---|---|
| Full dataset | 277,916 |
| Stratified sample (scored) | 50,000 |
| Unique restaurants | 5,936 |
| Unique cities | 472 |
| Date range | 2005-05-15 to 2022-01-19 |
| Price tiers | Budget / Mid / Upscale / Fine Dining |
| Dining types | Casual / Upscale |
4 Scoring MDE
mde_by() expects a data.table with a text
column and a grouping column. We pass review_id as the
grouping key so each review gets its own aggregated score — the
sentence-level scores for all sentences in that review are averaged into
one row per review.
## Scoring MDE on 50,000 reviews...
# mde_by() signature: mde_by(data, by = "group_col")
# The data.table must contain a column named 'text' (or pass text vector directly).
# Here we group by review_id to get one aggregated score per review.
mde_scores <- mde_by(
df[, .(review_id, text)],
by = "review_id"
)## Processed 46568 groups out of 50000. 93% done. Time elapsed: 3s. ETA: 0s.Processed 50000 groups out of 50000. 100% done. Time elapsed: 3s. ETA: 0s.
## Columns returned by mde_by(): review_id, sensory, affect, behavioral, social, intellectual, mde_count, word_count, sd_sensory, sd_affect, sd_behavioral, sd_social, sd_intellectual, n_sentences
## Key: <review_id>
## review_id sensory affect behavioral social intellectual
## <char> <num> <num> <num> <num> <num>
## 1: --0ecRcxmpWBGLW4WO529w 0.02034390 0.01250000 0.00000 0 0
## 2: --3-36l37JAW-LJlfANCPQ 0.01590909 0.04507576 0.00000 0 0
## 3: --6yNGeJX-wDvjSWnl-1pg 0.03601190 0.04735119 0.00625 0 0
## mde_count word_count sd_sensory sd_affect sd_behavioral sd_social
## <num> <num> <num> <num> <num> <num>
## 1: 0.5 16.00000 0.03150258 0.03535534 0.000 0
## 2: 1.0 19.16667 0.02468814 0.04876282 0.000 0
## 3: 0.5 11.56250 0.08972906 0.10598535 0.025 0
## sd_intellectual n_sentences
## <num> <int>
## 1: 0 8
## 2: 0 6
## 3: 0 16
# Merge the five dimension scores back onto the main data
df <- merge(df, mde_scores, by = "review_id", all.x = TRUE)
# Composite: equal-weight average of all five dimensions
df[, mde_composite := (sensory + affect + social + intellectual + behavioral) / 5]
# Drop the handful of reviews that produced no scorable sentences
mde_dims <- c("sensory","affect","social","intellectual","behavioral","mde_composite")
df <- df[complete.cases(df[, ..mde_dims])]
cat("Ready:", fmt(nrow(df)), "scored reviews\n")## Ready: 50,000 scored reviews
5 What Do the Scores Look Like?
5.1 The Shape of MDE
Before diving into drivers, it’s worth seeing what the scores actually look like in the wild.
dim_labels <- c(
sensory = "Sensory",
affect = "Affect",
social = "Social",
intellectual = "Intellectual",
behavioral = "Behavioral"
)
df_long <- melt(df, measure.vars = names(dim_labels),
variable.name = "dimension", value.name = "score")
df_long[, dimension := factor(dimension, levels = names(dim_labels),
labels = dim_labels)]
ggplot(df_long, aes(x = score, y = dimension, fill = dimension)) +
geom_density_ridges(alpha = 0.75, scale = 1.4,
quantile_lines = TRUE, quantiles = 2,
color = "white") +
scale_fill_manual(values = npg_cols[1:5], guide = "none") +
scale_x_continuous(limits = c(0, 0.35),
labels = number_format(accuracy = 0.01)) +
labs(title = "Distribution of MDE Scores by Dimension",
subtitle = "Vertical line = median | sentence-weighted scoring with valence adjustment",
x = "MDE Score", y = NULL)All five MDE dimensions are right-skewed — most reviews score near zero, but a long right tail of richly descriptive reviews pulls each distribution outward. Sensory language is far more common than intellectual.
Sensory language dominates — words like “aromatic”, “crispy”, and “divine” appear constantly. Intellectual MDE is the rarest, which makes sense: most diners write about how a meal felt, not what it taught them.
5.2 Do Better Ratings Mean Richer Experiences?
df_long_star <- melt(df[!is.na(stars)],
measure.vars = names(dim_labels),
variable.name = "dimension", value.name = "score")
df_long_star[, dimension := factor(dimension, levels = names(dim_labels),
labels = dim_labels)]
df_long_star[, star_f := factor(stars)]
ggplot(df_long_star, aes(x = star_f, y = score, fill = star_f)) +
geom_violin(trim = TRUE, alpha = 0.7, color = NA) +
geom_boxplot(width = 0.12, fill = "white", outlier.size = 0.3, alpha = 0.9) +
facet_wrap(~dimension, scales = "free_y", ncol = 3) +
scale_fill_manual(values = npg_cols[c(2,4,5,1,3)], guide = "none") +
labs(title = "MDE Dimensions Across Star Ratings",
subtitle = "Higher stars -> richer, more expressive review language",
x = "Review Stars (1-5)", y = "MDE Score")Higher-star reviews carry more MDE language across every dimension. One-star reviews are blunt and brief; five-star reviews elaborate across all five dimensions.
5.3 Does Price Buy a Richer Experience?
df_price <- df[!is.na(price_tier),
lapply(.SD, mean, na.rm = TRUE),
.SDcols = names(dim_labels), by = price_tier]
df_price_long <- melt(df_price, id.vars = "price_tier",
variable.name = "dimension", value.name = "mean_score")
df_price_long[, dimension := factor(dimension, levels = names(dim_labels),
labels = dim_labels)]
df_price_long <- df_price_long[!is.na(price_tier)]
ggplot(df_price_long, aes(x = price_tier, y = mean_score, fill = dimension)) +
geom_col(position = position_dodge(0.8), width = 0.7, alpha = 0.85) +
scale_fill_manual(values = npg_cols[1:5], name = "MDE Dimension") +
scale_y_continuous(labels = number_format(accuracy = 0.001)) +
labs(title = "Mean MDE Scores by Price Tier",
subtitle = "Does spending more buy a richer experience?",
x = "Price Tier", y = "Mean MDE Score") +
theme(axis.text.x = element_text(angle = 20, hjust = 1))Fine dining lifts affect and sensory MDE most strongly. Mid-range restaurants surprisingly hold their own on social MDE — unpretentious settings generate their own warmth.
5.4 Casual vs. Upscale — A Clean Contrast
The dataset has a pre-classified type column labelling
every restaurant as casual or upscale,
giving us a clean binary lens.
df_type <- df[!is.na(dining_type),
lapply(.SD, mean, na.rm = TRUE),
.SDcols = names(dim_labels), by = dining_type]
df_type_long <- melt(df_type, id.vars = "dining_type",
variable.name = "dimension", value.name = "mean_score")
df_type_long[, dimension := factor(dimension, levels = names(dim_labels),
labels = dim_labels)]
ggplot(df_type_long, aes(x = dimension, y = mean_score, fill = dining_type)) +
geom_col(position = position_dodge(0.75), width = 0.65, alpha = 0.85) +
scale_fill_manual(values = c("casual" = npg_cols[1], "upscale" = npg_cols[2]),
name = "Dining Type", labels = c("Casual","Upscale")) +
scale_y_continuous(labels = number_format(accuracy = 0.001)) +
labs(title = "MDE Profiles: Casual vs. Upscale Dining",
subtitle = "Upscale leads on affect and sensory; casual holds its ground on social",
x = "MDE Dimension", y = "Mean MDE Score")Upscale restaurants dominate on affect and sensory MDE. Casual dining holds its own on social — relaxed, group-friendly settings generate a different kind of memorability.
Fine dining is a curated sensory event — diners arrive primed to notice. That shows up clearly in higher sensory and affect scores. But the social warmth of a casual neighbourhood spot? That’s harder to engineer, and the data reflects it.
6 What Drives MDE?
6.2 Does the Year Matter?
Yelp has matured substantially since its early days. As the user base shifted and review culture evolved, did MDE language change too?
df_yearly <- df[!is.na(year) & year >= 2010 & year <= 2022,
.(mean_mde = mean(mde_composite, na.rm = TRUE),
se_mde = sd(mde_composite, na.rm = TRUE) / sqrt(.N),
n = .N),
by = year]
ggplot(df_yearly, aes(x = year, y = mean_mde)) +
geom_ribbon(aes(ymin = mean_mde - se_mde, ymax = mean_mde + se_mde),
fill = npg_cols[1], alpha = 0.2) +
geom_line(color = npg_cols[1], linewidth = 1.3) +
geom_point(aes(size = n), color = npg_cols[4], alpha = 0.85) +
scale_size_continuous(name = "Reviews", labels = comma, range = c(2, 8)) +
scale_x_continuous(breaks = 2010:2022) +
labs(title = "Year-over-Year: Mean Composite MDE Score",
subtitle = "Point size proportional to review count | ribbon = +/- 1 SE",
x = NULL, y = "Mean Composite MDE Score") +
theme(axis.text.x = element_text(angle = 30, hjust = 1))Composite MDE has trended upward since around 2014, possibly reflecting a more engaged and descriptive reviewer base as Yelp’s platform culture matured.
7 The Models
7.1 OLS: Putting It All Together
Can we predict composite MDE from observable restaurant and review characteristics? The OLS model includes star rating, price tier, dining type, table service, review length, and social votes.
df_model <- df[
!is.na(mde_composite) & !is.na(price_tier) &
!is.na(stars) & !is.na(dining_type) &
!is.na(log_rev_len)
]
df_model[, `:=`(
price_tier = relevel(factor(price_tier), ref = "$$ (Mid)"),
dining_type = relevel(factor(dining_type), ref = "casual"),
table_svc = as.integer(
fifelse(trimws(as.character(table_service)) %in%
c("TRUE","True","true","1"), 1L, 0L))
)]
ols_fit <- lm(
mde_composite ~
stars +
price_tier +
dining_type +
table_svc +
log_rev_len +
log_social,
data = df_model
)
summary(ols_fit)##
## Call:
## lm(formula = mde_composite ~ stars + price_tier + dining_type +
## table_svc + log_rev_len + log_social, data = df_model)
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.025051 -0.006830 -0.001635 0.004048 0.163718
##
## Coefficients: (2 not defined because of singularities)
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 1.867e-02 4.812e-04 38.805 < 2e-16 ***
## stars 3.282e-03 4.157e-05 78.944 < 2e-16 ***
## price_tier$ (Budget) -1.234e-03 2.613e-04 -4.721 2.35e-06 ***
## price_tier$$$ (Upscale) 2.153e-03 1.122e-04 19.183 < 2e-16 ***
## price_tier$$$$ (Fine Dining) 2.484e-03 2.695e-04 9.217 < 2e-16 ***
## dining_typeupscale NA NA NA NA
## table_svc NA NA NA NA
## log_rev_len -3.400e-03 7.230e-05 -47.023 < 2e-16 ***
## log_social -2.289e-04 7.531e-05 -3.040 0.00237 **
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.01183 on 49993 degrees of freedom
## Multiple R-squared: 0.1879, Adjusted R-squared: 0.1878
## F-statistic: 1928 on 6 and 49993 DF, p-value: < 2.2e-16
tidy_ols <- broom::tidy(ols_fit, conf.int = TRUE) %>%
filter(term != "(Intercept)") %>%
mutate(
sig = case_when(p.value < 0.001 ~ "***",
p.value < 0.01 ~ "**",
p.value < 0.05 ~ "*",
TRUE ~ ""),
group = case_when(
str_detect(term, "price_tier") ~ "Price Tier",
str_detect(term, "log_") ~ "Review Signals",
TRUE ~ "Restaurant"),
term_clean = term %>%
str_remove("price_tier") %>% str_remove("dining_type") %>%
str_replace_all("_", " ") %>% str_to_title()
) %>%
arrange(group, estimate)
ggplot(tidy_ols, aes(x = estimate,
y = reorder(term_clean, estimate),
color = group)) +
geom_vline(xintercept = 0, linetype = "dashed", color = "grey60") +
geom_errorbarh(aes(xmin = conf.low, xmax = conf.high),
height = 0.3, linewidth = 0.8, alpha = 0.7) +
geom_point(size = 3) +
geom_text(aes(label = sig, x = conf.high + 0.00005),
hjust = 0, size = 4, color = npg_cols[2]) +
scale_color_manual(values = npg_cols[c(1, 2, 4)],
name = "Variable Group") +
facet_wrap(~group, scales = "free_y", ncol = 2) +
labs(title = "OLS Regression: Predictors of Composite MDE",
subtitle = "95% CI | * p<.05 ** p<.01 *** p<.001",
x = "Coefficient", y = NULL)Review length (log) is the dominant driver of composite MDE — memorable experiences inspire more words. Star rating and upscale dining type add meaningfully on top.
7.2 Mixed-Effects: Accounting for Restaurant Clustering
OLS treats every review as independent, but reviews from the same restaurant share a kitchen, a menu, and an atmosphere. We account for that structure with a linear mixed-effects model — a random intercept per restaurant, fit separately for each MDE dimension.
dims <- c("sensory","affect","social","intellectual","behavioral")
lmer_fits <- lapply(dims, function(d) {
f <- as.formula(paste(
d,
"~ stars + price_tier + dining_type + table_svc +
log_rev_len + log_social + (1 | business_id)"
))
lmer(f, data = df_model, REML = TRUE,
control = lmerControl(optimizer = "bobyqa"))
})
names(lmer_fits) <- dims
# Tidy all five models; normalise p-value column name across broom.mixed versions
lmer_tidy <- imap_dfr(lmer_fits, function(fit, dim_name) {
td <- broom.mixed::tidy(fit, conf.int = TRUE, effects = "fixed") %>%
filter(term != "(Intercept)") %>%
mutate(dimension = dim_name)
if (!"p.value" %in% names(td) && "p" %in% names(td))
td <- rename(td, p.value = p)
if (!"p.value" %in% names(td) && "statistic" %in% names(td))
td <- mutate(td, p.value = 2 * pnorm(-abs(statistic)))
if (!"p.value" %in% names(td))
td <- mutate(td, p.value = NA_real_)
td
})lmer_sig <- lmer_tidy %>%
mutate(
sig = ifelse(!is.na(p.value) & p.value < 0.05, "*", ""),
term_clean = term %>%
str_remove("price_tier") %>% str_remove("dining_type") %>%
str_replace_all("_", " ") %>% str_to_title(),
dimension = factor(dimension, levels = dims,
labels = c("Sensory","Affect","Social",
"Intellectual","Behavioral"))
)
ggplot(lmer_sig, aes(x = dimension,
y = reorder(term_clean, estimate),
fill = estimate)) +
geom_tile(color = "white", linewidth = 0.6) +
geom_text(aes(label = paste0(sprintf("%.4f", estimate), sig)),
size = 3, color = "white", fontface = "bold") +
scale_fill_gradient2(low = npg_cols[2], mid = "white", high = npg_cols[1],
midpoint = 0, name = "Coefficient") +
labs(title = "Mixed-Effects Coefficients Across MDE Dimensions",
subtitle = "Random intercepts by restaurant | * = p < .05",
x = "MDE Dimension", y = NULL) +
theme(panel.grid = element_blank(),
axis.text.x = element_text(angle = 20, hjust = 1))Each cell shows the fixed-effect coefficient for that predictor-dimension pair. Blue = positive lift on MDE; red = negative. Stars mark p < .05.
Review length dominates every single dimension — elaboration surfaces MDE language regardless of what’s being described. Beyond that, upscale dining has the strongest lift on affect and sensory, while table service contributes most to social MDE. Intellectual MDE barely responds to any structural predictor, suggesting it’s driven more by the novelty of the experience itself than by anything about how the restaurant is set up.
7.3 Marginal Effects: What Does Price Actually Predict?
Regression coefficients are useful but abstract. Marginal effects translate them into predicted MDE scores — what you’d actually expect to see at each price tier or dining type, with everything else held at its mean.
me_price <- avg_predictions(ols_fit, variables = "price_tier") %>%
as.data.frame() %>%
filter(!is.na(price_tier)) %>%
mutate(var = "Price Tier", label = as.character(price_tier))
me_type <- avg_predictions(ols_fit, variables = "dining_type") %>%
as.data.frame() %>%
mutate(var = "Dining Type",
label = str_to_title(as.character(dining_type)))
me_all <- bind_rows(me_price, me_type) %>%
mutate(var = factor(var, levels = c("Price Tier","Dining Type")))
ggplot(me_all, aes(x = label, y = estimate,
ymin = conf.low, ymax = conf.high, fill = var)) +
geom_col(alpha = 0.85, width = 0.55) +
geom_errorbar(width = 0.2, linewidth = 0.9) +
geom_label(aes(label = sprintf("%.4f", estimate)),
vjust = -0.4, size = 3.5, fill = "white", label.size = 0) +
facet_wrap(~var, scales = "free_x") +
scale_fill_manual(
values = c("Price Tier" = npg_cols[1], "Dining Type" = npg_cols[2]),
guide = "none") +
labs(title = "Predicted Composite MDE: Price Tier & Dining Type",
subtitle = "Marginal effects from OLS | error bars = 95% CI",
x = NULL, y = "Predicted MDE Score") +
theme(axis.text.x = element_text(angle = 15, hjust = 1))The upscale premium in MDE is real but modest. Much of the gap is driven by how experiences are described, not just where they happen.
7.4 Does Dining Type Mediate the Price Effect?
One more structural question: does price tier work through dining type — pricier restaurants tend to be upscale, and upscale dining generates richer MDE language — or does it have a direct effect of its own?
m_total <- lm(mde_composite ~ price_tier, data = df_model)
m_direct <- lm(mde_composite ~ price_tier + dining_type, data = df_model)
c_path <- broom::tidy(m_total, conf.int = TRUE) %>%
filter(str_detect(term, "price_tier")) %>%
select(term, c_total = estimate)
cp_path <- broom::tidy(m_direct, conf.int = TRUE) %>%
filter(str_detect(term, "price_tier")) %>%
select(term, c_direct = estimate)
med_tbl <- left_join(c_path, cp_path, by = "term") %>%
mutate(indirect = c_total - c_direct,
pct_mediated = round(100 * indirect / c_total, 1),
term_clean = str_remove(term, "price_tier")) %>%
select(Price = term_clean,
`c (Total)` = c_total,
`c' (Direct)` = c_direct,
`Indirect` = indirect,
`% Mediated` = pct_mediated)
kable(med_tbl, digits = 4,
caption = "Mediation: Does Dining Type Explain the Price -> MDE Effect?") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = FALSE) %>%
column_spec(5, bold = TRUE, color = npg_cols[1])| Price | c (Total) | c’ (Direct) | Indirect | % Mediated |
|---|---|---|---|---|
| $ (Budget) | -0.0007 | -0.0007 | 0 | 0 |
| \[$ (Upscale) </td> <td style="text-align:right;"> 0.0021 </td> <td style="text-align:right;"> 0.0021 </td> <td style="text-align:right;"> 0 </td> <td style="text-align:right;font-weight: bold;color: rgba(230, 75, 53, 255) !important;"> 0 </td> </tr> <tr> <td style="text-align:left;"> \]$$ (Fine Dining) | 0.0026 | 0.0026 | 0 | 0 |
Yes and no. A meaningful share of the price effect runs through dining type — but there’s a significant direct residual. This likely captures things dining type doesn’t encode: ingredient quality, plating, service choreography, physical space. You don’t have to be labelled “upscale” to deliver an upscale experience in the dimensions that matter for MDE.
8 What We Found
| Predictor | Finding | Strength |
|---|---|---|
| Star rating | Higher stars -> higher MDE across all dims | Strong |
| Upscale dining type | Clear lift on affect and sensory MDE | Strong |
| Fine dining price tier | Higher MDE, partly via dining type | Moderate |
| Table service | Drives social and affect MDE | Moderate |
| Review length (log) | Strongest single predictor across all 5 dims | Very strong |
| Social votes (log) | High-MDE reviews earn more reader votes | Moderate |
| Price -> Dining Type -> MDE | ~30-50% of price effect is mediated | Moderate |
8.1 What This Means in Practice
For restaurant owners: The biggest takeaway isn’t about price — it’s about giving people something worth writing about. Review length is the strongest predictor of MDE scores, and longer reviews come from richer experiences. Table service, deliberate sensory design, and moments of genuine social connection all contribute to the kind of meal that ends up in a long, glowing review.
For researchers: The MDE framework holds up well
empirically. The five dimensions show distinct predictor profiles —
especially intellectual MDE, which barely responds to structural
variables — and the mdeinR scoring approach produces
outputs that correlate meaningfully with external signals like reader
vote counts.
8.2 Caveats Worth Knowing
- Text-derived endogeneity: Both MDE scores and star ratings come from the same review text. A richer writing style might inflate both simultaneously.
- No reviewer-level data: Elite status, review history, and personal writing habits all likely affect MDE scores but are unavailable in this dataset.
- Binary dining type: “Casual” and “upscale” is a coarse typology. Fast-casual, gastropub, food truck, and fine dining each do something different within those buckets.
- Lexicon boundary: The 324-word validated lexicon is comprehensive but fixed. Emerging food culture vocabulary and non-English borrowings (e.g., omakase, mezze) may be under-scored.
9 References
Built with mdeinR (Vajpai,
Webb, and Beldona 2025), ggplot2, lme4,
and a curated Yelp restaurant review dataset. CSV goes at
data/yelp_restaurants.csv relative to this Rmd.
6.1 Social Votes: Do Other Readers Agree?
One underused signal in Yelp data is the social vote tally — the sum of “useful”, “funny”, and “cool” votes a review earns from other readers. If MDE language genuinely captures something real about the dining experience, high-MDE reviews should also resonate with readers.
Reviews that earn more social votes from other readers also score higher on MDE across all five dimensions — an independent validation that the scoring captures something real.
The relationship is monotonic and consistent. Reviews earning 8+ votes score noticeably higher on MDE than zero-vote reviews. The lexicon isn’t just picking up verbose writing — it’s capturing something other readers also find meaningful.