library(readr)
library(dplyr)
library(tibble)
library(purrr)
library(stringr)
library(tidytext)
library(tokenizers)
library(udpipe)
library(spacyr)
library(SnowballC)
library(hunspell)
source("R/use-spacy.R")
sentences <- read_csv(
"data/workforce/workforce_sentences.csv",
na = character(),
col_types = cols(
sentence_id = col_character(),
document_id = col_character(),
source_line = col_character(),
text = col_character(),
reference_label = col_character(),
uncertainty = col_character(),
annotator_id = col_character(),
rationale = col_character(),
codebook_version = col_character(),
codebook_hash = col_character(),
derived = col_character(),
transformation = col_character()
)
)
pipeline <- use_project_spacy()
spacy_info <- spacy_pipeline_version()
spacy_info_table <- tibble(
item = names(spacy_info),
value = unname(unlist(spacy_info))
)
udpipe_parser <- udpipe_load_model(
"data/treebank/en_ewt-500-parser.udpipe"
)
vertical <- sentences$text |>
tokenize_words(lowercase = FALSE, strip_punct = FALSE) |>
map_chr(\(tokens) paste(tokens, collapse = "\n"))
udpipe_parsed <- udpipe_annotate(
udpipe_parser,
x = vertical,
doc_id = sentences$sentence_id,
tokenizer = "vertical",
tagger = "default",
parser = "default"
) |>
as.data.frame() |>
as_tibble()
spacy_text <- sentences$text
names(spacy_text) <- sentences$sentence_id
spacy_parsed <- spacy_parse(
spacy_text,
pos = TRUE,
lemma = TRUE,
entity = TRUE,
dependency = TRUE,
nounphrase = TRUE
) |>
as_tibble()Choosing dictionary forms
Compare teaching lemmas with a released pipeline
Stemmed forms shorten the vocabulary, but strings such as requir and provid give the team pause. Those strings help a computer compare forms, yet they look broken on a public report.
The team wants a cleaner label for words such as is, provided, and classes. It needs to know when a model’s dictionary form is better than a rule-chopped stem, and when a model can still guess badly.
A lemma is the dictionary form of a word, the form a reader might look up. Lemmatization chooses that form in context. This lesson compares a tiny UDPipe teaching model with a released spaCy English pipeline.
The Riverton Workforce Lab, its job board, and its training flyer are fictional and were created for teaching. Both models run locally; the lesson does not download anything while it runs.
By the end of this lesson, you will be able to:
- define a lemma in plain language;
- compare a lemma with a Snowball stem;
- see how tokenization changes the comparison set;
- name what a dictionary proxy can and cannot measure; and
- separate raw dictionary flags from broken-string counts.
Load both lemmatizers
Two local lemmatizers need the same sentence file and a shared set of table tools. The code loads the CSV reader, tokenizers, the UDPipe teaching model, the spaCy bridge, SnowballC stems, and hunspell dictionary checks. UDPipe receives one token per line and returns lemmas from its trained tagger, which combines a model with dictionary and guesser components. spaCy receives the raw sentences through the project helper use_project_spacy().
| Pipeline field | Value |
|---|---|
| name | core_web_sm |
| version | 3.8.0 |
| lang | en |
| license | MIT |
| spacy | 3.8.7 |
The spaCy table records the exact local pipeline: core_web_sm version 3.8.0 under the MIT license, running with spaCy 3.8.7. The two tools return slightly different token counts for the same 28 sentences: 178 from UDPipe and 179 from spaCy. The extra comparison problem is driver's, which spaCy splits into driver and 's while the vertical tokenizer keeps it whole. That word type has no spaCy lemma to compare, and the tables below drop it.
Compare lemmas with stems
The next table uses word types that appear in the Riverton sentences and that both parsers emit as tokens. A stem and a lemma can agree, but they answer different questions.
known_to_hunspell <- \(words) map_lgl(hunspell(words), \(result) length(result) == 0L)
vocabulary_words <- sentences |>
select(sentence_id, text) |>
unnest_tokens(word, text) |>
distinct(word) |>
arrange(word)
udpipe_lookup <- udpipe_parsed |>
filter(upos != "PUNCT") |>
transmute(
word = str_to_lower(token),
udpipe_lemma = str_to_lower(lemma)
) |>
group_by(word) |>
summarise(udpipe_lemma = first(udpipe_lemma), .groups = "drop")
spacy_lookup <- spacy_parsed |>
filter(pos != "PUNCT") |>
transmute(
word = str_to_lower(token),
spacy_lemma = str_to_lower(lemma)
) |>
group_by(word) |>
summarise(spacy_lemma = first(spacy_lemma), .groups = "drop")
same_word_lemmas <- vocabulary_words |>
left_join(udpipe_lookup, by = "word") |>
left_join(spacy_lookup, by = "word")
comparison_words <- c(
"is",
"provided",
"required",
"classes",
"shifts",
"applicants",
"training",
"schedules",
"paid",
"pays"
)
lemma_stem_comparison <- same_word_lemmas |>
filter(word %in% comparison_words) |>
mutate(
stem = wordStem(word, language = "en"),
udpipe_known = known_to_hunspell(udpipe_lemma),
spacy_known = known_to_hunspell(spacy_lemma)
) |>
arrange(match(word, comparison_words))| Word | UDPipe lemma | spaCy lemma | Snowball stem | UDPipe lemma known | spaCy lemma known |
|---|---|---|---|---|---|
| is | be | be | is | TRUE | TRUE |
| provided | provid | provide | provid | FALSE | TRUE |
| required | requi | require | requir | FALSE | TRUE |
| classes | classe | class | class | FALSE | TRUE |
| shifts | shift | shift | shift | TRUE | TRUE |
| applicants | applicants | applicant | applic | TRUE | TRUE |
| training | training | training | train | TRUE | TRUE |
| schedules | schedule | schedule | schedul | TRUE | TRUE |
| paid | paid | pay | paid | TRUE | TRUE |
| pays | pays | pay | pay | TRUE | TRUE |
is shows why a lemma can be better than a stem. A stemmer leaves is alone, while both parsers choose be. The sharper contrast is provided: the UDPipe teaching model returns provid, while spaCy returns provide. For classes, UDPipe returns classe, while spaCy returns class.
The table is deliberately simple, but it hides context. A word type can receive different lemmas in different sentences, which is why lemmatization is contextual in the first place. The next check shows the type rows where at least one tool gave more than one lemma.
lemma_variation <- bind_rows(
udpipe_parsed |>
filter(upos != "PUNCT") |>
transmute(system = "UDPipe", word = str_to_lower(token), lemma = str_to_lower(lemma)),
spacy_parsed |>
filter(pos != "PUNCT") |>
transmute(system = "spaCy", word = str_to_lower(token), lemma = str_to_lower(lemma))
) |>
group_by(system, word) |>
summarise(
distinct_lemmas = n_distinct(lemma),
lemmas = paste(sort(unique(lemma)), collapse = ", "),
occurrences = n(),
.groups = "drop"
) |>
filter(distinct_lemmas > 1L) |>
arrange(system, word)| System | Word type | Distinct lemmas | Lemmas seen | Occurrences |
|---|---|---|---|---|
| UDPipe | evening | 2 | even, evening | 2 |
| UDPipe | paid | 2 | paid, pay | 3 |
| UDPipe | skills | 2 | skill, skills | 2 |
| spaCy | skills | 2 | skill, skills | 2 |
This is not an equal contest. The UDPipe model was trained in this project on a 500-sentence excerpt for teaching. The spaCy pipeline is a released English pipeline packaged for general use. The methods differ too: UDPipe lemmatizes through its trained tagger together with dictionary and guesser machinery, while spaCy’s English lemmatizer uses lookup tables and part-of-speech rules. The tools, tokenization, and training setup differ, so this lesson cannot turn this contrast into a causal story about training size alone.
Count dictionary failures
A cheap screen asks whether each lemma appears in hunspell’s dictionary. It answers one specific question: did the model return a string that is not an English word? It cannot tell a correct lemma that the dictionary happens not to hold, such as riverton, from a genuine error. It is also blind to a wrong lemma that is a real word. If a model returned leave as the lemma of left in the phrase the left column, this check would pass it. The counts below measure broken strings, not correctness.
compared_word_lemmas <- same_word_lemmas |>
filter(!is.na(udpipe_lemma), !is.na(spacy_lemma)) |>
mutate(
udpipe_known = known_to_hunspell(udpipe_lemma),
spacy_known = known_to_hunspell(spacy_lemma),
udpipe_flagged = !udpipe_known,
spacy_flagged = !spacy_known,
coverage_or_case_artifact = word %in% c("october", "riverton"),
udpipe_broken_string = udpipe_flagged & !coverage_or_case_artifact,
spacy_broken_string = spacy_flagged & !coverage_or_case_artifact
)
lemma_quality <- tibble(
system = c("UDPipe teaching model", "spaCy released pipeline"),
word_types_compared = c(nrow(compared_word_lemmas), nrow(compared_word_lemmas)),
raw_dictionary_flags = c(
sum(compared_word_lemmas$udpipe_flagged),
sum(compared_word_lemmas$spacy_flagged)
),
coverage_or_case_artifacts = c(
sum(compared_word_lemmas$udpipe_flagged & compared_word_lemmas$coverage_or_case_artifact),
sum(compared_word_lemmas$spacy_flagged & compared_word_lemmas$coverage_or_case_artifact)
),
broken_string_flags = c(
sum(compared_word_lemmas$udpipe_broken_string),
sum(compared_word_lemmas$spacy_broken_string)
)
)
flagged_comparison <- compared_word_lemmas |>
filter(udpipe_flagged | spacy_flagged) |>
select(
word,
udpipe_lemma,
spacy_lemma,
udpipe_flagged,
spacy_flagged,
coverage_or_case_artifact,
udpipe_broken_string,
spacy_broken_string
)| System | Word types compared | Raw dictionary flags | Coverage or case artifacts | Broken-string flags |
|---|---|---|---|---|
| UDPipe teaching model | 95 | 8 | 2 | 6 |
| spaCy released pipeline | 95 | 2 | 2 | 0 |
| Word | UDPipe lemma | spaCy lemma | UDPipe flagged | spaCy flagged | Coverage or case artifact | UDPipe broken string | spaCy broken string |
|---|---|---|---|---|---|---|---|
| classes | classe | class | TRUE | FALSE | FALSE | TRUE | FALSE |
| diploma | diploman | diploma | TRUE | FALSE | FALSE | TRUE | FALSE |
| includes | includ | include | TRUE | FALSE | FALSE | TRUE | FALSE |
| need | ne | need | TRUE | FALSE | FALSE | TRUE | FALSE |
| october | october | october | TRUE | TRUE | TRUE | FALSE | FALSE |
| provided | provid | provide | TRUE | FALSE | FALSE | TRUE | FALSE |
| required | requi | require | TRUE | FALSE | FALSE | TRUE | FALSE |
| riverton | riverton | riverton | TRUE | TRUE | TRUE | FALSE | FALSE |
On the 95 word types both parsers return a lemma for, hunspell flags 8 UDPipe lemmas and 2 spaCy lemmas. Two flags are the same on both sides and are not model errors: riverton is a made-up place name, and october is flagged because this lesson lowercases lemmas before checking. spaCy returned October, which hunspell knows. Setting those aside, UDPipe produced six broken strings: classe, diploman, includ, ne, provid, and requi. spaCy produced none. Six word types is a small count from 28 sentences of one kind of text.
Decide when the cost is justified
For a public table, a checked lemma is usually safer than a Snowball stem. is and are can group under be, and provided can become provide rather than provid.
Stemming is cheaper and often enough for rough private matching. It is a poor choice for display because strings such as requir and schedul are not reader words. Lemmatization is cleaner when it works, but the UDPipe provid example shows that a lemma column still needs checks.
final_check <- tibble(
model = c("UDPipe", "spaCy"),
provided_lemma = c(
lemma_stem_comparison$udpipe_lemma[lemma_stem_comparison$word == "provided"],
lemma_stem_comparison$spacy_lemma[lemma_stem_comparison$word == "provided"]
)
)
invisible(suppressMessages(spacy_finalize()))| Model | Lemma for provided |
|---|---|
| UDPipe | provid |
| spaCy | provide |
What to remember
- A lemma is a dictionary form chosen for a word in context.
- A stem is a rule-chopped comparison string.
- Tokenizers changed the comparison:
driver'shas no spaCy word-type match. - The dictionary screen flags unknown strings, not lemma correctness.
- Raw hunspell flags are 8 for UDPipe and 2 for spaCy on 95 word types.
- After coverage and case artifacts are set aside, the broken-string count is 6 for UDPipe and 0 for spaCy.
Use lemmas for reader-facing grouping only after checking the rows that look broken or surprising. The important comparison here is six small-model lemma guesses to inspect, not a causal story about training size.