How to Analyze Sentiment with LLMs in R
Introduction
Sentiment analysis determines the emotional tone of text - is it positive, negative, or neutral? LLMs make this easy without training custom models.
Common use cases:
- Analyze customer reviews
- Monitor social media mentions
- Process survey feedback
- Track brand sentiment over time
Why Use LLMs for Sentiment Analysis?
| Approach | Setup | Nuance | Context Understanding |
|---|---|---|---|
| Rule-based | Word lists | Limited | Poor |
| Traditional ML | Training data | Moderate | Moderate |
| LLMs | Just a prompt | Excellent | Excellent |
LLMs understand sarcasm, context, and nuance that simpler methods miss.
Getting Started
Load the packages:
library(ellmer)
library(tidyverse)This tutorial uses ellmer which works with Claude, OpenAI, Gemini, or local models.
Basic Sentiment Analysis
Simple three-way classification
Start with positive, negative, or neutral:
chat <- chat_claude()
review <- "The food was delicious but the service was incredibly slow."
response <- chat$chat(paste(
"Analyze the sentiment of this text.",
"Reply with only: positive, negative, or neutral.",
"\n\nText:", review
))
response
# "neutral"The review has both positive (delicious food) and negative (slow service) elements, so the overall sentiment is neutral.
Why LLMs handle nuance better
Traditional sentiment tools often miss context:
# Sarcasm - sounds positive, means negative
"Oh great, another meeting that could have been an email."
# Rule-based: "great" = positive ❌
# LLM: negative ✓
# Negation - negative word, positive meaning
"Not bad at all, actually quite impressed."
# Rule-based: "bad" = negative ❌
# LLM: positive ✓
# Mixed - needs context weighing
"The product broke after a week, but customer service was amazing."
# LLM understands both aspectsStructured Sentiment Output
Using extract_data for reliable results
For consistent, parseable output, use structured extraction:
sentiment_type <- type_enum(
values = c("positive", "negative", "neutral"),
description = "Overall sentiment of the text"
)The type_enum() guarantees the response is one of the three allowed values.
Extract sentiment
chat <- chat_claude()
review <- "Absolutely love this product! Best purchase I've made all year."
sentiment <- chat$extract_data(review, type = sentiment_type)
sentiment
# "positive"Sentiment with Confidence Scores
When you need more than just a label
Get confidence scores to identify borderline cases:
sentiment_detailed <- type_object(
sentiment = type_enum(
values = c("positive", "negative", "neutral"),
description = "Overall sentiment"
),
confidence = type_number("Confidence score from 0 to 1"),
explanation = type_string("Brief explanation")
)Extract detailed sentiment
chat <- chat_claude()
review <- "It's okay I guess. Does what it says but nothing special."
result <- chat$extract_data(review, type = sentiment_detailed)
result$sentiment
# "neutral"
result$confidence
# 0.85
result$explanation
# "Lukewarm endorsement with no strong positive or negative indicators"Low confidence (below 0.7) suggests the text is ambiguous and may need human review.
Fine-grained Sentiment
Five-point scale
For more granularity, use a five-point scale:
sentiment_5point <- type_enum(
values = c("very_negative", "negative", "neutral", "positive", "very_positive"),
description = "Sentiment on a 5-point scale"
)Classify on the scale
chat <- chat_claude()
reviews <- c(
"Terrible product. Complete waste of money. Avoid!",
"Not great. Had some issues but usable.",
"It's fine. Does the job.",
"Really happy with this purchase!",
"AMAZING! Exceeded all my expectations!!!"
)
classify_5point <- function(text) {
chat <- chat_claude()
Sys.sleep(0.5)
chat$extract_data(text, type = sentiment_5point)
}
sentiments <- map_chr(reviews, classify_5point)
tibble(review = reviews, sentiment = sentiments)| review | sentiment |
|---|---|
| Terrible product… | very_negative |
| Not great… | negative |
| It’s fine… | neutral |
| Really happy… | positive |
| AMAZING!… | very_positive |
Numeric scores
Convert to numbers for analysis:
sentiment_to_score <- function(sentiment) {
scores <- c(
very_negative = 1,
negative = 2,
neutral = 3,
positive = 4,
very_positive = 5
)
scores[sentiment]
}
df <- tibble(
review = reviews,
sentiment = sentiments,
score = map_dbl(sentiments, sentiment_to_score)
)
mean(df$score)
# 3.0 (average sentiment)Aspect-Based Sentiment
Analyze sentiment by aspect
Products often have multiple aspects with different sentiments:
aspect_sentiment <- type_object(
overall = type_enum(
values = c("positive", "negative", "neutral"),
description = "Overall sentiment"
),
aspects = type_array(
items = type_object(
aspect = type_string("The aspect being discussed"),
sentiment = type_enum(
values = c("positive", "negative", "neutral"),
description = "Sentiment for this aspect"
)
),
description = "Individual aspects mentioned"
)
)Extract aspect sentiments
chat <- chat_claude()
review <- "The laptop is incredibly fast and the screen is beautiful.
However, the battery life is disappointing and the keyboard feels cheap."
result <- chat$extract_data(review, type = aspect_sentiment)
result$overall
# "neutral"
result$aspects
# [[1]] aspect: "speed", sentiment: "positive"
# [[2]] aspect: "screen", sentiment: "positive"
# [[3]] aspect: "battery", sentiment: "negative"
# [[4]] aspect: "keyboard", sentiment: "negative"Convert to data frame
aspects_df <- tibble(
aspect = map_chr(result$aspects, "aspect"),
sentiment = map_chr(result$aspects, "sentiment")
)
aspects_df| aspect | sentiment |
|---|---|
| speed | positive |
| screen | positive |
| battery | negative |
| keyboard | negative |
Batch Sentiment Analysis
Process multiple reviews
Create a reusable function:
analyze_sentiment <- function(text) {
chat <- chat_claude()
sentiment_type <- type_enum(
values = c("positive", "negative", "neutral"),
description = "Sentiment"
)
Sys.sleep(0.5) # Rate limiting
chat$extract_data(text, type = sentiment_type)
}Apply to a data frame
reviews_df <- tibble(
id = 1:5,
review = c(
"Love it! Works perfectly.",
"Broken on arrival. Very disappointed.",
"Does what it says. Nothing more.",
"Best purchase ever! Highly recommend!",
"Meh. Expected better for the price."
)
)
reviews_df <- reviews_df |>
mutate(sentiment = map_chr(review, analyze_sentiment))
reviews_df| id | review | sentiment |
|---|---|---|
| 1 | Love it!… | positive |
| 2 | Broken on arrival… | negative |
| 3 | Does what it says… | neutral |
| 4 | Best purchase… | positive |
| 5 | Meh. Expected… | negative |
Calculate summary statistics
reviews_df |>
count(sentiment) |>
mutate(pct = n / sum(n) * 100)| sentiment | n | pct |
|---|---|---|
| positive | 2 | 40% |
| negative | 2 | 40% |
| neutral | 1 | 20% |
Domain-Specific Sentiment
Customize for your domain
Add context for better accuracy in specific domains:
chat <- chat_claude(
system_prompt = "You analyze sentiment in restaurant reviews.
Consider these aspects:
- Food quality and taste
- Service speed and friendliness
- Ambiance and cleanliness
- Value for money
'Rich and decadent' is positive for desserts.
'Simple and quick' is positive for lunch spots.
'Crowded' can be positive (popular) or negative (uncomfortable)."
)
review <- "The place was packed on a Saturday night.
Food took 45 minutes but was worth the wait."
sentiment_type <- type_enum(
values = c("positive", "negative", "neutral"),
description = "Sentiment"
)
chat$extract_data(review, type = sentiment_type)
# "positive" (busy = popular, long wait but worth it)Emotion Detection
Beyond positive/negative
Detect specific emotions:
emotion_type <- type_object(
primary_emotion = type_enum(
values = c("joy", "sadness", "anger", "fear", "surprise", "disgust", "neutral"),
description = "Primary emotion expressed"
),
intensity = type_enum(
values = c("low", "medium", "high"),
description = "Intensity of the emotion"
)
)Detect emotions
chat <- chat_claude()
texts <- c(
"I can't believe they did this to me!",
"Just got the promotion! So happy!",
"The news was shocking, didn't see it coming."
)
detect_emotion <- function(text) {
chat <- chat_claude()
Sys.sleep(0.5)
chat$extract_data(text, type = emotion_type)
}
emotions <- map(texts, detect_emotion)
tibble(
text = texts,
emotion = map_chr(emotions, "primary_emotion"),
intensity = map_chr(emotions, "intensity")
)| text | emotion | intensity |
|---|---|---|
| I can’t believe… | anger | high |
| Just got the promotion… | joy | high |
| The news was shocking… | surprise | medium |
Error Handling
Handle failures gracefully
safe_sentiment <- function(text) {
tryCatch({
chat <- chat_claude()
sentiment_type <- type_enum(
values = c("positive", "negative", "neutral"),
description = "Sentiment"
)
Sys.sleep(0.5)
chat$extract_data(text, type = sentiment_type)
}, error = function(e) {
warning("Sentiment analysis failed: ", e$message)
NA_character_
})
}
# Use with map
sentiments <- map_chr(reviews, safe_sentiment)
# Check for failures
sum(is.na(sentiments))Local Sentiment Analysis
Use Ollama for free, private analysis
For sensitive data or high volume:
chat <- chat_ollama(model = "llama3.2")
sentiment_type <- type_enum(
values = c("positive", "negative", "neutral"),
description = "Sentiment"
)
chat$extract_data("Great product, highly recommend!", type = sentiment_type)
# "positive"See Local LLMs with Ollama for setup instructions.
Comparison: LLM vs Traditional
When to use each approach
| Scenario | Best Approach |
|---|---|
| Quick analysis, small dataset | LLM |
| Production, millions of texts | Traditional ML (faster, cheaper) |
| Need to understand sarcasm | LLM |
| Simple positive/negative | Either works |
| Multiple languages | LLM (built-in multilingual) |
| Sensitive data | Local LLM (Ollama) |
Hybrid approach
Use LLMs to label training data, then train a faster model:
# Step 1: Label 1000 samples with LLM
labeled_data <- df |>
slice_sample(n = 1000) |>
mutate(sentiment = map_chr(text, analyze_sentiment))
# Step 2: Train a fast classifier on labeled data
# (using tidymodels or similar)
# Step 3: Use fast model for productionSummary
| Task | Code |
|---|---|
| Basic sentiment | chat$extract_data(text, type_enum(c("positive", "negative", "neutral"))) |
| With confidence | type_object(sentiment = ..., confidence = type_number()) |
| 5-point scale | type_enum(c("very_negative", ..., "very_positive")) |
| Aspect-based | type_object(overall = ..., aspects = type_array(...)) |
| Emotion detection | type_enum(c("joy", "anger", "sadness", ...)) |
Key points:
- LLMs understand sarcasm, context, and nuance
- Use
type_enum()for consistent output - Add domain context in system prompts for accuracy
- Use
Sys.sleep()in batch processing for rate limits - Consider local models for sensitive or high-volume data