Restaurant Review Analysis

LLM-based sentiment analysis of restaurant reviews across five progressively richer prompting stages. Based on the McCombs PGP-AIML case study, re-implemented with Gemini 2.5 Flash and the notes browser. Heatmap via htmlplotlib.

Source notebook: Case_Study_Restaurant_Review_Analysis.ipynb (McCombs PGP-AIML, 2024). Original used Llama-2-13B; replaced here with Gemini 2.5 Flash.

Setup: Load data and define LLM helper

import os, io, json, sys
import pandas as pd
from google import genai

# Add htmlplotlib
sys.path.insert(0, '/home/john/tmp/htmlplotlib')
import htmlplotlib

# Load data
data = pd.read_csv('/home/john/tmp/restaurant_reviews.csv')
print(f'Loaded {len(data)} reviews, columns: {list(data.columns)}')
show(data)

# Gemini client
GEMINI_KEY = open(os.path.expanduser('~/.gemini_api_key')).read().split()[2]
client = genai.Client(api_key=GEMINI_KEY)
MODEL = 'gemini-2.5-flash'

import time

def ask(instruction, review, retries=10):
    for attempt in range(retries):
        try:
            resp = client.models.generate_content(
                model=MODEL,
                contents=f'{instruction.strip()}\n\n{review}'
            )
            return resp.text.strip()
        except Exception as e:
            if attempt == retries - 1:
                raise
            wait = min(2 ** attempt, 30)
            print(f'Retry {attempt+1} after {wait}s: {e}')
            time.sleep(wait)

def extract_json(text):
    start = text.find('{')
    end   = text.rfind('}')
    if start == -1 or end == -1:
        return {}
    try:
        return json.loads(text[start:end+1])
    except json.JSONDecodeError:
        return {}

print(f'Using model: {MODEL}')

Stage 1: Basic sentiment (Positive / Negative / Neutral)

Simplest prompt: classify each review into one of three sentiment classes.

INST_1 = """
You are an AI analyzing restaurant reviews.
Classify the sentiment of the provided review as exactly one of: Positive, Negative, Neutral
Return only that one word, nothing else.
"""

data1 = data.copy()
data1['sentiment'] = data1['review_full'].apply(lambda r: ask(INST_1, r))
print('Sentiment distribution:')
print(data1['sentiment'].value_counts().to_string())
show(data1[['restaurant_ID', 'rating_review', 'sentiment', 'review_full']])

Stage 2: Structured JSON output

Same classification but returning structured JSON.

INST_2 = """
You are an AI analyzing restaurant reviews.
Classify the sentiment as Positive, Negative, or Neutral.
Return ONLY valid JSON with no other text: {"sentiment": "<value>"}
"""

data2 = data.copy()
data2['parsed']    = data2['review_full'].apply(lambda r: extract_json(ask(INST_2, r)))
data2['sentiment'] = data2['parsed'].apply(lambda d: d.get('sentiment', 'Unknown'))
print('Sentiment distribution (structured output):')
print(data2['sentiment'].value_counts().to_string())
show(data2[['restaurant_ID', 'sentiment']])

Stage 3: Overall + aspect sentiments

Classify sentiment for the overall review and three aspects: Food Quality, Service, Ambience.

INST_3 = """
You are an AI analyzing restaurant reviews.
Classify:
  - Overall sentiment: Positive, Negative, or Neutral
  - Sentiment for each aspect (Positive, Negative, Neutral, or Not Applicable if not mentioned):
    1. Food Quality
    2. Service
    3. Ambience
Return ONLY valid JSON:
{"Overall": "...", "Food Quality": "...", "Service": "...", "Ambience": "..."}
"""

data3 = data.copy()
data3['parsed'] = data3['review_full'].apply(lambda r: extract_json(ask(INST_3, r)))
aspects3  = pd.json_normalize(data3['parsed'])
final3    = pd.concat([data3[['restaurant_ID', 'rating_review', 'review_full']], aspects3], axis=1)
print('Overall distribution:')
print(final3['Overall'].value_counts().to_string())
print('\nFood Quality:', final3['Food Quality'].value_counts().to_dict())
print('Service:     ', final3['Service'].value_counts().to_dict())
print('Ambience:    ', final3['Ambience'].value_counts().to_dict())
show(final3[['restaurant_ID', 'Overall', 'Food Quality', 'Service', 'Ambience']])

Stage 4: Aspects + liked/disliked features

Extract specific liked or disliked features mentioned for each aspect.

INST_4 = """
You are an AI analyzing restaurant reviews. Return ONLY valid JSON with no other text:
{
  "Overall": "Positive|Negative|Neutral",
  "Food Quality": "Positive|Negative|Neutral|Not Applicable",
  "Service": "Positive|Negative|Neutral|Not Applicable",
  "Ambience": "Positive|Negative|Neutral|Not Applicable",
  "Food Quality Features": ["liked or disliked features, or empty list"],
  "Service Features": ["liked or disliked features, or empty list"],
  "Ambience Features": ["liked or disliked features, or empty list"]
}
"""

data4  = data.copy()
data4['parsed'] = data4['review_full'].apply(lambda r: extract_json(ask(INST_4, r)))
aspects4 = pd.json_normalize(data4['parsed'])
final4   = pd.concat([data4[['restaurant_ID', 'review_full']], aspects4], axis=1)
sentiment_cols = ['Overall', 'Food Quality', 'Service', 'Ambience']

feature_cols = ['Food Quality Features', 'Service Features', 'Ambience Features']
for col in feature_cols:
    if col in final4.columns:
        final4[col] = final4[col].apply(lambda x: ', '.join(x) if isinstance(x, list) else (x or ''))
show(final4[['restaurant_ID'] + sentiment_cols + ['Food Quality Features', 'Service Features', 'Ambience Features']])

Stage 5: Full analysis + customer response

Everything from stage 4, plus a drafted response to the customer tailored to their sentiment.

INST_5 = """
You are an AI analyzing restaurant reviews. Classify sentiment and extract features, then draft a polite customer response:
  - Positive: thank them and invite them back
  - Neutral: thank them and ask what could be improved
  - Negative: apologise sincerely and commit to investigate
Return ONLY valid JSON:
{
  "Overall": "Positive|Negative|Neutral",
  "Food Quality": "Positive|Negative|Neutral|Not Applicable",
  "Service": "Positive|Negative|Neutral|Not Applicable",
  "Ambience": "Positive|Negative|Neutral|Not Applicable",
  "Food Quality Features": [],
  "Service Features": [],
  "Ambience Features": [],
  "Response": "your response to the customer"
}
"""

data5  = data.copy()
data5['parsed'] = data5['review_full'].apply(lambda r: extract_json(ask(INST_5, r)))
aspects5 = pd.json_normalize(data5['parsed'])
final5   = pd.concat([data5[['restaurant_ID', 'review_full']], aspects5], axis=1)

feature_cols = ['Food Quality Features', 'Service Features', 'Ambience Features']
for col in feature_cols:
    if col in final5.columns:
        final5[col] = final5[col].apply(lambda x: ', '.join(x) if isinstance(x, list) else (x or ''))
show(final5[['restaurant_ID', 'Overall', 'Food Quality', 'Service', 'Ambience']])
print('\n--- Sample responses ---')
for i in [0, 8, 13]:  # positive, mixed, negative examples
    print(f'\nReview {i} ({final5.loc[i,"Overall"]}): {data.loc[i,"review_full"][:60]}...')
    print(f'Response: {final5.loc[i,"Response"]}')

Visualisation: Sentiment heatmap (Stage 3)

Heatmap of sentiment per review per aspect using htmlplotlib. Colour scale: blue = Positive, red = Negative, grey = Neutral / N/A.

import numpy as np

sentiment_map = {'Positive': 1.0, 'Neutral': 0.5, 'Negative': 0.0, 'Not Applicable': 0.5}
aspect_cols = ['Overall', 'Food Quality', 'Service', 'Ambience']

matrix = np.array([
    [sentiment_map.get(str(final3.loc[i, col]), 0.5) for col in aspect_cols]
    for i in final3.index
])

ylabels = [f'{final3.loc[i,"restaurant_ID"]} ({final3.loc[i,"rating_review"]}\u2605)' for i in final3.index]

html = htmlplotlib.html_heatmap(
    matrix,
    xticklabels=aspect_cols,
    yticklabels=ylabels,
    cmap='coolwarm',
    fmt='.1f',
    annot=False,
    xlabel='Aspect',
    ylabel='Restaurant',
    scale_factor=0.7,
)
show(HtmlOutput(html))

for col in aspect_cols:
    counts = final3[col].value_counts()
    vals = [counts.get('Positive',0), counts.get('Neutral',0), counts.get('Negative',0), counts.get('Not Applicable',0)]
    print(f'{col}: Positive={vals[0]} Neutral={vals[1]} Negative={vals[2]} N/A={vals[3]}')
created 2026-05-29