library(readr)
library(dplyr)
library(tibble)
library(purrr)
library(stringr)
library(tokenizers)
library(udpipe)
library(spacyr)
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()
pipeline_info <- spacy_pipeline_version()
parsed_spacy <- spacy_parse(
setNames(sentences$text, sentences$sentence_id),
pos = TRUE,
lemma = TRUE,
entity = TRUE,
dependency = TRUE,
nounphrase = TRUE
)
spacy_phrases <- nounphrase_extract(parsed_spacy) |>
as_tibble() |>
transmute(doc_id, chunk = str_replace_all(nounphrase, "_", " "))
example_text <- "Riverton Skills Centre will run evening classes in October."
example_parsed <- spacy_parse(
c(example = example_text),
pos = TRUE,
lemma = TRUE,
entity = TRUE,
dependency = TRUE,
nounphrase = TRUE
)
example_phrases <- nounphrase_extract(example_parsed) |>
as_tibble() |>
transmute(chunk = str_replace_all(nounphrase, "_", " "))
spacy_finalize()
pipeline_table <- tibble(
field = names(pipeline_info),
value = unlist(pipeline_info, use.names = FALSE)
)Finding noun chunks from grammar links
Compare spaCy phrases with small dependency chunks
After counting adjacent phrases, the Riverton Workforce Lab must choose its units for checks. It considers basic spreadsheet skills as one phrase or three separate words.
A grammar-aware method offers one convention for making that choice. It can group candidate phrases for checks, but it does not create a single correct inventory of English phrases.
The author created the fictional Riverton Workforce Lab, its job board, and its training flyer for teaching.
Use this lesson to practice how to:
- describe a noun chunk in plain language.
- extract noun chunks with a trained spaCy pipeline.
- build simple chunks from a UDPipe dependency parse.
- explain why chunk definitions differ across tools.
- separate agreement from accuracy.
- explain why parser errors affect chunk rules.
Ask a trained pipeline for noun chunks
The setup reads the text file and runs spaCy. It also prepares inputs for a small UDPipe-based rule. A noun chunk is a short, nonnested phrase around one noun. It contains the noun and directly preceding determiners, numbers, compounds, and describing words.
Under this convention, chunks neither nest nor extend past the noun. Thus evening classes in October produces evening classes and October, not one longer phrase. Tools can apply different boundary conventions.
| Pipeline field | Value |
|---|---|
| name | core_web_sm |
| version | 3.8.0 |
| lang | en |
| license | MIT |
| spacy | 3.8.7 |
| Sentence ID | spaCy noun chunk |
|---|---|
| s001 | Paid 12-week training |
| s002 | No prior data experience |
| s003 | Evening schedules |
| s004 | Applicants |
| s004 | basic spreadsheet skills |
| s005 | A high school diploma |
| s006 | The employer |
| s006 | certification training |
| s007 | Rotating night shifts |
| s007 | part |
| s007 | the job |
| s008 | Workers |
The released spaCy pipeline returns 40 noun chunks for the 28 text units. For the check sentence, it returns three flat chunks: Riverton Skills Centre, evening classes, and October.
Build chunks from dependency links
The siteβs UDPipe model uses 500 training sentences. Evaluation used 13 held-out political weblog posts from 2003 to 2006. Unlabelled attachment was 0.7235, and labelled attachment was 0.6561. spaCyβs released production model uses far more training text. This comparison illustrates different methods, not equivalent training conditions.
The local UDPipe model has no tokenizer, so the lesson gives it one token per line. In the code, \(tokens) is R shorthand for a small function that handles one sentenceβs tokens at a time.
The classroom rule starts from each NOUN or PROPN token. It collects direct dependents with these relations:
detidentifies determiners such asaorthe.amodidentifies describing words.compoundlinks nouns within a compound.nummodidentifies numbers that modify nouns.
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"))
annotated <- udpipe_annotate(
parser,
x = vertical,
doc_id = sentences$sentence_id,
tokenizer = "vertical",
tagger = "default",
parser = "default"
) |>
as.data.frame() |>
as_tibble()
token_count_gap <- parsed_spacy |>
count(doc_id, name = "spacy_tokens") |>
inner_join(
annotated |>
count(doc_id, name = "udpipe_tokens"),
by = "doc_id"
) |>
filter(spacy_tokens != udpipe_tokens)
chunk_relations <- c("det", "amod", "compound", "nummod")
collect_chunk <- function(current_doc_id, current_token_id) {
annotated |>
filter(
doc_id == current_doc_id,
token_id == current_token_id |
(head_token_id == current_token_id & dep_rel %in% chunk_relations)
) |>
arrange(as.integer(token_id)) |>
pull(token) |>
str_c(collapse = " ")
}
udpipe_chunks <- annotated |>
filter(upos %in% c("NOUN", "PROPN")) |>
transmute(
doc_id,
token_id,
chunk = map2_chr(doc_id, token_id, collect_chunk)
) |>
select(doc_id, chunk)| Sentence ID | spaCy tokens | UDPipe tokens |
|---|---|---|
| s017 | 8 | 7 |
| Sentence ID | UDPipe-built chunk |
|---|---|
| s001 | Paid |
| s001 | Paid week |
| s001 | week training |
| s002 | No prior data experience |
| s003 | Evening schedules |
| s004 | spreadsheet |
| s004 | need basic spreadsheet skills |
| s005 | A high school |
| s006 | The employer pays |
| s006 | certification |
| s006 | certification training |
| s007 | night |
Different tokenization produces different row counts. In s017, spaCy separates driver's into driver and 's. The tokenizers package keeps one token for UDPipe, which explains the 179 versus 178 rows. spaCyβs chunks still contain Paid 12-week training and A valid driver's license. The difference concerns token rows, not those chunk strings.
The rule emits one chunk for every noun-like tag, so chunks can overlap. The chunks Paid, Paid week, and week training come from the same four words in s001. The rule joins heads and dependents without an adjacency check. Thus Paid week omits 12- and does not occur in the source.
The chunk Paid results from an incorrect PROPN tag. Here, Paid describes training. Chunk rules can propagate tagging errors and introduce additional errors.
Compare the two lists
Exact agreement is strict: the sentence ID and the chunk text must both match. That makes agreement a string match, not accuracy. Neither list is ground truth, and spaCy chunks are non-overlapping while the UDPipe-built list can overlap.
agreed_chunks <- inner_join(
distinct(spacy_phrases),
distinct(udpipe_chunks),
by = c("doc_id", "chunk")
)
comparison_summary <- tibble(
measure = c("spaCy chunks", "UDPipe-built chunks", "exact agreements"),
count = c(nrow(spacy_phrases), nrow(udpipe_chunks), nrow(agreed_chunks))
)
different_examples <- tibble(
method = c("spaCy", "UDPipe-built"),
sentence_id = c("s005", "s005"),
chunk = c("A high school diploma", "A high school"),
explanation = c(
"spaCy keeps the full education phrase.",
"The dependency rule attaches words to school and leaves diploma outside this chunk."
)
)
spreadsheet_examples <- bind_rows(
spacy_phrases |>
filter(doc_id == "s004", str_detect(chunk, "spreadsheet")) |>
mutate(method = "spaCy"),
udpipe_chunks |>
filter(doc_id == "s004", str_detect(chunk, "spreadsheet")) |>
mutate(method = "UDPipe-built")
) |>
select(method, doc_id, chunk)| Measure | Count |
|---|---|
| spaCy chunks | 40 |
| UDPipe-built chunks | 56 |
| exact agreements | 25 |
| Method | Sentence ID | Chunk | Explanation |
|---|---|---|---|
| spaCy | s005 | A high school diploma | spaCy keeps the full education phrase. |
| UDPipe-built | s005 | A high school | The dependency rule attaches words to school and leaves diploma outside this chunk. |
| Method | Sentence ID | Chunk |
|---|---|---|
| spaCy | s004 | basic spreadsheet skills |
| UDPipe-built | s004 | spreadsheet |
| UDPipe-built | s004 | need basic spreadsheet skills |
The lists have 25 exact string matches, which measure agreement, not accuracy. The counts 40 and 56 describe different chunk definitions. spaCy groups basic spreadsheet skills. The UDPipe rule also emits spreadsheet and incorrectly includes need in need basic spreadsheet skills.
A bigram groups adjacent words even across phrase boundaries. A dependency chunk groups words according to parser links. Its usefulness depends on correct parsing.
What to remember
- Noun chunks are flat, non-overlapping spans under a tool-specific convention.
- spaCy returns 40 noun chunks for these 28 text units.
- The UDPipe-built rule returns 56 overlapping chunks from a deliberately weak teaching model.
- The 25 exact matches are agreement, not accuracy.
Paid weekshows how a rule can manufacture text by pasting non-adjacent tokens.- Tokenization alone explains the 179 versus 178 row-count gap.
Use chunk lists to select text for human checks. When tools disagree, examine the source, tokenization, and parse before interpreting the phrase.