W4: Dictionaries and Sentiment Analysis
2026-10-02Fall 2026 (115-1) · National Taiwan University (GIND)
NTLDEV 7191 · Fri. 16:30–18:20 · 國發 204
Last compiled: 2026-10-08 17:24:08 (Asia/Taipei)
When do MPs describe household economic conditions negatively?
rent identifies the topic; cannot afford describes hardship.Mentioning inflation does not tell us how an MP evaluates household conditions.
| Concept | Question | Example |
|---|---|---|
| Issue attention | What is mentioned? | This speech discusses energy bills. |
| Sentiment | How is something evaluated? | High energy bills are hurting families. |
| Stance | What position is taken toward a target? | I support the government’s price cap. |
Negative language about a problem can accompany support for a policy.
Reference: Rauh (2018) · Proksch et al. (2019)
Today we practise two parts separately: counting issue mentions and scoring positive/negative words. We still need to connect each evaluation to its target to answer the opening question.
Example: “Families cannot afford rent or energy bills.”
| Level | Concrete example |
|---|---|
| Construct | How much an MP talks about household costs |
| Indicator | Count matches such as rent and energy bills (one phrase match) |
| Score | Speech A: 4 matches / 100 words = 4 per 100; speech B: 4 / 200 = 2 per 100 |
| Claim | Speech A has twice as many household-cost matches per 100 words as speech B |
This counts mentions. To measure negative evaluations, we also need to identify language such as “cannot afford.”
Reference: Rauh (2018) · Proksch et al. (2019)
| Text unit | Political question |
|---|---|
| Sentence | Which statements refer to household hardship? |
| Speech | How much of a contribution concerns hardship? |
| Speaker-period | How does a speaker’s attention change over time? |
Our lab sample is 5,000 UK House of Commons contributions, 1945–2025.
Keep speaker, date and source IDs when splitting or combining texts.
Each document is one parliamentary speech contribution. Run C01 once in R to load the data and examples; keep this R session open.
# C01: Setup
# First use: uncomment the next line to install.
# install.packages(c("quanteda", "quanteda.textstats"))
library(quanteda)
library(quanteda.textstats)
paths <- c("workflow.R", "W4/classroom/workflow.R",
"classroom/workflow.R", "../classroom/workflow.R")
found <- paths[file.exists(paths)]
if (length(found) == 0) found <- file.choose()
setwd(dirname(normalizePath(found[1])))
source("workflow.R")Resource: Classroom ZIP
Predict: How many times will prices appear in row A?
# C02: Counts and document length
library(quanteda)
demo <- c(A = "Prices rise. Prices worry voters.",
B = "Prices fall.")
demo_tokens <- tokens(demo, remove_punct = TRUE)
demo_dfm <- dfm(demo_tokens)
selected <- dfm_match(demo_dfm, c("prices", "rise", "fall"))
as.matrix(selected)
ntoken(demo_tokens)Compare: Why do the selected row totals differ from document length?
prices appears twice, rise once, and fall zero times.worry and voters are not shown.The government does not support the opposition.
The opposition does not support the government.
Same bag of words; different actor and target.
A dictionary maps expressions to predefined categories.
dic stores the dictionary for the next example.
Mentioning household costs does not establish economic hardship.
Reference: Grimmer, Roberts & Stewart (2022) · Resource: quanteda
| Document | household_costs | employment |
|---|---|---|
| text1 | 2 | 0 |
| text2 | 0 | 1 |
rent + energy bills give 2 matches; job security gives 1.
Resource: R code
| Choice | Main question |
|---|---|
| Existing dictionary | Does its original domain fit this corpus? |
| Adapted dictionary | Which changes improve performance here? |
| New dictionary | Can we document coverage and exclusions? |
| Another method | Is the category too context-dependent for word rules? |
Availability is not evidence of suitability.
Next: Whether adapting or building, decide which expressions belong in the dictionary.
Reference: Rauh (2018)
For household costs: distinguish energy bills from a parliamentary bill.
Next: use frequency, KWIC, collocations and keyness to find candidate terms and check their meaning.
Predict: Is a frequent word also used in many different speeches?
frequency counts occurrences; docfreq counts speeches with a match.
Try: Change 6 to 12. Which words describe procedure rather than policy?
Resource: quanteda
Code: head(freq, 6)
frequency: total occurrences. hon appears 6,514 times.docfreq: speeches containing the word. hon appears in 2,826 speeches.rank: order by frequency. group = all: speeches are pooled together.KWIC = keyword in context. Will security always refer to defence?
In pairs: Classify two matches. Then change the window to 12.
Resource: quanteda
Code: head(context_table[, columns], 2)
docname identifies the speech.pre → keyword → post; up to five tokens on each side.international security; row 2 shows security council. Use the surrounding words to interpret the match.Collocations: adjacent words with a strong statistical association.
# C05: Phrases in real speeches (run C01 first)
coll <- textstat_collocations(discovery_tokens, size = 2, min_count = 20)
row_order <- order(coll$lambda, decreasing = TRUE)
coll <- coll[row_order, ]
head(coll, 6)
first_phrase <- phrase(coll$collocation[1])
phrase_context <- kwic(discovery_tokens, first_phrase, window = 5)
head(phrase_context, 2)Try: Raise min_count to 50. Are the leading phrases about household costs?
Resource: quanteda.textstats
Code: head(phrase_context, 2)
sri lanka. It ranks first after we sort the candidate phrases by lambda.Keyness: which words are distinctive of Conservative versus Labour speeches?
# C06: Relative word use (run C01 and C03 first)
party_dfm <- dfm_subset(recap_dfm,
party %in% c("Conservative", "Labour"))
party_dfm <- dfm_group(party_dfm, groups = party)
party_dfm <- dfm_trim(party_dfm, min_termfreq = 1)
keys_con <- textstat_keyness(party_dfm, target = "Conservative")
keys_lab <- textstat_keyness(party_dfm, target = "Labour")
head(keys_con, 3)
head(keys_lab, 3)Compare: Each run uses a different target. Which words stand out for each party?
Resource: quanteda.textstats
Target: Conservative
| Word | chi2 | Con. | Lab. |
|---|---|---|---|
| aircraft | 44.37 | 116 | 31 |
| cyberstalking | 39.62 | 42 | 0 |
| stalking | 33.96 | 36 | 0 |
Target: Labour
| Word | chi2 | Con. | Lab. |
|---|---|---|---|
| waste | 61.43 | 31 | 124 |
| tory | 49.50 | 11 | 73 |
| advertising | 38.72 | 12 | 64 |
Use discovery texts to choose terms; keep separate texts to test the finished rules.
Discovery texts → candidate terms → human review → frozen dictionary
Held-out texts → independent human labels → error analysis
Reference: Rauh (2018)
| Text | Broad bill* |
energy bills |
|---|---|---|
| Energy bills are rising. | 1 | 1 |
| This bill reforms parliament. | 1 | 0 |
| Families cannot afford heating. | 0 | 0 |
A phrase removes one false positive but still misses a relevant passage.
Predict: Which sentence will the phrase dictionary match?
# C07: A complete phrase-lookup example
library(quanteda)
examples <- c("Energy bills are rising.",
"This bill reforms parliament.",
"Families cannot afford heating.")
example_tokens <- tokens(examples, remove_punct = FALSE)
cost_dict <- dictionary(list(costs = "energy bills"))
matched <- tokens_lookup(example_tokens, cost_dict)
cost_counts <- dfm(matched)
as.matrix(cost_counts)Change: Add "cannot afford heating". Which error changes?
Resource: quanteda
Predict: What will bill* retrieve besides bills?
# C08: Wildcards in real speeches (run C01 first)
wild <- kwic(discovery_tokens, "bill*", window = 0)
exact <- kwic(discovery_tokens, c("bill", "bills"),
valuetype = "fixed", window = 0)
c(wildcard = nrow(wild), exact = nrow(exact))
word_counts <- table(wild$keyword)
word_counts <- sort(word_counts, decreasing = TRUE)
head(word_counts, 8)Discuss: Does an exact match solve the substantive ambiguity?
Resource: quanteda
Actual C08 output
bill*: matches any suffix after bill, including billion.bill / bills: 1,470 + 69 = 1,539 matches.The eight most frequent wildcard matches
| Token | Count | Token | Count |
|---|---|---|---|
bill |
1,470 | billions |
18 |
billion |
157 | billericay |
5 |
bills |
69 | bill’s |
3 |
bill's |
24 | billâ |
3 |
Different apostrophes and an apparent encoding artifact remain visible in the output.
These are match counts, not numbers of speeches. Use KWIC to check meaning.
Note: Each dot = one Andersen story at a given n-gram length. X = number of distinct n-grams; rows = 1–4-word sequences. Colour = occurrences of the most frequent n-gram (log scale: yellow = more; purple = fewer).
Reference: Hvitfeldt & Silge (2022)
Predict: Will counts and rates identify the same top speeches?
# C09: Rankings on real speeches (run C01 first)
discovery_ids <- docnames(discovery_tokens)
rates <- subset(comparison, doc_id %in% discovery_ids)
rates <- subset(rates, tokens >= 50)
cols <- c("doc_id", "broad", "tokens", "broad_per100")
raw_order <- order(rates$broad, decreasing = TRUE)
rate_order <- order(rates$broad_per100, decreasing = TRUE)
head(rates[raw_order, cols], 3)
head(rates[rate_order, cols], 3)Compare: What does the 50-token restriction change?
head(..., 3) shows the top three rows; cols selects columns.
| Sorted by | doc_id | broad | tokens | broad_per100 |
|---|---|---|---|---|
| Count | speech_2792 | 20 | 1,349 | 1.483 |
| Count | speech_1673 | 19 | 2,655 | 0.716 |
| Count | speech_0743 | 18 | 1,599 | 1.126 |
| Rate | speech_4435 | 4 | 69 | 5.797 |
| Rate | speech_1551 | 4 | 78 | 5.128 |
| Rate | speech_4917 | 3 | 59 | 5.085 |
Rate = 100 × broad / tokens. broad counts matches to bill*.
20 / 1,349 → 1.48 per 100; 4 / 69 → 5.80 per 100.
More matches does not necessarily mean a higher density.
Let P = positive matches, N = negative matches, L = original token count.
| Summary | Definition | Interpretation |
|---|---|---|
| Net tone | 100 × (P − N) / L | Net matches per 100 tokens |
| Match coverage | 100 × (P + N) / L | Matched sentiment tokens per 100 tokens |
Scoring means turning matches into a number. With P = 3, N = 1 and L = 100: net tone = 2, coverage = 4%. Later, the Proksch et al. case uses a different scoring rule.
Reference: Proksch et al. (2019)
check_texts contains five teaching examples, created in workflow.R and loaded by C01.
| Name | What it checks |
|---|---|
good |
A positive match |
negated |
Whether negation is handled |
unmatched |
No dictionary matches |
balanced |
Positive and negative matches cancel |
empty |
No words; denominator is zero |
Compare: Why do unmatched, balanced, and empty differ?
Reference: Rauh (2018)
P = positive matches; N = negative matches; L = total word tokens. Net = 100 × (P − N) / L; Coverage = 100 × (P + N) / L; Balance = (P − N) / (P + N).
Actual C10 output (rounded)
| doc_id | Net | Coverage | Balance |
|---|---|---|---|
| good | 25 | 25.00 | 1 |
| negated | 20 | 20.00 | 1 |
| unmatched | 0 | 0.00 | NA |
| balanced | 0 | 28.57 | 0 |
| empty | NA | NA | NA |
Net = net_per100; Coverage = coverage_per100; Balance = balance.
What each row means
not.NA).Zero can mean no matches or cancellation; neither proves neutrality.
Predict: Will keeping not make the second sentence negative?
# C11: A deliberate sentiment failure (run C01 first)
negation_tokens <- check_tokens[c("good", "negated")]
as.list(negation_tokens)
negation_dfm <- dfm(negation_tokens)
negation_counts <- dfm_lookup(negation_dfm, toy_sentiment)
as.matrix(negation_counts)
without_stopwords <- tokens_remove(negation_tokens, stopwords("en"))
as.list(without_stopwords)Explain: Both sentences contain good. Does our word-count rule use not to change its category?
Reference: Rauh (2018)
1. Dictionary matches
Both sentences match good. The rule does not use not to reverse the match.
Keep negation words, then add a rule that handles them.
Rules can improve a baseline without resolving every sentence.
Reference: Rauh (2018)
Note: Each story has three points, one per stopword list. X = total words; Y = percentage of word occurrences removed. Colours identify lists; brackets show list sizes; curves show trends. Different lists remove different shares of the same text.
Reference: Denny & Spirling (2018)
Reference: Denny & Spirling (2018)
| Research claim | What this case shows |
|---|---|
| Vocabulary can place parties on a scale | Wordfish estimates relative positions from counts |
| The ordering survives reasonable choices | Check it: this example produces 12 orderings |
| The model explains why a party changed | That causal question needs a different design |
For our dictionaries: do substantive comparisons survive reasonable scoring choices?
Reference: Denny & Spirling (2018)
Predict: Which version will flag more speeches?
Discuss: Scores changed. Do they agree better with human judgments?
Reference: Denny & Spirling (2018)
What is dev? The table created in C12 for inspecting dictionary rules: one row = one speech, with 4,950 speeches.
v1_per1000 and v2_per1000 are its columns: matches per 1,000 words under each dictionary version.
# C15: Visualizing dictionary sensitivity (run C12 first)
# install.packages("ggplot2") # run once
library(ggplot2)
ggplot(dev, aes(x = v1_per1000, y = v2_per1000)) +
geom_point(size = 1, alpha = 0.4, colour = "#314f4f") +
geom_abline(intercept = 0, slope = 1,
colour = "firebrick", linetype = "dashed") +
labs(x = "v1 matches per 1,000 tokens",
y = "v2 matches per 1,000 tokens") +
theme_minimal()Discuss: What can this plot show without human labels?
Reference: Denny & Spirling (2018)
One point = one speech. On the dashed line: unchanged; below: fewer matches in v2; above: more matches in v2. Overlapping points can hide many speeches.
Changed scores show sensitivity, not which dictionary is more valid.
Human labels are evidence to scrutinize, not unquestionable truth.
Reference: Rauh (2018) · Proksch et al. (2019)
A collection of easy examples cannot establish corpus-wide performance.
Extension: ten illustrative labels, 1 = energy-policy relevant, 0 = not relevant.
# C13: Human agreement, not model accuracy
# install.packages("irr") # run once
library(irr)
ratings <- data.frame(
coder_A = c(1, 1, 1, 1, 1, 0, 0, 0, 0, 0),
coder_B = c(1, 1, 1, 1, 0, 1, 0, 0, 0, 0))
table(ratings$coder_A, ratings$coder_B)
mean(ratings$coder_A == ratings$coder_B)
agreement <- kappa2(ratings, weight = "unweighted")
agreement$valueDiscuss: What does kappa (.60) tell us beyond raw agreement (80%)?
Resource: irr
| 20 illustrative passages | Human: relevant | Human: not relevant |
|---|---|---|
| Dictionary: match | 6 true positives | 2 false positives |
| Dictionary: no match | 4 false negatives | 8 true negatives |
The unit is a passage, not a dictionary word.
Before running the code: calculate precision and recall from the preceding table.
# C14: Checking validation arithmetic (run C01 first)
predicted <- validation$predicted
human <- validation$human
TP <- sum(predicted == 1 & human == 1)
FP <- sum(predicted == 1 & human == 0)
FN <- sum(predicted == 0 & human == 1)
precision <- TP / (TP + FP)
recall <- TP / (TP + FN)
F1 <- 2 * TP / (2 * TP + FP + FN)
c(precision = precision, recall = recall, F1 = F1)Discuss: Could we estimate recall by reading only dictionary matches?
Code: c(precision = precision, recall = recall, F1 = F1)
Missed cases matter: reading only matched passages cannot tell us recall.
Two scores can rise together but still disagree: scores of 1, 2, 3 and 11, 12, 13 have perfect correlation.
Reference: Rauh (2018) · Proksch et al. (2019)
| Claim | Evidence needed |
|---|---|
| This sentence is negative | Sentence-level agreement |
| This group is relatively more positive | Group-level measurement validity |
| Sentiment changed over time | Stable measurement across periods |
| Countries differ in conflict | Comparable measures and institutional context |
One successful comparison does not validate every use.
Reference: Rauh (2018) · Proksch et al. (2019)
Reference: Boyle et al. (2025)
Reported average F1: 0.90 development / 0.86 held out.
Instructor’s research-process note: We first tried fine-tuning transformers, but the results were not satisfactory.
Reference: Boyle et al. (2025)
About 15% of abstracts were classified as policy relevant.
Only 8% explicitly mentioned policy or policies.
| Journal aims and scope | Policy-relevant abstracts |
|---|---|
| Explicitly refer to policy | 26.7% |
| Do not explicitly refer to policy | 3.7% |
These are descriptive text patterns, not evidence of policy uptake.
Reference: Boyle et al. (2025)
Reference: Rauh (2018)
| Step | What the study does |
|---|---|
| Dictionary | Combine SentiWS and GermanPolarityClues; manually review terms |
| Context | Add direct two-word negation rules |
| Score | Positive minus negative matches, divided by retained terms |
| Validation | Three people code 1,500 sampled Bundestag sentences |
Compare the original dictionaries and the augmented version with human judgments.
Reference: Rauh (2018)
Reference: Rauh (2018)
Do the original and revised dictionaries recover human-rated evaluations?
Reference: Rauh (2018)
The study validates a measure. It does not explain why politicians become more positive.
Reference: Rauh (2018)
Reference: Proksch et al. (2019)
Question: Is more positive opposition language associated with unanimous passage?
log((P + 0.5) / (N + 0.5)).Human-coded European Parliament texts provide a separate measurement check.
Reference: Proksch et al. (2019)
Compare human and automated tone for the same speaker groups.
Reference: Proksch et al. (2019)
Shared category names do not guarantee comparable numerical scales.
Reference: Proksch et al. (2019)
Reference: Proksch et al. (2019)
| Concept | What the evidence must establish |
|---|---|
| Sentiment | Evaluative language in a defined unit |
| Stance | Support or opposition toward a specified target |
| Conflict | A substantive relationship between actors or positions |
Negative words can describe a social problem without attacking another party.
Reference: Proksch et al. (2019)
Application: use the same construction, scoring and validation principles.
LIWC = Linguistic Inquiry and Word Count.
Example: 4 category matches in 100 words = 4%.
A category percentage measures word use; interpretation needs validation.
Resource: LIWC
| Resource | Role | Access |
|---|---|---|
| LIWC application | Runs built-in or custom dictionaries | Licensed software |
| Official LIWC dictionaries | Proprietary word-category resources | Subject to license |
| quanteda + our dictionary | R matching with our own rules | No LIWC purchase |
Importing a file does not grant a license to use its dictionary.
Import our ten-word teaching dictionary, not an official LIWC dictionary.
# C16: Custom dictionary (run C01; stay in W4/classroom)
library(quanteda)
own <- dictionary(file = "Dictionaries/teaching_affect.dic", format = "LIWC")
mini <- tokens(c("Good policy.", "A bad crisis."), remove_punct = TRUE)
matched <- tokens_lookup(mini, own, capkeys = FALSE)
hits <- dfm(matched)
counts <- as.matrix(hits)
counts
word_count <- ntoken(mini)
100 * counts / word_countDiscuss: Why divide by all word tokens, not just dictionary matches?
Resource: quanteda
Reference: Eichorst & Lin (2019)
Each column is a party program; each row is a dictionary term. Darker cells indicate larger logged counts; white indicates zero.
Reference: Eichorst & Lin (2019)
Copy the whole block into R. Requires quanteda.
Teaching score = 100 × (C − V) / L
C = concrete matches; V = vague matches; L = all word tokens.
This is concrete percentage − vague percentage, in percentage points.
text1: 100 × (0 − 1) / 5 = −20; text2: 100 × (1 − 0) / 5 = 20.
Adapted from Eichorst & Lin (2019): the paper excludes stopwords from the denominator and rescales the difference to 0–1. Our example does neither.
Reference: Eichorst & Lin (2019)
Reference: Lin (2023)
Reference: Lin et al. (2020)
Optional extension: return to finding candidates, now with POS → candidate terms → human review.
# O01: POS candidates (model download on first run)
# install.packages("udpipe") # run once before class
library(udpipe)
dir.create("udpipe_models", showWarnings = FALSE)
info <- udpipe_download_model("english-ewt",
model_dir = "udpipe_models", overwrite = FALSE)
model <- udpipe_load_model(info$file_model)
text <- "Housing costs are unaffordable."
annotation <- udpipe_annotate(model, x = text, parser = "none")
ann <- as.data.frame(annotation)
ann[, c("token", "lemma", "upos")]Discuss: Which tokens would you keep as dictionary candidates?
Resource: UDPipe
| Code | What it does |
|---|---|
library(udpipe) |
Loads the installed R package. |
dir.create(...) |
Creates a folder for the model file. |
udpipe_download_model |
Downloads model data; keeps an existing file. |
udpipe_load_model |
Reads that model into memory. |
udpipe_annotate |
Applies the model to our sentence. |
as.data.frame(annotation) |
Converts the result into a table. |
ann[, c("token", "lemma", "upos")] keeps all rows and selects three columns.
Resource: UDPipe
| token | lemma | upos |
|---|---|---|
| Housing | housing | ADJ |
| costs | cost | NOUN |
| are | be | AUX |
| unaffordable | unaffordable | ADJ |
| . | . | PUNCT |
token: observed form · lemma: predicted base form · upos: predicted word class
Discuss: Would you accept Housing → ADJ? Which words are useful dictionary candidates?
| Approach | Role of POS and token relations |
|---|---|
| Candidate filtering | Keep nouns or adjectives for human review. |
| TextRank | Filter candidates with POS; rank words in a co-occurrence network. |
| Dependency-based extraction | Follow grammatical links between words, such as a noun and its modifier. |
Co-occurrence: words appear near each other. Dependency: a parser predicts a grammatical link.
Our O01 used parser = "none"; dependency extraction needs parsing enabled.
Resource: UDPipe
Obama’s 2013 inaugural address, included in quanteda. Run O01 first to load model.
library(quanteda)
# install.packages(c("textrank", "igraph")) # run once
library(textrank)
speech <- corpus_subset(data_corpus_inaugural, Year == 2013)
text <- as.character(speech)
ann <- as.data.frame(udpipe_annotate(model, x = text,
parser = "none"))
keep <- ann$upos %in% c("NOUN", "ADJ")
result <- textrank_keywords(ann$lemma, relevant = keep,
ngram_max = 2, sep = " ")
scores <- sort(result$pagerank$vector, decreasing = TRUE)
round(head(scores, 6), 4)Actual output: Obama 2013, English EWT model
new is broad; some endure forms were tagged as nouns.cooc = 2: the pair occurs twice in this speech.
Resource: R code
Print the top five
library(igraph)
top5 <- names(head(scores, 5))
links <- subset(pairs,
term1 %in% top5 | term2 %in% top5)
g <- graph_from_data_frame(links, directed=FALSE)
set.seed(4)
plot(g, layout = layout_with_fr(g),
vertex.size = 6, vertex.color = "#7cae96",
vertex.label.color = "#314f4f",
vertex.label.cex = 1.5,
edge.width = E(g)$cooc)Top five and their neighbours
Try: change 5 to 10 and rerun.
Thicker lines = more occurrences of a pair. Scores on the left come from the full network.
Optional Python demo. POS labels word classes; NER identifies names and their types.
# Install once in a terminal: python -m pip install flair
from flair.data import Sentence
from flair.models import SequenceTagger
tagger = SequenceTagger.load("ner-fast")
sentence = Sentence(
"Barack Obama spoke at the United Nations in New York.")
tagger.predict(sentence)
for entity in sentence.get_spans("ner"):
print(entity.text, entity.get_label("ner").value)For R users: I developed flaiR, an R package that lets you access Flair NLP through R functions.
Resource: Flair · Python code
“Barack Obama spoke at the United Nations in New York.”
| Detected name | Label | Interpretation |
|---|---|---|
| Barack Obama | PER | Person |
| United Nations | ORG | Organization |
| New York | LOC | Location |
Resource: Flair
The minister praised the NHS but criticized its funding.
Seed concept → suggested terms → corpus examples → researcher review
These tools assist dictionary development; they do not certify validity.
| Choice | Example issue |
|---|---|
| Segmentation | Does a relevant expression remain intact? |
| Script normalization | Are variants merged appropriately? |
| Entity aliases | Are abbreviations linked to the correct institution? |
| Negation and context | Does the phrase express support, criticism, or quotation? |
Validate in the language and political setting of the project.
Reference: Proksch et al. (2019)
First, label the sentiment expressed in each sentence.
| Method | How it assigns labels |
|---|---|
| Dictionary lookup | Matches words to predefined categories. |
| Transformer classifier | Predicts labels using a trained model. |
| LLM prompting | Follows a prompt and coding instructions. |
Which sentences need more than word matching?
Resource: R code
A hypothetical timing comparison, not a benchmark.
| Method | Time for all five |
|---|---|
| Dictionary lookup | 0.02 seconds |
| Transformer classifier | 0.30 seconds |
| LLM prompting | 3.00 seconds |
Would faster labels answer our research question? What validation is still needed?
Resource: R code · Transformers
Return to our opening question, or choose your own research question:
For our opening question: What extra rules would identify negative evaluations of household conditions?
Would a dictionary be a good choice for your project?
Discuss in pairs for 2 minutes: choose a corpus and explain your decision.
Reference: Denny & Spirling (2018) · Rauh (2018) · Proksch et al. (2019)
Optional challenge: Add one justified energy-policy expression.
# O02: Your own dictionary (run C01 first)
my_dict <- dictionary(list(energy = c("energy", "gas bills")))
matched <- tokens_lookup(discovery_tokens, my_dict)
my_dfm <- dfm(matched)
my_counts <- as.numeric(my_dfm[, "energy"])
sum(my_counts > 0)
my_context <- kwic(discovery_tokens, "energy", window = 6)
head(my_context, 3)In pairs: Show one supporting passage and one possible false positive.
Illustrative arithmetic, not estimated performance on Parliament.
W5 (October 9): no class. W6 (October 16): human coding and classification.
| Prepare now | Develop in W6 |
|---|---|
| Label five texts with a short codebook | Annotation quality and coder agreement |
| Explain one uncertain decision | Refine definitions before training |
| Keep dictionary predictions separate | Train and evaluate Naive Bayes / SVM |
About 10 minutes of preparation; no model training or upload.
45-60 minutes of core practice.
Denny, M. J., and A. Spirling (2018). “Text Preprocessing for Unsupervised Learning: Why It Matters, When It Misleads, and What to Do about It.” Political Analysis 26(2): 168–189. DOI
Rauh, C. (2018). “Validating a Sentiment Dictionary for German Political Language: A Workbench Note.” Journal of Information Technology & Politics 15(4): 319–343. DOI
Proksch, S.-O., W. Lowe, J. Wäckerle, and S. Soroka (2019). “Multilingual Sentiment Analysis: A New Approach to Measuring Conflict in Legislative Speeches.” Legislative Studies Quarterly 44(1): 97–131. DOI
Further readings
Grimmer, Roberts & Stewart (2022). Text as Data. Chapters 5 and 16.
Hvitfeldt & Silge. Supervised Machine Learning for Text Analysis in R. Online chapters on tokenization and stopwords.
Eichorst & Lin (2019). “Resist to Commit.” The Journal of Politics 81(1): 15–32.
Boyle et al. (2025). “Catalysts for progress? Mapping policy insights from energy research.” Energy Research & Social Science 121: 103955.
Software documentation
quanteda: dictionaries, KWIC and text matching.
quanteda.textstats: frequency, collocations and keyness.
UDPipe / TextRank: annotation and keyword extraction.
ggplot2: plotting dictionary scores.
Flair: named entity recognition (Python).
irr: agreement between coders.
LIWC: categories and word-use measures.
Resource: Bibliography