One session: from calling an API to auditing what it returns
By this week, you have built the classical text-mining stack by hand — scraping, preprocessing, classification, clustering, topic modeling, sentiment, word vectors. This lab asks one question of every technique: what does an LLM do differently, where does it win, and where does it fall over?
How to use this page
Work through the sections in order and press Run on each demo. The outputs are not simulated — they come from a real run of the lab (a local llama3 via Ollama, plus frontier models over API). Each demo keeps its real Python underneath — expand “Show the code” anywhere, then open the notebook and run it yourself: where your outputs differ from these is worth a second look.
The lab in one arc
| §1 | Call four providers with one identical prompt — APIs vs. a free local model |
| §2 | Re-do six classic text-analysis tasks with an LLM, next to the traditional tool |
| §3 | Break it: sarcasm, false confidence, and scores that follow the prompt, not the data |
| §4 | Measure it: accuracy, reliability, agreement, robustness, LLM-as-judge |
| §5 | Decide: a task-shape guide for when the LLM is the right tool |
The habit this lab builds
Every demo follows the same rhythm: observe the raw text, execute a small step, compare outputs side by side, and question the result — does this make sense? what would change it? The section that matters most is §3: you’ll first watch the LLM succeed, then watch it fail in ways that look exactly like success. That contrast — not the API syntax — is what you should carry into your project.
§0 · Local LLM setup
Everything in this lab runs free and offline on your laptop. Two pulls from Ollama and the environment is ready — no API key required to participate.
Install once
check_ollama() — the first cell of the notebook
Show the code — the lab’s chat() helper
import ollama, json, time, re
DEFAULT_MODEL = "llama3"
EMBED_MODEL = "nomic-embed-text"
# One helper, reused all session long
def chat(prompt, system=None, model=DEFAULT_MODEL, temperature=0.0, fmt=None):
messages = ([{"role": "system", "content": system}] if system else [])
messages.append({"role": "user", "content": prompt})
kwargs = dict(model=model, messages=messages,
options={"temperature": temperature})
if fmt == "json":
kwargs["format"] = "json"
return ollama.chat(**kwargs)["message"]["content"]
§1 · One prompt, four providers
Model builders vs. inference providers: the same model is often served in several places at different speeds and prices. Send one identical prompt to OpenAI, Anthropic, Google, and a local llama3 — and compare what comes back.
The shared prompt — a small financial-analysis task
A brief note on parameters
| Param | What it does |
|---|---|
temperature | randomness — 0 = deterministic, 1+ = creative |
top_p | nucleus sampling (alternative to temperature) |
max_tokens | cap the output length |
stop / stream / seed | end strings · stream tokens · best-effort reproducibility |
Rule of thumb:
temperature=0 for extraction and classification (most of this class), ≈0.7 for creative writing.
Parameters are tied to the model, not the company — reasoning models swap temperature for
reasoning_effort; embedding models take only input. Always check the current docs.
§2 · Six classic tasks, redone with an LLM
Each demo pairs a technique you have already built by hand with its LLM counterpart — and ends with what to watch out for. Work left to right.
One prompt replaces the whole NLTK pipeline
Prior lectures: tokenization → POS → NER → regex, all hand-rolled. With an LLM: describe the schema you want, in words.
Input — a raw job post
Show the code
out = chat(
prompt=f"Extract entities from this job post. Return JSON with keys: "
f"company, job_title, skills (list), location, email.\n\nPOST:\n{job_post}",
system="You are an information-extraction assistant. Output ONLY valid JSON.",
fmt="json",
)The hallucination experiment — same passage, three prompts
This passage hints at famous specifics without stating a single one — a deliberate trap. Run it three ways and compare field by field.
The bait passage
Show the code — the two prompts
# STRICT — the guardrail
"IMPORTANT: If a field is not explicitly stated in the passage, return null. "
"Do NOT use outside knowledge. Do NOT guess."
# LOOSE — no guardrail, plus a framing that primes completion
"Complete the company briefing below. Fill in every field."Zero-shot sentiment vs. the supervised classifiers you just built
Prior lecture: TF-IDF features + Naive Bayes / SVM / logistic regression, labeled training data, F1/AUC. Now: no training data at all — describe the task in words, and run it on the same 30-review Amazon sample from the word-vector exercise.
The entire “model” — a system prompt
Classification report (n = 30)
| precision | recall | f1 | |
|---|---|---|---|
| POSITIVE | 1.00 | 0.87 | 0.93 |
| NEGATIVE | 0.88 | 1.00 | 0.94 |
| accuracy | 0.93 |
Confusion matrix (rows = gold)
Show the code
def classify(text):
ans = chat(prompt=text, system=SYSTEM, temperature=0.0).upper()
if "POSITIVE" in ans: return "POSITIVE"
if "NEGATIVE" in ans: return "NEGATIVE"
return "UNKNOWN"
for i, row in tqdm(enumerate(sample.itertuples()), total=len(sample)):
predictions.append(classify(row.text))
print(classification_report(clean["gold"], clean["pred"],
labels=["POSITIVE","NEGATIVE"]))Same input, same prompt — can you trust a single answer?
A deliberately ambiguous review
temperature=0 gives (nearly)
deterministic output but can still drift across runs and model versions. For labeling pipelines, always run
multiple passes and vote — next demo.Majority voting — the easy reliability upgrade
Ten harder reviews, five votes each at temperature=0.
Watch the one case where a perfectly unanimous vote is still wrong.
Show the code
def classify_vote(text, n=5, temperature=0):
labels = []
for _ in range(n):
ans = chat(prompt=text, system=SYSTEM, temperature=temperature).strip().upper()
hits = re.findall(r"POSITIVE|NEGATIVE", ans)
labels.append(hits[0] if hits else "NEGATIVE")
return Counter(labels).most_common(1)[0][0], labelsTopic labeling — interpretation, not clustering
The LLM doesn’t replace LDA — it replaces the human who used to name LDA’s clusters. First the naive version: keywords only.
A coherent topic (what LDA might produce)
electionvotecandidatecampaignpolldemocratrepublicansenatordebateballot
Pure noise — a junk cluster on purpose
carrecipedogquantumlemoncongresssocketoperaxeroxpineapple
The production fix: keywords + representative documents + a self-rating
Give the model what a human analyst would get — a few documents near the cluster centroid — and permission to report low coherence.
Cluster A — battery complaints
coherence: HIGH
Cluster B — deliberate junk
coherence: LOW — the model pushed back
Show the code — the richer labeling prompt
LABEL_TMPL = (
"Below are the top keywords and a few representative documents from one cluster.\n"
"Decide on a 2-4 word label and a one-sentence description.\n"
"Also rate the cluster's coherence: high / medium / low.\n"
"If keywords and documents do not share a clear common theme, set coherence to 'low' "
"and say so. It is acceptable to report low coherence — do not invent a theme.\n\n"
"TOP KEYWORDS: {kw}\n\nREPRESENTATIVE DOCUMENTS:\n- {docs}"
)Aspect-based sentiment, structured for a pipeline
Prior lecture: lexicon sentiment — sum the positive words, subtract
the negative ones. It misses negation, context, sarcasm. The LLM extracts {aspect: sentiment} pairs
directly, as JSON.
battery POSITIVE camera NEGATIVE price NEGATIVE
Harder: aspects the review never names
This reviewer never says “size,” “waterproofing,” “durability,” or “warranty” — they describe them obliquely. A bag-of-words extractor can’t catch these without a hand-built ontology. Click each extracted aspect to see its evidence in the text.
The review
Show the code
implicit_prompt = (
"Extract aspect-sentiment pairs from the product review. "
"Aspect names should reflect what the customer is actually talking about, "
"even if they don't use that exact word. "
"Return JSON: {aspects: [{aspect, sentiment, evidence}]}\n\nREVIEW:\n" + implicit_review
)Three eras of “similar”: TF-IDF → MiniLM → LLM-era embeddings
One query, seven documents, three methods. The query shares zero words with the two documents that actually answer it.
Query
Show the code — all three methods
# 1) TF-IDF cosine
v = TfidfVectorizer().fit(docs + [query])
tfidf_scores = cosine_similarity(v.transform([query]), v.transform(docs))[0]
# 2) Sentence-transformer (BERT-era, 384-dim)
st = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
st_scores = cosine_similarity([st.encode(query)], st.encode(docs))[0]
# 3) LLM-era embedding via Ollama (768-dim)
def embed(text):
return ollama.embeddings(model="nomic-embed-text", prompt=text)["embedding"]
ollama_scores = [cos(embed(query), embed(d)) for d in docs]§3 · Where LLMs struggle — failures that look like successes
Three subtle cases: sarcasm, self-consistency that isn’t correctness, and numerical scores that follow the prompt instead of the data. Each one arrives after you’ve watched the model succeed in §2 — that’s deliberate.
You first: label these five reviews
Bojić et al. (2025, Scientific Reports) measured LLM–human agreement across sentiment, political leaning, emotional intensity, and sarcasm — sarcasm shows the largest gap. Commit to your own labels before any model answers. No changing your mind after.
The agreement trap
A tempting pipeline: run the LLM N times, the runs agree, Cohen’s κ ≈ 1.0, declare the labels reliable. But a model can be consistently wrong — Bojić et al. report LLMs beating humans on inter-rater reliability, which measures agreement, not accuracy.
majority: NEGATIVE · true label: NEGATIVE · self-agreement κ = 1.0 — and this time it happens to be right.
“Just give each review a 0–10 score” — a common business ask
Three reviews — a rave, a genuinely mixed one, a rant. Two tests,
both at temperature=0, five runs per condition (every condition returned five identical scores —
that’s the stability temperature 0 buys; the question is what the stable number means).
Test 1 — same reviews, two rubrics
Test 2 — same reviews, two reviewer personas
If the score measured the review, the persona shouldn’t matter. Watch the mixed review.
§4 · How do you know the LLM did a good job?
Five measurements, all computed on the §2.2 classifier. Each answers a different question — no single number is “the” evaluation.
The toolkit, in one table
| Evaluation | What it answers | Method |
|---|---|---|
| Accuracy | Is the LLM right? | Compare to human gold labels: F1, classification_report, confusion matrix |
| Reliability | Does it agree with itself across runs? | Run N× at temperature 0; majority vote; inspect vote spread |
| Agreement | Does it agree with humans (or another LLM)? | Cohen’s κ between LLM labels and human labels |
| Robustness | Do small prompt rewrites flip answers? | 2–3 paraphrased prompts; κ between runs — different from reliability |
| LLM-as-judge | Can a stronger model grade a weaker one? | G-Eval / pairwise / Likert — needs strong justification in academic work |
Grounding in current evidence, worth reading: Bojić et al. 2025 (LLMs beat humans on inter-rater reliability — agreement, not accuracy) and Bermejo et al. 2025 (GPT-4 / Claude 3.5 beat outsourced coders on 5 NLP tasks), both in Scientific Reports.
Show the code — all five metrics
f1_pos = f1_score(clean["gold"], clean["pred"], pos_label="POSITIVE") # accuracy
kappa_self = cohen_kappa_score(run1, run2) # reliability (same prompt, 2 passes)
kappa_human = cohen_kappa_score(gold, pred) # agreement with humans
kappa_v12 = cohen_kappa_score(v1, v2) # robustness (paraphrased prompts)
judge = chat(f"REVIEW:\n{r}\n\nCLASSIFIER ANSWER: {p}", system=JUDGE) # LLM-as-judge§5 · When to use an LLM for text analysis
The lab’s closing synthesis: fit depends on task shape more than model choice. Pick a task to see its verdict, or read the full table.
What’s your task?
Main strength
Main pitfall
The full guide
| Task | LLM fit | Main strength | Main pitfall |
|---|
What this means in practice
If the task is a strong fit ✅, zero/few-shot prompting is probably competitive with paid human annotation — effort shifts from “label more data” to “design better prompts and evaluation.” If it’s ⚠️ moderate, build a small expert-labeled validation set first (50–200 items) and treat the LLM as a collaborator you audit, not an authority. Everywhere: structured JSON output, temperature 0 for labeling, majority voting for high-stakes labels, version-pin the model — and never measure quality by the model’s agreement with itself (§3.2).
Suggested workflow for the course project
| 1 | Prototype on ~10 examples in a notebook. Eyeball the outputs. |
| 2 | Manually label 50–200 items — this is your gold set. |
| 3 | Measure F1 or Cohen’s κ. Iterate on prompt, few-shot examples, JSON schema. |
| 4 | Still short? Fine-tune a smaller model, or fall back to traditional ML. |
Good luck playing with LLMs! 🎓