Learn how lemmas differ from stems and why lemmatizers built in different ways disagree on workforce text.
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.
Note
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.
TipWhat you will learn
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().
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.
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.
Word types whose lemmas vary across Riverton occurrences
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 )knitr::kable( lemma_quality,col.names =c("System","Word types compared","Raw dictionary flags","Coverage or case artifacts","Broken-string flags" ),caption ="Dictionary proxy counts for lemmas on the same Riverton word types",row.names =FALSE)
Dictionary proxy counts for lemmas on the same Riverton word types
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
knitr::kable( flagged_comparison,col.names =c("Word","UDPipe lemma","spaCy lemma","UDPipe flagged","spaCy flagged","Coverage or case artifact","UDPipe broken string","spaCy broken string" ),caption ="Where either model's lemma is flagged by hunspell",row.names =FALSE)
Where either model’s lemma is flagged by hunspell
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"] ))knitr::kable( final_check,col.names =c("Model", "Lemma for provided"),caption ="A final check on the clearest contrast",row.names =FALSE)
A final check on the clearest contrast
Model
Lemma for provided
UDPipe
provid
spaCy
provide
invisible(suppressMessages(spacy_finalize()))
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's has 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.