Focus Group Analysis

2026-10-25

Analyze simulated and imported transcripts

How much did each speaker contribute? Which terms and topics distinguished their turns? How did moderator questions organize the discussion, and where do readability, recurring themes, or participation patterns differ? FocusGroup addresses these questions for simulated sessions and imported transcripts.

Descriptive analysis runs offline. Thematic analysis and model summaries are separate, opt-in model tasks. A live session or requested model analysis uses LLMR, requires a provider key in its usual environment variable, and receives an explicit model configuration.

Run a focus group

config <- LLMR::llm_config(
  provider = "openai",
  model = "gpt-4o-mini",
  temperature = 0.7
)

result <- run_focus_group(
  topic = "Impact of Social Media on Mental Health",
  n_participants = 5,
  guide = c(
    Opening = 1,
    Icebreaker = 1,
    Engagement = 2,
    Exploration = 3,
    Closing = 1
  ),
  flow = "desire_based",
  config = config,
  seed = 110,
  message_mode = "roleflip"
)

print(result)
print(result$focus_group)

The returned focus_group_result has six returned components: focus_group, transcript, summary, participants, usage, and metadata.

head(result$transcript)
result$participants
result$usage

Each transcript row has a unique message_id. round identifies the moderator cycle, so a moderator question and its participant responses share a round. phase retains the guide phase. Generated rows also carry provider response metadata and token counts; a count the provider did not report is NA.

Run descriptive analysis offline

analyze_focus_group() runs descriptive analyses without a model unless the caller opts in by supplying config.

analysis <- analyze_focus_group(
  result,
  num_topics = 4,
  include_plots = TRUE
)

print(analysis)
analysis$basic_stats
analysis$tfidf
analysis$readability
analysis$issues

The focus_group_analysis returned components are basic_stats, topics, tfidf, readability, themes, model_summary, plots, and issues. A component that cannot be computed is an empty component with consistent columns where it is tabular. For example, plots is an empty list if ggplot2 is unavailable. issues identifies an optional analysis that could not run and gives its reason.

Individual descriptive analyses remain available as methods on the underlying FocusGroup object:

readability <- result$focus_group$analyze_readability()
participation <- result$focus_group$analyze_participation_balance()
questions <- result$focus_group$analyze_question_patterns()

readability
participation$participation_stats
questions$question_patterns

Opt in to model analysis

Thematic analysis and model summaries run only when an explicit config is passed to analyze_focus_group().

model_analysis <- analyze_focus_group(
  result,
  num_topics = 4,
  config = config
)

cat(model_analysis$themes)
cat(model_analysis$model_summary)

If a requested model analysis fails, the provider condition is raised. The failure is not converted to a missing field or omitted from the result.

Import and analyze an existing transcript

Importing a transcript creates no model output. Pass moderator_id when the moderator is known. If it is omitted, a speaker named exactly mod, moderator, facilitator, or interviewer (in any case) is the moderator.

transcript <- data.frame(
  speaker = c("Facilitator", "Ana", "Ben", "Ana"),
  text = c(
    "What should change about the library hours?",
    "Evening hours help working parents.",
    "Morning crowding has become difficult.",
    "Both schedules need enough staff."
  )
)

imported <- focus_group_from_transcript(
  transcript,
  topic = "Library hours",
  moderator_id = "Facilitator"
)

imported_analysis <- analyze_focus_group(imported, include_plots = FALSE)
print(imported_analysis)

Summarize a discussion

summarize_focus_group() summarizes a session, a set of arms, or an imported transcript by participant, guide item, disagreement, trajectory, turn, or the whole session, at one or more of three lengths: "headline", "brief", and "detailed". The model returns claims as JSON. Each claim gives its status ("stated", "implied", "contradictory", or "not_stated"), the ids of the messages it rests on, and any words it quotes. Every claim is checked against the transcript before prose is rendered from it: the cited messages must exist and be by the speakers the claim names, each quote must appear in the message it cites, and a claim for the whole group must cite every participant who spoke.

The chunks below run offline. The scripted runner stands in for the model: it reads the transcript from the prompt and answers with one claim per participant, quoting the first five words of that participant’s first message. A live call passes config and no runner.

transcript <- data.frame(
  speaker = c("Facilitator", "Ana", "Ben", "Ana", "Facilitator", "Ben"),
  text = c(
    "What should change about the library hours?",
    "Evening hours help working parents like me.",
    "I see it differently. Mornings are too crowded now.",
    "Crowded mornings still beat being locked out after work.",
    "What would you give up to pay for longer hours?",
    "I would give up the Sunday story hour before anything else."
  )
)
imported <- focus_group_from_transcript(
  transcript,
  topic = "Library hours",
  moderator_id = "Facilitator"
)

scripted <- function(experiments, ...) {
  prompt <- experiments$messages[[1]][[1]]$content
  unit <- sub(".*one participant, (P[0-9]+)\\..*", "\\1", prompt)
  lines <- regmatches(prompt, gregexpr("\\[#[0-9]+\\] P[0-9]+: [^\n]*", prompt))[[1]]
  own <- lines[grepl(paste0("^\\[#[0-9]+\\] ", unit, ": "), lines)][1]
  id <- as.integer(sub("^\\[#([0-9]+)\\].*", "\\1", own))
  words <- strsplit(sub("^[^:]*: ", "", own), " ")[[1]]
  claim <- list(
    id = 1, unit = unit, kind = "position", status = "stated",
    text = paste(unit, "gave a view on the hours."),
    speakers = list(unit), evidence = list(id), context = list(),
    quotes = list(list(message_id = id, speaker = unit,
                       text = paste(words[1:5], collapse = " ")))
  )
  reply <- list(claims = list(claim),
                levels = list(headline = list(1), brief = list(1)))
  experiments$response_text <- as.character(jsonlite::toJSON(reply, auto_unbox = TRUE))
  experiments
}

summary_config <- LLMR::llm_config("openai", "gpt-4o-mini")
by_person <- summarize_focus_group(
  imported, summary_config,
  compression = c("headline", "brief"),
  .runner = scripted
)
by_person
#> <focus_group_summary>
#> By: participant | Lengths: headline, brief
#> Model: openai/gpt-4o-mini | Calls: 2 | Structured output: schema
#> Claims kept: 2 | Quotes: 2
#> P1: P1 gave a view on the hours.
#> P2: P2 gave a view on the hours.

The summarizing model labels the speakers by their handles: Ana and Ben are labeled P1 and P2, and the facilitator Moderator. Names inside the messages reach it as written, as the speakers said them. The researcher’s columns keep the names, and each quote is cut from the message as recorded.

by_person$metadata$handles
#>    speaker_id      handle     label        role
#> 1 Facilitator Facilitator Moderator   moderator
#> 2         Ana         Ana        P1 participant
#> 3         Ben         Ben        P2 participant
by_person$claims[, c("unit", "status", "text")]
#>   unit status                         text
#> 1   P1 stated P1 gave a view on the hours.
#> 2   P2 stated P2 gave a view on the hours.
by_person$quotes[, c("unit", "message_id", "speaker_id", "verbatim", "start", "end")]
#>   unit message_id speaker_id                           verbatim start end
#> 1   P1          2        Ana Evening hours help working parents     1  34
#> 2   P2          3        Ben     I see it differently. Mornings     1  30

A quote passes when it occurs in the cited message once case, whitespace, and quotation marks are set aside; verbatim, start, and end give it as it stands in the source. A reply that fails a check is sent back once, with each failure stated. Here the first reply about P2 misquotes the message:

calls <- 0L
misquote_once <- function(experiments, ...) {
  calls <<- calls + 1L
  out <- scripted(experiments, ...)
  if (calls == 2L) {
    out$response_text <- sub("I see it differently.", "I see it quite differently.",
                             out$response_text, fixed = TRUE)
  }
  out
}
reasked <- summarize_focus_group(
  imported, summary_config,
  compression = "headline",
  .runner = misquote_once
)
reasked$calls[, c("call_id", "unit", "attempt")]
#>   call_id unit attempt
#> 1       1   P1       1
#> 2       2   P2       1
#> 3       3   P2       2
cat(reasked$calls$messages[[3]][[3]]$content)
#> Your reply did not pass these checks:
#> - Quote 1 of claim 1 is not in message 3. Message 3 reads: "I see it differently. Mornings are too crowded now."
#> - The reply has no claim that reports what P2 said; give at least one claim with "unit": "P2", P2 among its speakers, and a message by P2 as evidence.
#> - The "headline" list has no claim that passed the checks.
#> 
#> Reply again with the complete JSON object in the same format, with every point above corrected. Cite only messages shown in the transcript, copy each quote exactly from the message it cites, and give the status "not_stated" to anything the transcript does not show.

When the second reply fails as well, the run stops with an error of class focusgroup_summary_check_failure; its partial field holds the units completed before it, and its checks and replies fields show what failed. With on_fail = "drop" the failing claims move to rejected instead, except claims of a disagreement, of a position held by one participant alone, or of a change, which still stop the run.

by = "item" sends one guide item at a time, which also keeps a long session within the prompt window. by = "turn" gives the gist of every message. by = "trajectory" follows the discussion through stages fixed in advance: thirds of the participant turns for the headline, one stage per guide item for the longer lengths. by = "disagreements" lists each disagreement, and each position held by one participant alone, with how it ended. Private probe answers reach the model only with include_probes = TRUE.

by_item <- summarize_focus_group(
  result, config,
  by = "item",
  compression = c("headline", "detailed")
)
by_item$summaries

fg_turns() gives the turn table the summaries read, with no model call: each message’s guide item, handle, and length, and how the speaker came to have the floor. An imported transcript has no routing record, so every link is "sequence".

fg_turns(imported)[, c("message_id", "item", "speaker_id", "handle", "words", "link")]
#>   message_id item  speaker_id      handle words     link
#> 1          1    1 Facilitator Facilitator     7 sequence
#> 2          2    1         Ana         Ana     7 sequence
#> 3          3    1         Ben         Ben     9 sequence
#> 4          4    1         Ana         Ana     9 sequence
#> 5          5    2 Facilitator Facilitator    10 sequence
#> 6          6    2         Ben         Ben    11 sequence

Replay a session

fg_player() writes a session to a self-contained HTML file that steps through it message by message, built from the records the session saved and without any model call. The package includes a synthetic session made with a scripted runner, which the chunk below replays; open = FALSE keeps the browser closed while the vignette is built.

session <- readRDS(system.file("extdata", "player-session.rds",
                               package = "FocusGroup"))
page <- fg_player(session, file = tempfile(fileext = ".html"), open = FALSE)
file.exists(page)
#> [1] TRUE
table(session$routing$rule)
#> 
#>     addressed     called_on highest_score 
#>             1             2             5

Each speaker selection in session$routing appears as a panel before the turn it chose. Under desire-based selection the panel shows the score of every participant asked, marks the previous speaker as not asked, and states whether a tie was drawn at random; a call-on or a participant addressed by the moderator is marked as such. For a printable page, use mode = "static"; for a study with arms, pass the result of run_focus_group_arms() to write one file per arm.

Construct the objects directly

The R6 interface exposes the parts assembled by run_focus_group().

agents <- create_agents(
  n_participants = 3,
  demographics = data.frame(
    age = c(25, 35, 45),
    gender = c("male", "female", "non-binary"),
    education = c("high school", "bachelor's", "master's"),
    stringsAsFactors = FALSE
  ),
  config = config
)

flow <- create_conversation_flow(
  mode = "desire_based",
  agents = agents,
  moderator_id = "MOD"
)

fg <- FocusGroup$new(
  topic = "Impact of Social Media on Mental Health",
  purpose = "Explore perspectives and experiences related to social media.",
  agents = agents,
  moderator_id = "MOD",
  turn_taking_flow = flow,
  admin_config = config
)

Built-in flows are available only through create_conversation_flow(). ConversationFlow is the base class for custom turn-taking rules.

Interpretation

A generated transcript is evidence about the specified simulation, not about a population. Analysis should retain the guide, participant records, model configuration, and message construction recorded with the result.