How to Analyze Sentiment with LLMs in R

llm
ellmer
sentiment analysis
Learn to analyze sentiment in text using LLMs in R. Classify reviews, social media, and feedback as positive, negative, or neutral without training models.
Published

April 4, 2026

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 aspects

Structured 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 production

Summary

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

Sources