27  Project: Mining customer reviews at scale

From tens of thousands of reviews to an evidence-backed opportunity brief

This project asks you to do what a real product analytics team does with a mountain of customer feedback: read all of it, without reading any of it yourself. You will run a structured-extraction pipeline over tens of thousands of product reviews, turn the extractions into named themes, track those themes over time, and condense the result into a one-page opportunity brief that a product manager could act on. Every skill comes from earlier chapters; what is new is the scale, and the discipline that scale demands. We supply the milestones, the evaluation gates, and the traps; you supply the work and the judgment.

NoteSetup for this chapter

The project itself is yours to run at scale, in your own scripts with checkpoints; the milestone code blocks below are sketches to adapt. But the chapter opens with a working miniature, the entire pipeline executed on one hundred fifty real reviews, so you can see every artifact the milestones ask for before you build the big version. Running it needs an OPENROUTER_API_KEY and the huggingface_hub library (the reviews stream from the Hugging Face Hub); the full project additionally needs patience and the gates. Budget a few hours of wall-clock time across the milestones, most of it waiting on API calls you have already verified on small samples.

27.1 The brief

A product team in a category you choose, say video games, kitchen appliances, or pet supplies, wants to know what to build or fix next. They have one asset that nobody has fully exploited: years of customer reviews, tens of thousands of them. Buried in that text are recurring complaints, delights, and gaps that competitors have not filled. Nobody can read 50,000 reviews. Skimming a sample misses the slow-burning issues, and keyword counts cannot tell “the battery died” from “the battery lasts forever, I was worried it would die.”

Your deliverable is a one-page market-opportunity brief: the top pain themes in the category, with their size, their trajectory over time, representative customer quotes, and one concrete recommendation.

One constraint separates this from storytelling and makes it analytics: every claim in the brief must trace back to counted extractions and quoted reviews. If the brief says “complaints about controller drift doubled between 2021 and 2023,” there must be a query against your extraction table that reproduces those numbers.

The project is split into four milestones, each producing an artifact that is consumed by the next, with an evaluation gate before the artifact is allowed to move downstream.

27.2 The pipeline in miniature, end to end

Before the milestones, we run the whole pipeline at 1/300th scale: one hundred fifty video-game reviews, extracted, themed, and trended for real. Everything below this section scales these exact moves up; nothing else changes but the counts and the discipline.

from dotenv import load_dotenv
load_dotenv()

import json
import pandas as pd
from huggingface_hub import HfFileSystem

# Stream the raw review file straight off the Hub and stop after 150 keepers:
# a few hundred kilobytes transferred, out of a category file measured in
# gigabytes. Never download what you only need the front of.
fs = HfFileSystem()
PATH = ("datasets/McAuley-Lab/Amazon-Reviews-2023"
        "/raw/review_categories/Video_Games.jsonl")

rows = []
with fs.open(PATH, "r") as f:
    for line in f:
        r = json.loads(line)
        text = (r.get("text") or "").strip()
        if 60 <= len(text) <= 600 and r["rating"] <= 3:   # complaints carry the signal
            rows.append({"text": text, "rating": r["rating"],
                         "year": pd.Timestamp(r["timestamp"], unit="ms").year})
        if len(rows) == 150:
            break

reviews = pd.DataFrame(rows)
print(f"{len(reviews)} low-rating reviews, {reviews.year.min()}-{reviews.year.max()}")
150 low-rating reviews, 2004-2023

Milestone 1’s move is structured extraction with the Chapter 11 concurrency pattern:

from concurrent.futures import ThreadPoolExecutor
from pydantic import BaseModel, Field
from gaba.llm import call_structured

class Extraction(BaseModel):
    issues: list[str] = Field(description="each distinct problem, as a short noun phrase like 'controller drift' or 'pay-to-win economy'; empty if none")

def extract(text: str) -> list[str]:
    return call_structured(
        "Extract the problems this reviewer experienced:\n" + text, Extraction,
        system="You extract product issues from reviews. Issues only, no praise.",
    ).data.issues

with ThreadPoolExecutor(max_workers=8) as pool:
    review_issues = list(pool.map(extract, reviews.text))

issues = pd.DataFrame([
    {"issue": i, "year": y, "rating": rt}
    for ints, y, rt in zip(review_issues, reviews.year, reviews.rating)
    for i in ints
])
print(f"{len(issues)} issue mentions from {len(reviews)} reviews")
issues.head(6)
319 issue mentions from 150 reviews
issue year rating
0 black is hard to see 2018 3.0
1 need external light to use keyboard 2018 3.0
2 auto-renew scam 2020 1.0
3 auto-renew allows Sony to charge double the an... 2020 1.0
4 Sony exploiting financially challenged 2020 1.0
5 Amazon gets kickbacks 2020 1.0

Before these extractions are fed to anything downstream, we apply Gate A’s move at miniature scale. We read the first ten reviews ourselves and wrote down each one’s issues by hand, before looking at any model output; those labels are committed with the book. Scoring is fuzzy by token overlap, with the threshold fixed before we looked at a single score:

from gaba import DATA_DIR

gold = json.loads((DATA_DIR / "reviews_gold_10.json").read_text())["labels"]

def fuzzy(a: str, b: str, threshold: float = 0.5) -> bool:
    """Token-Jaccard match: same complaint, different words."""
    wa, wb = set(a.lower().split()), set(b.lower().split())
    return len(wa & wb) / max(len(wa | wb), 1) >= threshold

tp = fp = fn = 0
for g in gold:
    assert reviews.text.iloc[g["index"]].startswith(g["text_start"])  # same stream order
    pred = review_issues[g["index"]]
    hits = sum(any(fuzzy(p, lab) for lab in g["issues"]) for p in pred)
    tp += hits
    fp += len(pred) - hits
    fn += sum(not any(fuzzy(lab, p) for p in pred) for lab in g["issues"])

print(f"gate A at n=10: precision {tp / max(tp + fp, 1):.2f}, "
      f"recall {tp / max(tp + fn, 1):.2f}  (fuzzy Jaccard >= 0.5)")
gate A at n=10: precision 0.24, recall 0.29  (fuzzy Jaccard >= 0.5)

Two cautions apply to reading these numbers. Token overlap is a crude stand-in for “matches by meaning”, so treat the printed precision and recall as a floor and read the actual mismatches before believing either; some will be the same complaint in different words, and some will be real disagreements worth a prompt fix. And ten reviews serve only as a smoke test: they catch an extractor that is badly broken before any money is spent scaling it, while the real Gate A below demands fifty labels and match-by-meaning judgment. One of our ten, a two-star review whose text is pure praise, exists to check the empty case: an extractor that invents an issue there fails in the way that matters most at scale.

Milestone 2’s move is themes from embeddings (Chapters 6 and 13), with one naming call per cluster:

from sklearn.cluster import KMeans
from gaba.embed import embed_texts
from gaba.llm import call_llm

vecs = embed_texts(issues.issue.tolist())
issues["theme_id"] = KMeans(n_clusters=6, random_state=0, n_init=10).fit_predict(vecs)

def name_theme(examples: list[str]) -> str:
    reply = call_llm(
        "Name the single product-issue theme these all belong to:\n- "
        + "\n- ".join(examples[:12]),
        system="Reply with the theme name only: two to five words, no preamble, "
               "no punctuation, no formatting.").text
    # Lightweight models sometimes add a preamble anyway; keep the last line.
    return reply.strip().splitlines()[-1].strip(" *\"'.")

themes = (issues.groupby("theme_id").agg(mentions=("issue", "size"))
          .sort_values("mentions", ascending=False).reset_index())
themes["theme"] = [name_theme(issues[issues.theme_id == t].issue.tolist())
                   for t in themes.theme_id]
themes[["theme", "mentions"]]
theme mentions
0 Hardware Malfunction 75
1 Usability and Performance Issues 71
2 Accessory and compatibility issues 61
3 Subscription service exploitation 48
4 Product Durability Issues 34
5 Missing components and features 30

Milestone 3’s move is the trend that turns counts into a story:

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(6.5, 3.6))
for t, color in zip(themes.theme_id.head(2), ["#0969da", "#cf222e"]):
    series = (issues[issues.theme_id == t].groupby("year").size().sort_index())
    label = themes.loc[themes.theme_id == t, "theme"].iloc[0]
    ax.plot(series.index, series.values, marker="o", color=color, label=label)
ax.set_xlabel("review year"); ax.set_ylabel("issue mentions")
ax.legend(frameon=False, fontsize=9)
ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout(); plt.show()
Line chart of issue mentions per year for the two largest extracted themes, showing their relative size and movement over time.
Figure 27.1: The two largest themes in the miniature, tracked by review year. At one hundred fifty reviews the lines are suggestive; at fifty thousand, movements like these are the finding the brief is built on.

A trend says where a theme is going; the rating split says how much it costs. Here the same issues table is cut by the rating of the review each mention came from:

seg = (issues.assign(seg=issues.rating.map(lambda r: "1 star" if r == 1 else "2-3 stars"))
       .groupby(["theme_id", "seg"]).size().unstack(fill_value=0)
       .reindex(themes.theme_id, fill_value=0))
one_star = seg.get("1 star", pd.Series(0, index=seg.index))
mid_star = seg.get("2-3 stars", pd.Series(0, index=seg.index))

fig, ax = plt.subplots(figsize=(6.5, 3.4))
ypos = range(len(seg))
ax.barh(ypos, one_star, color="#cf222e", label="from 1-star reviews")
ax.barh(ypos, mid_star, left=one_star, color="#9a9a9a", label="from 2-3 star reviews")
ax.set_yticks(ypos, themes.theme.tolist(), fontsize=8)
ax.invert_yaxis()
ax.set_xlabel("issue mentions")
ax.legend(frameon=False, fontsize=8, loc="lower right")
ax.spines[["top", "right"]].set_visible(False)
plt.tight_layout(); plt.show()
Horizontal stacked bar chart of the six miniature themes, largest at the top. Each bar splits the theme's issue mentions into a red segment for mentions from 1-star reviews and a gray segment for mentions from 2-3 star reviews.
Figure 27.2: Each theme’s issue mentions split by the rating of the review they came from. Mentions concentrated in 1-star reviews are the dealbreaker signal, complaints from buyers the product already lost, while 2-3 star mentions come from customers the product has not yet lost.

Read the bars by their red share, since a bar’s length shows only the mention count the theme table already lists, while the red share carries the new information. A theme dominated by 1-star mentions is a dealbreaker: it ends purchases, and fixing it defends revenue. A theme whose mentions skew toward 2 and 3 stars is an annoyance that customers tolerate, which makes it a different kind of opportunity. The miniature kept only ratings of 3 and below, so the sharpest contrast, complaints inside otherwise positive reviews, is invisible here; Milestone 3 runs this same cut across the full rating range, where it becomes the “what to build next” signal.

This is the entire mechanism: extract, verify, theme, trend, segment, and the brief follows from the tables. The miniature also prices the project. One hundred fifty extractions cost about two cents, so fifty thousand cost a few dollars, which is the kind of arithmetic Gate A expects you to show, because a guessed estimate does not clear the gate. What the miniature cannot do is exactly what the milestones and gates below exist for: at this scale the themes are rough, the trends are noise-prone, and the verification runs ten labels deep where the gates demand fifty, so the project consists of the scale together with the verification.

Milestone Skills from Artifact Gate
1. Structured extraction Ch. 3, 11 Issue and positive tables, one row per extraction A: extraction quality
2. Themes Ch. 6, 13 Theme table with names, counts, quotes B: cluster quality
3. Trends and segments Ch. 13 Trend table and charts by quarter and rating feeds C
4. The brief all of the above One page for a product manager C: faithfulness

27.3 The data

We use the Amazon Reviews 2023 dataset released by the McAuley Lab at the University of California, San Diego (UCSD), available on Hugging Face. It covers dozens of product categories with review text, star rating, timestamp, and product identifiers. Pick one category that interests you and take tens of thousands of reviews from it; 50,000 is a good target, large enough that manual reading is hopeless and trends are visible, small enough to process for a few dollars.

import json
from huggingface_hub import HfFileSystem

# Stream one category file; never hold the full category in memory or on disk.
fs = HfFileSystem()
path = ("datasets/McAuley-Lab/Amazon-Reviews-2023"
        "/raw/review_categories/Video_Games.jsonl")

with fs.open(path, "r") as f:
    for line in f:
        review = json.loads(line)
        # filter, checkpoint, and extract in batches as the milestones describe

Before any model sees this data, profile it the way Chapter 13 profiled the report corpus: the distribution of review lengths, the distribution of ratings, reviews per year. Two facts you find here will matter later. First, review length is heavy-tailed; a few reviews run thousands of words, and they will dominate your token bill if you let them. Second, ratings skew positive on Amazon (in most categories well over half are five stars, though you should confirm it in your own), which affects how you should read “share of reviews” numbers throughout.

27.4 Milestone 1: structured extraction at scale

The first milestone applies Chapter 3 to every review: a schema-constrained call that turns free text into rows. Per review we want the overall sentiment, a list of issues (pain points), and a list of positives, where each issue carries a short verbatim-style description and a category from a fixed list.

from pydantic import BaseModel
from typing import Literal

Category = Literal[
    "quality", "performance", "usability", "price",
    "shipping_packaging", "support", "compatibility", "other",
]

class Issue(BaseModel):
    description: str          # short phrase, in the customer's terms
    category: Category

class ReviewExtraction(BaseModel):
    sentiment: Literal["positive", "negative", "mixed"]
    issues: list[Issue]       # empty list if the review reports no problems
    positives: list[str]      # short phrases for what the customer praised

Fix the category list before you start and keep it short. Categories exist for segmentation in Milestone 3; the fine-grained structure comes from clustering the descriptions, which makes a long enum unnecessary. The prompt matters as much as the schema, and two instructions matter most: extract only what is stated, and treat empty lists as a correct answer. Without the second, models invent a problem to fill the field.

TipWith an AI coding tool

The ReviewExtraction and Issue models are exactly the kind of schema an assistant drafts well from a short description of what a review-mining pipeline needs. Hand it the category list and the empty-list requirement and ask for the Pydantic classes and the extract_review call, then read the result against Milestone 1’s real failure mode: does the schema make an empty issues list the easy path, and does Category stay a short, fixed enum. A common defect in a drafted schema treats the empty list as a special case and grows Category into a long taxonomy whose structure belongs to Milestone 2’s clustering. Getting that boundary right in the schema saves you from re-labeling tens of thousands of reviews later.

EXTRACTION_PROMPT = """You extract structured facts from a product review.
Report only what the reviewer actually states. Do not infer or speculate.
issues: problems the reviewer experienced, as short phrases in their terms.
positives: things the reviewer explicitly praised, as short phrases.
Empty lists are correct and common. A five-star review often has no issues;
a one-star review often has no positives."""

async def extract_review(text: str) -> ReviewExtraction:
    response = await client.beta.chat.completions.parse(
        model="meta-llama/llama-3.1-8b-instruct",  # cheap model, simple task
        messages=[{"role": "system", "content": EXTRACTION_PROMPT},
                  {"role": "user", "content": text}],
        response_format=ReviewExtraction,
    )
    return response.choices[0].message.parsed

Estimate the cost before you run anything. This is the habit Chapter 11 drilled, and at this scale it is not optional. A truncated review plus the prompt is about 200 input tokens, and the extraction is about 150 output tokens. At the cheap-model prices we have used throughout the book, $0.10 per million input tokens and $0.40 per million output tokens, the arithmetic is:

Item Tokens Price per 1M Cost
Input: 50,000 reviews × 200 10.0M $0.10 $1.00
Output: 50,000 reviews × 150 7.5M $0.40 $3.00
Total $4.00
TipCost: about four dollars, or two with the batch API

Roughly $4 for the full extraction pass at synchronous prices, and about $2 if your provider’s batch API (application programming interface) is available. Batch jobs typically run at half price in exchange for results that arrive within a day, whereas synchronous calls return within seconds. The number that wrecks this estimate is review length, since an untruncated 3,000-word review costs twenty times the average. Truncate first (see the traps section), and run a 100-review pilot to confirm your per-review token averages before committing to the full pass.

On self-hosted hardware the same numbers read as throughput. Truncation cuts the tokens each review occupies the machine for, and the heavy tail of review lengths will cost you wall-clock time and capacity exactly as it will cost you dollars here.

Run the extraction with the parallel pattern from Chapter 11, checkpointing as you go: write each completed batch to a JSONL file keyed by review_id, and on startup skip every id already on disk. A 50,000-call job will hit rate limits, transient errors, and at least one interruption; a pipeline that can resume loses nothing, while one that cannot starts over and doubles its bill.

import asyncio, json
from pathlib import Path

CHECKPOINT = Path("extractions.jsonl")
done = {json.loads(line)["review_id"]
        for line in CHECKPOINT.open()} if CHECKPOINT.exists() else set()

async def extract_all(reviews, concurrency=20):
    sem = asyncio.Semaphore(concurrency)
    async def one(review):
        async with sem:
            result = await extract_review(review["text"][:2000])  # truncate!
            return review["review_id"], result
    pending = [r for r in reviews if r["review_id"] not in done]
    with CHECKPOINT.open("a") as f:
        for coro in asyncio.as_completed([one(r) for r in pending]):
            review_id, result = await coro
            f.write(json.dumps({"review_id": review_id,
                                **result.model_dump()}) + "\n")

The output of this milestone is a flat table: one row per extracted issue (and a parallel one for positives), joined back to the review’s rating, timestamp, and id. Expect something on the order of 30,000 to 60,000 issue rows from 50,000 reviews. Do not proceed to Milestone 2 until this table passes Gate A below.

27.5 Milestone 2: from extractions to themes

Sixty thousand issue strings are still too many to read. But they are short, self-contained phrases, ideal material for the embedding analytics of Chapter 13. Embed every issue description, cluster the vectors, and you have candidate themes: groups of issues that customers describe in different words but that mean the same thing.

from gaba.embed import embed_texts
from sklearn.cluster import KMeans

X = embed_texts(issue_df["description"].tolist())

# Start around k=30 for a 50k-review category; tune by inspection.
kmeans = KMeans(n_clusters=30, random_state=0, n_init=10).fit(X)
issue_df["cluster"] = kmeans.labels_

Then name each cluster as Chapter 13 did: sample member descriptions, hand them to the model, and ask for a short theme label plus a one-sentence summary.

def name_cluster(members: list[str], n_sample: int = 20) -> str:
    sample = random.sample(members, min(n_sample, len(members)))
    prompt = ("These phrases are customer complaints that clustered together.\n"
              "Give a 3-to-5-word theme name and a one-sentence summary.\n\n"
              + "\n".join(f"- {m}" for m in sample))
    return chat(prompt)   # the plain completion helper from Chapter 2

And apply the same check that chapter insisted on: a tidy label can conceal an incoherent cluster, so for every theme you intend to report, read 20 actual member strings yourself. If a third of them do not belong under the label, the theme is not real yet; split it (raise k, or recluster that cluster alone) or merge it with its neighbor. Expect a junk cluster or two, vague gripes like “bad product, do not buy” that carry sentiment but no theme; label them as junk and exclude them from the brief, because forcing a story onto them would manufacture a finding.

Your output here is a theme table: theme id, name, one-sentence summary, member count, and three representative quotes pulled from the source reviews. Quote the full review sentences, because the brief will quote customers, and an extracted phrase is your pipeline’s paraphrase of them.

27.7 Milestone 4: the brief

Now compress everything into one page, written for a product manager who has not seen your pipeline and never will. The structure we recommend:

  1. Headline finding. One sentence: the single largest actionable opportunity and its size.
  2. Top pain themes. Three to five themes, each with: name, share of reviews affected, trajectory (rising, flat, falling, with numbers), one verbatim customer quote, and the rating segment it concentrates in.
  3. The opportunity. One recommendation: what to build, fix, or position against, and which theme evidence supports it.
  4. Method and confidence. Three sentences on how the numbers were produced and the extraction precision you measured in Gate A, so the reader can calibrate trust.

The governing rule is that every claim traces to counted extractions and quoted reviews. “Customers love the build quality” is allowed only if the positives table shows it and a quote backs it. No number appears in the brief that you cannot recompute from your tables with a few lines of pandas. You may use the model to polish the prose of the brief; you may not use it to generate the findings.

WarningDon’t outsource this

The judgment step, deciding which themes constitute an opportunity, belongs to you. A large language model (LLM) asked “what should the team build?” will produce a confident, generic answer for any input, and it cannot weigh what the data does not contain: the cost of a fix, the competitive landscape, what the team can actually build. The pipeline’s job is to put verified counts and real quotes in front of you. Choosing among them, deciding that the rising 4-star complaint about storage capacity matters more than the larger but falling complaint about packaging, is the analyst’s contribution, and it is the part a hiring manager or an instructor reads your brief to see.

27.8 Evaluation gates

The pipeline has three stages, and a defect at any stage flows silently into the brief. So we gate each stage, in the spirit of Chapter 9: a small labeled sample, a measurement, and a threshold you must clear before the next milestone’s outputs can be trusted.

Gate A: extraction quality. Before scaling past the pilot, hand-label 50 randomly sampled reviews yourself: read each one and write down the issues a careful human would extract, before you look at what the model produced, so the model’s answer cannot anchor yours. Then compare and count three things per review, matching issues by meaning, because exact wording rarely lines up:

# Per review in the golden set of 50:
#   true_positives:  model issues that match one of your labeled issues
#   false_positives: model issues with no support in the review
#   false_negatives: labeled issues the model missed
precision = tp / (tp + fp)   # what share of extracted issues are real
recall    = tp / (tp + fn)   # what share of real issues were extracted

Also check the empty case: of your sampled reviews with no genuine complaint, how often did the model invent one? What good looks like: precision at or above 0.85, recall at or above 0.75, and invented issues in under 1 of 10 no-complaint reviews. Below that, fix the prompt and schema and re-measure on the same golden set; do not scale a leaky extractor, because at 50,000 reviews a 25% false-positive rate is more than ten thousand phantom complaints flowing into your themes. This is the golden-set discipline of Chapter 9 in its smallest useful form, and the 50 labels you write here are reusable for every prompt revision afterward.

Gate B: cluster quality. For each theme you plan to report, conduct an intruder test, the same logic as Chapter 9’s human checks: take 5 member descriptions from the cluster, plant 1 description drawn from a different cluster, shuffle, and see whether you (or better, a classmate who did not construct the clusters) can identify the intruder. A coherent cluster makes the intruder obvious; an incoherent one makes it a coin flip. What good looks like: the intruder identified in at least 4 of 5 trials per reported theme. Themes that fail get split, merged, or dropped, and their failure is itself information about where the embedding space separates poorly.

Building a lineup needs no model calls at all, so the test costs nothing but attention. Here it is on the miniature’s own clusters, once on the tightest cluster by member-to-centroid similarity and once on the loosest:

import numpy as np

def coherence(t: int) -> float:
    """Mean cosine similarity of a cluster's members to its centroid."""
    m = vecs[(issues.theme_id == t).to_numpy()]
    c = m.mean(axis=0)
    return float((m @ (c / np.linalg.norm(c))).mean())

coh = {int(t): coherence(t) for t in themes.theme_id}
tightest, loosest = max(coh, key=coh.get), min(coh, key=coh.get)

def intruder_trial(t: int, seed: int) -> None:
    rng = np.random.default_rng(seed)
    members = issues.loc[issues.theme_id == t, "issue"].drop_duplicates().tolist()
    others = issues.loc[issues.theme_id != t, "issue"].drop_duplicates().tolist()
    lineup = list(rng.choice(members, size=min(5, len(members)), replace=False))
    lineup.append(others[int(rng.integers(len(others)))])   # the intruder, last
    order = rng.permutation(len(lineup))
    name = themes.loc[themes.theme_id == t, "theme"].iloc[0]
    print(f"theme '{name}' (coherence {coh[t]:.2f}): spot the intruder")
    for i, j in enumerate(order):
        print(f"  {i + 1}. {lineup[j]}")
    print(f"  -> the intruder is #{list(order).index(len(lineup) - 1) + 1}\n")

intruder_trial(tightest, seed=0)
intruder_trial(loosest, seed=1)
theme 'Usability and Performance Issues' (coherence 0.77): spot the intruder
  1. slow loading times
  2. poor gameplay
  3. lack of improvement over previous version
  4. boss level difficulty
  5. backlighting below keys
  6. poor fit
  -> the intruder is #5

theme 'Subscription service exploitation' (coherence 0.70): spot the intruder
  1. auto-renew allows Sony to charge double the annual fee
  2. lack of great aspects from previous game
  3. unbalanced endgame
  4. repetitive dialogue after death
  5. no feature to skip games
  6. slow gameplay
  -> the intruder is #2

Cover the answer lines and try both lineups before reading on. On the tight cluster the intruder should jump out, and that felt obviousness is what a reportable theme feels like. On the loose cluster the choice is often genuinely ambiguous, and the ambiguity is the measurement: if the cluster’s own members do not cohere enough to expose an outsider, the theme’s label is a caption on a pile and cannot be reported as a finding. Once you have made your call, run the real gate with a judge who did not build the clusters, since the builder always knows too much.

Gate C: brief faithfulness. Hand your finished brief and extraction tables to someone else (or yourself a day later, working only from the tables). For every number and every quote in the brief: can it be recomputed or located in the data? What good looks like: 100%. This gate has no partial credit, for the same reason that in Chapter 9 we gave faithfulness special status: a brief that is 95% faithful is not 95% trustworthy, because the reader cannot tell which 5% to discount.

27.9 Traps

Five failure modes recur in this project. We name them here so that you can watch for them before they surface in your grade.

Agreement bias inflates positives. Models are reluctant to report that a review contains nothing nice, so the positives list fills with weak inferences like “the customer implies the price was acceptable”. Instruct the model that empty lists are valid, and extract only what is stated. Check the rate of empty positives in your Gate A sample against your own labels.

Paraphrase clusters that look like themes. A cluster whose members are 800 near-identical strings (“stopped working after a month,” “quit working in a month”) is one complaint repeated, and reporting it as a discovered structure overstates the finding; when the shared phrasing originates in your extraction prompt, moreover, the cluster is an artifact of the pipeline, because the customers never wrote those words. Quote the underlying reviews, whose wording the pipeline could not have planted, and be suspicious of any cluster with very low internal variety.

Survivor bias. Reviews come from people who bought the product and bothered to write; people who abandoned the purchase, returned it silently, or churned before owning it are invisible. Your brief describes the experience of buyers who speak, and the method section should say so, because a claim about “customers” at large would overreach the sample.

Cost blowups from unbounded review length. The heavy tail of review lengths can multiply your token bill several-fold. Truncate every review to a fixed budget (2,000 characters captures the substance of nearly all reviews) before it enters the prompt. Assert the truncation in code, since the data cannot be trusted to stay short.

Non-English reviews. Most categories contain a slice of reviews in other languages. The extractor may translate them, mangle them, or refuse them, each behavior corrupting a different downstream stage. Decide a policy up front, where the simple one is to detect and exclude non-English text, and report the excluded share in your method note.

27.10 Going further

Three natural extensions, each connecting to a chapter you have already read. First, temporal drift: Chapter 24’s monitoring mindset applies directly here; productionize the trend analysis so each new quarter of reviews is extracted, assigned to existing themes, and flagged when a theme’s share shifts beyond its historical range, turning a one-off study into an early-warning system. Second, a RAG (retrieval-augmented generation) assistant over the reviews: with reviews chunked and embedded, the Chapter 7 pipeline gives the product team an interface to ask “what do customers say about the battery?” and get quoted, cited answers, with your theme table doubling as a retrieval filter. Third, a dashboard: the theme, trend, and segment tables are exactly the structure a Plotly Dash or Streamlit app takes as input, and a brief that updates itself stays useful longer than one frozen in a slide deck.

27.11 Project rubric

Component What is assessed Points
Gate A: extraction quality A hand-labeled 50-review sample; reported precision and recall; documented prompt iterations; thresholds met or the shortfall analyzed 25
Gate B: cluster quality Intruder tests on every reported theme; incoherent clusters split, merged, or dropped, with the decisions explained 20
Gate C: brief faithfulness Recomputable numbers, locatable quotes, and a method-and-confidence section 25
Brief quality One page; themes sized and trended with evidence; a specific, defensible opportunity recommendation; readable by someone who never saw the pipeline 30
Total 100

A brief that reads beautifully but fails Gate C scores lower than a plain one whose every number checks out. This weighting is deliberate, because in this field the analysis you can verify is the only analysis you have.