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.
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}')
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']])
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']])
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']])
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']])
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"]}')
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]}')