W4 Lab: Worked Answers

LIWC-format dictionaries, custom policy rules, and a W6 preview

Let’s work through the results from our lab. For the validation exercise, we’ll use the 24 example sentences and their supplied labels. Compare these outputs with your own, and read the sentences behind any differences. Return to the student handout.

# install.packages("quanteda")  # run once before class
library(quanteda)
# Find the lab folder; if asked, select workflow.R inside W4/lab.
paths <- c("W4/lab/workflow.R", "lab/workflow.R",
           "../lab/workflow.R", "../workflow.R", "workflow.R")
found <- paths[file.exists(paths)]
if (length(found) == 0) found <- file.choose()
setwd(dirname(normalizePath(found[1])))
source("workflow.R")  # load the data and prepared examples

Run the setup block once, then follow sections 1–4 in order. If a file picker opens, choose workflow.R inside the extracted lab. The party comparison is optional.

1. LIWC format and sentiment

toy_sentiment
Dictionary object with 2 key entries.
- [positive]:
  - excellent, good, improve, success, successful
- [negative]:
  - bad, crisis, failure, harm, harmful
texts <- c("The policy is good.", "The policy is not good.")
example_tokens <- tokens(texts, remove_punct = TRUE)
example_dfm <- dfm(example_tokens)
example_hits <- dfm_lookup(example_dfm, toy_sentiment)
counts <- convert(example_hits, to = "data.frame")
counts
  doc_id positive negative
1  text1        1        0
2  text2        1        0
word_count <- ntoken(example_tokens)
positive <- counts$positive
negative <- counts$negative
100 * (positive - negative) / word_count
text1 text2 
   25    20 
# The setup above created these five named examples.
check_texts
                             good                           negated 
            "The policy is good."         "The policy is not good." 
                        unmatched                          balanced 
      "The minister spoke today." "A good plan with a bad outcome." 
                            empty 
                               "" 
check_tokens <- tokens(check_texts, remove_punct = TRUE)
check_dfm <- dfm(check_tokens)
check_hits <- dfm_lookup(check_dfm, toy_sentiment)
convert(check_hits, to = "data.frame")
     doc_id positive negative
1      good        1        0
2   negated        1        0
3 unmatched        0        0
4  balanced        1        1
5     empty        0        0
# Prepared by workflow.R using the three formulas explained in class.
check_scores
     doc_id tokens positive negative net_per100 coverage_per100 balance
1      good      4        1        0         25        25.00000       1
2   negated      5        1        0         20        20.00000       1
3 unmatched      4        0        0          0         0.00000      NA
4  balanced      7        1        1          0        28.57143       0
5     empty      0        0        0         NA              NA      NA
  no_match
1    FALSE
2    FALSE
3     TRUE
4    FALSE
5     TRUE
# TRUE counts as 1; the mean is the fraction without a match.
mean(sentiment$no_match)
[1] 0.8336

Category 1 is positive; category 2 is negative. The dictionary matches words without interpreting who or what is being evaluated. The prepared check_scores table uses the original dictionary; changing a dictionary requires rerunning the scoring code.

We get 25 net matches per 100 tokens for good and 20 for not good. Our rule counts good as positive in both sentences; adding not increases the denominator. An unmatched text has zero coverage. A text with balanced positive and negative counts has zero net tone but nonzero coverage. An empty text has a zero denominator, so its rates are missing. When we apply the dictionary to the speeches, a text with no matches may still express an opinion in words our list does not include.

Our ten-word teaching dictionary uses LIWC’s .dic file format. We apply it with quanteda and calculate our own score, so the result differs from LIWC’s Tone measure. See LIWC Analysis and quanteda import documentation.

2. Build an energy-policy dictionary

policy_entries <- read.csv("Dictionaries/energy_policy.csv")
v1_entries <- subset(policy_entries, version == "v1")
energy_v1 <- dictionary(list(energy = v1_entries$pattern))
v2_entries <- subset(policy_entries, version == "v2")
energy_v2 <- dictionary(list(energy = v2_entries$pattern))
contexts <- kwic(discovery_tokens, pattern = c("power", "gas", "bill*"),
                 valuetype = "glob", window = 8)
head(contexts, 8)
Keyword-in-context with 8 matches.                                                                               
  [speech_0006, 32]     opinion. on the question whether any additional | power
  [speech_0006, 37]        whether any additional power is given by the | bill 
 [speech_0006, 233]                   the case. the third object of the | bill 
 [speech_0006, 254]         acquired for public purposes. in effect the | bill 
  [speech_0008, 24] special act undertakers will be over-ridden by this | bill 
  [speech_0019, 38]          were very widely published and there was a | power
 [speech_0019, 165]   considered these proposals very carefully. he had | power
  [speech_0021, 36]     it would facilitate the further progress of the | bill 
                                                                 
 | is given by the bill to local authorities                     
 | to local authorities in blighted areas—not war-damaged areas—i
 | is to regulate the compensation payable in respect            
 | provides that where land is compulsorily acquired for         
 | , particularly as regards special provisions which may        
 | , which we can be quite certain was                           
 | under the act to submit them to the                           
 | if we were to have a reply to                                 
example_context <- kwic(discovery_tokens, pattern = "bill*", window = 8)
# The supplied corpus contains matches to bill*.
first_id <- example_context$docname[1]
original <- subset(speeches, doc_id == first_id)
original$text
[1] "I will try again, but I repudiate the idea that I had any irritation at all: that is a matter of opinion. On the question whether any additional power is given by the Bill to local authorities in blighted areas—not war-damaged areas—I say the answer is, \"Yes.\" To start with, it affords greater expedition in acquisition. It absolves them from personal notifications and allows them to give block notifications of an intention to acquire. All the local authorities, so far as I know, have come to the conclusion that this more expeditious procedure will save them some three months—I am not pinning myself down to an exact period—in the compulsory acquisition of areas for redevelopment in their blighted territory. That is the first point. The second point is that for the period of two years—I assume that two years is the time when the re-examination of the subsidies will take place—there is already provision in the Housing Acts whereby authorities who have exceptional costs for redevelopment may get those exceptional costs made up to the extent of about £15 per house. Thirdly, I intimated that the Chancellor had announced that when the block grant came up for revision account would be taken of all the necessities of the case. The third object of the Bill is to regulate the compensation payable in respect of land or property acquired for public purposes. In effect the Bill provides that where land is compulsorily acquired for public purposes under any enactment within a period of five years from November, 1944, the compensation will be assessed by reference to prices current at 31st March, 1939. The general conception is that the land is projected back into a March, 1939, setting, and the effect will be that any changes in the land or buildings since 1939 will be taken into account in fixing the March, 1939, price. For example, if there had been improvements to a property since March, 1939, 258 they would be taken into account. Owner-occupiers of buildings and agricultural land will be able to qualify for supplementary payments up to a maximum of 30 per cent. over the 1939 value of the buildings or land which are compulsorily acquired from them. The need for this 30 per cent. increase in the case of the owner-occupier is due to the cost of replacement. The owner-occupier must find himself another house in which to live, and the Government, as a result of the compromise last year, fixed upon 30 per cent. as an addition to March, 1939, prices. Again, where improvements have been carried out since March, 1939, to buildings or agricultural land at prices greater than March, 1939, prices the owner is entitled in certain circumstances to a reasonable addition for the cost of these improvements, and should there be any disagreement about the supplementary compensation the dispute goes to arbitration."

In these contexts, power may refer to political authority, bill* may match legislation, and gas may refer to tear gas. We need to read the passage to decide whether each match concerns energy. If we propose another version, we should be able to point to development examples that explain each change.

Change one rule, then rerun

V1 includes power, which can mean political authority. Replace it with wind farm*, then compare the old and revised dictionaries on two teaching sentences:

energy_revised <- dictionary(list(
  energy = c("energy", "wind farm*", "electric*", "gas", "bill*")
))
revision_texts <- c(
  wind = "The government should approve new wind farms.",
  authority = "Parliament must retain the power to question ministers."
)
revision_tokens <- tokens(revision_texts, remove_punct = FALSE)

before_tokens <- tokens_lookup(revision_tokens, energy_v1, capkeys = FALSE)
before_counts <- convert(dfm(before_tokens), to = "data.frame")
after_tokens <- tokens_lookup(revision_tokens, energy_revised, capkeys = FALSE)
after_counts <- convert(dfm(after_tokens), to = "data.frame")

data.frame(sentence = before_counts$doc_id,
           before = before_counts$energy,
           after = after_counts$energy)
   sentence before after
1      wind      0     1
2 authority      1     0

Read the result: wind changes from 0 to 1; authority changes from 1 to 0. Our change fixes both examples. Next, we’ll check other sentences to see which mistakes remain.

Your own revision may differ from this example. To explain it, show the entry you changed, a passage that supports the change, and a case it might still get wrong. We can use kwic(discovery_tokens, phrase("wind farm*")) as a starting point for checking a phrase in the speeches.

For sections 3–4, we’ll use the supplied energy_v2 so we can compare our results. Your own changes remain in energy_revised; the scores below refer to energy_v2.

3. Development errors

# Read the labelled teaching examples and generate predictions explicitly.
synthetic <- read.csv("Data/synthetic_validation.csv")
example_corpus <- corpus(synthetic, text_field = "text", docid_field = "doc_id")
example_tokens <- tokens(example_corpus, remove_punct = FALSE)

# Apply v1, then v2. Each matched phrase counts once.
v1_matches <- tokens_lookup(example_tokens, energy_v1, capkeys = FALSE)
v1_dfm <- dfm(v1_matches)
v1_binary <- dfm_weight(v1_dfm, scheme = "boolean")
v1_predictions <- convert(v1_binary, to = "data.frame")
synthetic$v1 <- v1_predictions$energy

v2_matches <- tokens_lookup(example_tokens, energy_v2, capkeys = FALSE)
v2_dfm <- dfm(v2_matches)
v2_binary <- dfm_weight(v2_dfm, scheme = "boolean")
v2_predictions <- convert(v2_binary, to = "data.frame")
synthetic$v2 <- v2_predictions$energy

dev <- subset(synthetic, split == "development")
errors <- subset(dev, v1 != human)
errors[, c("doc_id", "text", "human", "v1", "v2")]
   doc_id                                                    text human v1 v2
4   dev04                  Subsidies should support solar panels.     1  0  1
5   dev05           The government should approve new wind farms.     1  0  1
6   dev06                   We should reduce fossil fuel imports.     1  0  1
7   dev07 Parliament must retain the power to question ministers.     0  1  0
8   dev08                  This bill reforms criminal sentencing.     0  1  0
9   dev09                    Police used tear gas at the protest.     0  1  0
10  dev10              The minister brought energy to the debate.     0  1  1

The development examples show why we might remove standalone power, gas and bill*, and add expressions about renewable energy, solar panels, wind farms and fossil fuels. V2 fixes several mistakes, but it still matches energy when the sentence describes a person’s enthusiasm. To address that case, we would need a rule that uses the surrounding words, then more examples to check whether it works.

4. Synthetic test results

test <- subset(synthetic, split == "test")
# 0 = not relevant; 1 = relevant. Keep both categories in the table.
predicted <- factor(test$v2, levels = c(0, 1))
human <- factor(test$human, levels = c(0, 1))
table(predicted, human)
         human
predicted 0 1
        0 5 2
        1 1 4
errors <- subset(test, v2 != human)
errors[, c("doc_id", "text", "human", "v2")]
   doc_id                                                  text human v2
17 test05 The closure of coal mines requires a transition plan.     1  0
18 test06               Heating tariffs must remain affordable.     1  0
22 test10                 Her energy transformed the committee.     0  1

Calculate the counts and fractions explicitly:

predicted <- test$v2
human <- test$human
TP <- sum(predicted == 1 & human == 1)
FP <- sum(predicted == 1 & human == 0)
FN <- sum(predicted == 0 & human == 1)
TN <- sum(predicted == 0 & human == 0)
precision <- TP / (TP + FP)
recall <- TP / (TP + FN)
F1 <- 2 * TP / (2 * TP + FP + FN)
c(TP = TP, FP = FP, FN = FN, TN = TN)
TP FP FN TN 
 4  1  2  5 
c(precision = precision, recall = recall, F1 = F1)
precision    recall        F1 
0.8000000 0.6666667 0.7272727 

These denominators are nonzero. In other data, report an undefined rate as NA when its denominator is zero.

For v2, we get TP = 4, FP = 1, FN = 2, TN = 5. Precision = 0.80, recall = 0.667, F1 = 0.727. V1 finds the same four relevant sentences but also flags four irrelevant ones. V2 reduces those false positives to one: figurative energy. It still misses the sentences about coal-mine closures and heating tariffs.

These results describe the 12 test sentences. To evaluate our dictionary on parliamentary speeches, we would need human labels for those speeches.

If we read only the matches, we would never find the two missed sentences and could not calculate recall. When relevant texts are rare, predicting 0 for every text can give high accuracy while finding none of them. F1 helps us focus on precision and recall, but we should also report the four counts and explain how we chose the texts. Without predicted positives, precision is undefined; without relevant texts in our labeled sample, recall is undefined.

We could now add coal or heating, but that would use information from the test sentences to change our dictionary. We would need a fresh test set to evaluate that new version.

W6 preview: labels before models

W5 (October 9) is a holiday. W6 (October 16), Human Coding and Document Classification, covers annotation quality, inter-coder reliability, training/test sets and performance; its lab introduces Naive Bayes, SVM and cross-validation.

For the five-speech warm-up, explain how you reached each label. Point to a passage, apply our inclusion/exclusion rules, and note anything you are unsure about. We’ll compare difficult cases and refine the labeling rules in W6. No model training or extra submission is required now.

We’ll use these five speeches for discussion. We’ll need more labeled texts for training and evaluation, with a separate test set that we leave aside while developing the model. Make your own reading-based judgments before looking at dictionary predictions.

Real-text validation in W6

Use the three labeling rules in the handout for the five-speech preview. We’ll work through independent coding and evaluation in W6. The parliamentary speeches have no supplied human labels; we’ll need to collect those judgments before we can check the dictionary’s performance on them.

Optional: compare parties

# quanteda adds counts within each party.
party_matches <- tokens_lookup(policy_tokens, energy_v2, capkeys = FALSE)
party_dfm <- dfm(party_matches)
party_dfm <- dfm_group(party_dfm, groups = party)

# Group original word counts separately to preserve the denominator.
party_words <- dfm(toks)
party_words <- dfm_group(party_words, groups = party)

party_summary <- convert(party_dfm, to = "data.frame")
party_summary$tokens <- ntoken(party_words)
party_summary$per1000 <- 1000 * party_summary$energy / party_summary$tokens
head(party_summary, 6)
                              doc_id energy tokens    per1000
1 Alliance Party of Northern Ireland      0     44 0.00000000
2   Communist Party of Great Britain      0     36 0.00000000
3                       Conservative    151 447158 0.33768824
4          Democratic Unionist Party      1  11814 0.08464534
5   Green Party of England and Wales      2    217 9.21658986
6                        Independent      1   1394 0.71736011

We use dfm_group() to add counts within each party, ntoken() to count the original words, and convert() to display the result as a table. For each party, we divide total matches by total original word counts and multiply by 1,000. This lets us compare speeches of different total lengths. To interpret party differences, we still need to check the dates, topics and dictionary matches; the rate alone does not tell us whether a party supports a policy.

R session information

sessionInfo()
R version 4.4.1 (2024-06-14)
Platform: aarch64-apple-darwin20
Running under: macOS 26.7.1

Matrix products: default
BLAS:   /Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/lib/libRblas.0.dylib 
LAPACK: /Library/Frameworks/R.framework/Versions/4.4-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.0

locale:
[1] C.UTF-8/C.UTF-8/C.UTF-8/C/C.UTF-8/C.UTF-8

time zone: Asia/Taipei
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
[1] quanteda_4.3.1

loaded via a namespace (and not attached):
 [1] digest_0.6.39     fastmatch_1.1-8   fastmap_1.2.0     xfun_0.57        
 [5] Matrix_1.7-5      lattice_0.22-9    magrittr_2.0.4    knitr_1.51       
 [9] htmltools_0.5.9   rmarkdown_2.30    lifecycle_1.0.5   cli_3.6.5        
[13] stopwords_2.4     grid_4.4.1        compiler_4.4.1    tools_4.4.1      
[17] evaluate_1.0.5    Rcpp_1.1.1        yaml_2.3.12       otel_0.2.0       
[21] rlang_1.1.7       jsonlite_2.0.0    htmlwidgets_1.6.4 stringi_1.8.7