Tagging words by grammatical function

Read part-of-speech tags from a small trained model

word parsing
part-of-speech tagging
workforce research
Learn how a part-of-speech tagger labels workforce text and why held-out accuracy has limits.

After sentence labeling, the team examines single words in a job posting. It asks which words describe actions.

This changes the level of analysis. The team no longer classifies whole sentences about training or schedules. It examines each word’s grammatical function.

A part-of-speech tagger assigns a grammatical tag to each token. Tags can identify things, actions, descriptions, helper verbs, numbers, or punctuation. This lesson uses a UDPipe tagger with 500 hand-labeled training sentences from the Universal Dependencies English Web Treebank. A treebank contains sentences with human-assigned word-level labels.

Note

The author created the fictional Riverton Workforce Lab, its job board, and its training flyer for teaching. The project includes local UDPipe model and treebank files. The lesson downloads nothing during execution.

TipWhat you will learn

By the end of this lesson, you will be able to:

  • describe common Universal POS tags in plain language.
  • run a local UDPipe part-of-speech tagger on fixed tokens.
  • examine a tagging mistake in a job-posting sentence.
  • read a held-out accuracy score.
  • explain why weblog-post accuracy does not establish accuracy on job postings.

Load the local files

In this read-only pass, readr opens files, and dplyr and tibble handle tables. The purrr package processes token lists, tokenizers splits words, and udpipe assigns tags. The digest package supplies file fingerprints for comparison. The code reads the trained model from disk and records settings for the bundled run. It does not train a model.

library(readr)
library(dplyr)
library(tibble)
library(purrr)
library(tokenizers)
library(stringr)
library(udpipe)
library(digest)

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()
  )
)

learning_curve <- read_csv(
  "data/treebank/tagger-learning-curve.csv",
  na = character(),
  col_types = cols(
    training_sentences = col_integer(),
    training_tokens = col_integer(),
    held_out_tokens = col_integer(),
    correct_tokens = col_integer(),
    accuracy = col_double()
  )
)

metadata <- read_csv(
  "data/treebank/treebank-metadata.csv",
  na = character(),
  col_types = cols(
    artifact = col_character(),
    description = col_character(),
    source = col_character(),
    source_url = col_character(),
    retrieved_on = col_character(),
    license = col_character(),
    fingerprint = col_character()
  )
)

builder_script_path <- "data-raw/build-treebank-tagger.R"
builder_seed_match <- read_lines(builder_script_path) |>
  str_subset("^set.seed\\(") |>
  str_match("^set.seed\\((\\d+)\\)")
builder_seed <- as.integer(builder_seed_match[1, 2])
rng_settings <- RNGkind()

learning_curve_path <- "data/treebank/tagger-learning-curve.csv"
learning_curve_fingerprint <- digest(
  paste(read_lines(learning_curve_path), collapse = "\n"),
  algo = "sha256",
  serialize = FALSE
)
expected_curve_fingerprint <- metadata |>
  filter(artifact == "tagger-learning-curve.csv") |>
  pull(fingerprint)

metadata_display <- metadata |>
  filter(
    artifact %in% c(
      "en_ewt-train-excerpt.conllu",
      "en_ewt-held-out.conllu",
      "en_ewt-500-tagger.udpipe",
      "tagger-learning-curve.csv"
    )
  ) |>
  mutate(fingerprint_start = str_sub(fingerprint, 1, 12)) |>
  select(artifact, source, retrieved_on, license, fingerprint_start)

reproducibility_table <- tibble(
  item = c(
    "Builder seed from script",
    "Current R RNG kind",
    "Current normal generator",
    "Current sample generator",
    "Current R version"
  ),
  value = c(
    as.character(builder_seed),
    rng_settings[[1]],
    rng_settings[[2]],
    rng_settings[[3]],
    R.version.string
  )
)
Local treebank artifacts and provenance used by the tagger lesson
Artifact Source Retrieved License SHA-256 start
en_ewt-500-tagger.udpipe UD English EWT v2.18, released 2026-05-15 2026-08-28 CC BY-SA 4.0 9c3907d37e9b
en_ewt-held-out.conllu UD English EWT v2.18, released 2026-05-15 2026-08-28 CC BY-SA 4.0 468cbf678026
en_ewt-train-excerpt.conllu UD English EWT v2.18, released 2026-05-15 2026-08-28 CC BY-SA 4.0 8eb319b01a7d
tagger-learning-curve.csv UD English EWT v2.18, released 2026-05-15 2026-08-28 CC BY-SA 4.0 b1c614e3cdd1
Recorded builder seed and the current rerun environment
Reproducibility item Value
Builder seed from script 20260828
Current R RNG kind Mersenne-Twister
Current normal generator Inversion
Current sample generator Rejection
Current R version R version 4.6.1 (2026-06-24)

The table prints the first 12 SHA-256 characters for readability at high zoom. The full values are in data/treebank/treebank-metadata.csv. A matching hash indicates that the file remains unchanged from the recorded version. It does not establish that the numbers are correct.

The second table combines one setting from the builder script with current environment details. The committed model lacks build-time RNG kind, R version, and package-version metadata. Record those details on the next rebuild. Only the 500-sentence tagger is in this repository. The committed files cannot recreate the other four learning-curve rows.

Tag fixed tokens

The model’s training covers token tagging, not raw-text sentence splitting. The code first tokenizes each Riverton text unit. It writes one token per line and declares the input as vertical text to UDPipe.

The output contains a lemma, a word’s dictionary form. For example, is has the lemma be. The shorthand \(tokens) defines an R function that handles one sentence’s tokens at a time.

tagger <- udpipe_load_model(
  "data/treebank/en_ewt-500-tagger.udpipe"
)

vertical <- sentences$text |>
  tokenize_words(lowercase = FALSE, strip_punct = FALSE) |>
  map_chr(\(tokens) paste(tokens, collapse = "\n"))

tagged <- udpipe_annotate(
  tagger,
  x = vertical,
  doc_id = sentences$sentence_id,
  tokenizer = "vertical",
  tagger = "default",
  parser = "none"
) |>
  as.data.frame() |>
  as_tibble()

paid_sentence_tags <- tagged |>
  filter(doc_id == "s001") |>
  select(token, upos, lemma)
Tags for β€˜Paid 12-week training is provided.’
Token POS tag Lemma
Paid PROPN Paid
12 NUM 12
- PUNCT -
week NOUN week
training NOUN training
is AUX be
provided VERB provid
. PUNCT .

The sentence has eight tokens after keeping punctuation. Two outputs are wrong in a way a reader can judge. Paid is not a proper noun. It describes the training, and the tagger likely followed the capital letter at the start of the sentence. The lemma provid for provided is not a word. The model guessed a stem from a suffix rule.

Count the tags

The tagger uses Universal POS tags. The tagset is its fixed list of permitted labels.

  • NOUN marks things.
  • VERB marks actions.
  • ADJ marks descriptions.
  • ADP marks words such as of and in.
  • AUX marks helper verbs such as is.
  • DET marks words such as the and a.
  • PART marks particles such as to in to apply.
  • PROPN marks names.
  • PRON marks pronouns.
  • ADV marks adverbs.
  • CCONJ marks coordinating conjunctions such as and.
  • INTJ marks interjections.
tag_counts <- tagged |>
  count(upos, sort = TRUE)

punctuation_tokens <- sum(tagged$upos == "PUNCT")
tokens_without_punctuation <- nrow(tagged) - punctuation_tokens

intj_tokens <- tagged |>
  filter(upos == "INTJ") |>
  select(doc_id, token)

propn_tokens <- tagged |>
  filter(upos == "PROPN") |>
  select(doc_id, token)

expected_tag_counts <- tibble(
  upos = c(
    "NOUN",
    "PUNCT",
    "VERB",
    "AUX",
    "ADJ",
    "DET",
    "PROPN",
    "ADP",
    "NUM",
    "PART",
    "PRON",
    "ADV",
    "CCONJ",
    "INTJ"
  ),
  n = c(45L, 25L, 24L, 20L, 17L, 16L, 11L, 7L, 6L, 2L, 2L, 1L, 1L, 1L)
)
Universal POS tag counts across the 28 Riverton text units
POS tag Tagged tokens
NOUN 45
PUNCT 25
VERB 24
AUX 20
ADJ 17
DET 16
PROPN 11
ADP 7
NUM 6
PART 2
PRON 2
ADV 1
CCONJ 1
INTJ 1

Nouns are the largest group, followed by punctuation, verbs, and helper verbs. These categories provide a starting point for checks. The first sentence’s errors show that the table is not a perfect grammatical description of the postings.

This lesson keeps punctuation and tags 178 tokens. Removing the 25 PUNCT tokens leaves the 153 word tokens counted in Lesson 15.

The only INTJ is No in s002, which is wrong in context. Seven of the 11 PROPN tokens are all-capital flyer words: RIVERTON, SKILLS, OPEN, HOUSE, DATA, SUPPORT, and CERTIFICATE. Two are sentence-initial Paid in s001 and s025. One is Clear in s022. The remaining token, October in s028, has the correct tag.

The tagger treats capital letters as evidence of names. An overall accuracy score does not show this systematic error on the Riverton text.

Read the accuracy number

This score is for 13 held-out political weblog posts from the same treebank, not for job postings. Held-out means text kept back from training so it can be reserved for scoring. The posts in both treebank excerpts are weblog documents from 2003 to 2006, which strengthens the domain-transfer warning.

train_path <- "data/treebank/en_ewt-train-excerpt.conllu"
held_out_path <- "data/treebank/en_ewt-held-out.conllu"

read_gold_tokens <- function(path) {
  udpipe_read_conllu(path) |>
    as_tibble() |>
    filter(!str_detect(token_id, "[^0-9]"), !is.na(upos))
}

train_newdoc_ids <- read_lines(train_path) |>
  str_subset(fixed("# newdoc id = ")) |>
  str_remove(fixed("# newdoc id = "))

held_newdoc_ids <- read_lines(held_out_path) |>
  str_subset(fixed("# newdoc id = ")) |>
  str_remove(fixed("# newdoc id = "))

treebank_years <- str_match(
  c(train_newdoc_ids, held_newdoc_ids),
  "_ENG_(\\d{4})"
)[, 2] |>
  as.integer()

train_gold <- read_gold_tokens(train_path) |>
  transmute(token, gold_upos = upos)

held <- read_gold_tokens(held_out_path) |>
  transmute(sentence_id, token, gold_upos = upos)

lookup <- train_gold |>
  count(token, gold_upos, name = "n") |>
  arrange(token, desc(n), gold_upos) |>
  group_by(token) |>
  slice(1) |>
  ungroup() |>
  select(token, baseline_upos = gold_upos)

held_vertical <- held |>
  group_by(sentence_id) |>
  summarise(text = paste(token, collapse = "\n"), .groups = "drop")

held_predictions <- udpipe_annotate(
  tagger,
  x = held_vertical$text,
  doc_id = held_vertical$sentence_id,
  tokenizer = "vertical",
  tagger = "default",
  parser = "none"
) |>
  as.data.frame() |>
  as_tibble() |>
  group_by(doc_id) |>
  mutate(token_row = row_number()) |>
  ungroup() |>
  transmute(
    sentence_id = doc_id,
    token_row,
    tagger_upos = upos
  )

scored <- held |>
  left_join(lookup, by = "token") |>
  mutate(
    baseline_upos = coalesce(baseline_upos, "NOUN"),
    seen_in_training = token %in% train_gold$token
  ) |>
  group_by(sentence_id) |>
  mutate(token_row = row_number()) |>
  ungroup() |>
  left_join(held_predictions, by = c("sentence_id", "token_row")) |>
  mutate(
    baseline_correct = baseline_upos == gold_upos,
    tagger_correct = tagger_upos == gold_upos,
    easy_gold_tag = gold_upos %in% c("PUNCT", "DET")
  )

scored_500 <- learning_curve |>
  filter(training_sentences == 500L) |>
  mutate(
    calculated_accuracy = correct_tokens / held_out_tokens,
    accuracy_percent = round(calculated_accuracy * 100, 2)
  )

learning_curve_display <- learning_curve |>
  mutate(
    accuracy_percent = round(accuracy * 100, 2)
  )

gain_100_to_500 <- learning_curve |>
  filter(training_sentences %in% c(100L, 500L)) |>
  summarise(
    points = diff(accuracy) * 100,
    .groups = "drop"
  ) |>
  pull(points)

gain_500_to_2000 <- learning_curve |>
  filter(training_sentences %in% c(500L, 2000L)) |>
  summarise(
    points = diff(accuracy) * 100,
    .groups = "drop"
  ) |>
  pull(points)

training_ratio_100_to_500 <- 500L / 100L
training_ratio_500_to_2000 <- 2000L / 500L

accuracy_summary <- scored |>
  summarise(
    held_out_tokens = n(),
    lookup_correct = sum(baseline_correct),
    lookup_accuracy = lookup_correct / held_out_tokens,
    tagger_correct = sum(tagger_correct),
    tagger_accuracy = tagger_correct / held_out_tokens,
    gain_points = (tagger_accuracy - lookup_accuracy) * 100,
    seen_tokens = sum(seen_in_training),
    seen_percent = seen_tokens / held_out_tokens * 100,
    easy_tokens = sum(easy_gold_tag),
    easy_percent = easy_tokens / held_out_tokens * 100,
    .groups = "drop"
  )

content_summary <- scored |>
  filter(!easy_gold_tag) |>
  summarise(
    held_out_tokens = n(),
    lookup_correct = sum(baseline_correct),
    lookup_accuracy = lookup_correct / held_out_tokens,
    tagger_correct = sum(tagger_correct),
    tagger_accuracy = tagger_correct / held_out_tokens,
    .groups = "drop"
  )

accuracy_comparison <- tibble(
  scope = c(
    "All held-out tokens",
    "All held-out tokens",
    "Excluding PUNCT and DET",
    "Excluding PUNCT and DET"
  ),
  system = c(
    "Lookup baseline",
    "500-sentence tagger",
    "Lookup baseline",
    "500-sentence tagger"
  ),
  correct = c(
    accuracy_summary$lookup_correct,
    accuracy_summary$tagger_correct,
    content_summary$lookup_correct,
    content_summary$tagger_correct
  ),
  tokens = c(
    accuracy_summary$held_out_tokens,
    accuracy_summary$held_out_tokens,
    content_summary$held_out_tokens,
    content_summary$held_out_tokens
  ),
  accuracy = round(correct / tokens, 4)
)
Held-out tagger accuracy as training data increases
Training sentences Training tokens Held-out tokens Correct tokens Accuracy Accuracy percent
100 2311 4007 3301 0.8238 82.38
250 5539 4007 3496 0.8725 87.25
500 11595 4007 3641 0.9087 90.87
1000 21857 4007 3720 0.9284 92.84
2000 39802 4007 3778 0.9429 94.29
The 500-sentence tagger compared with a lookup baseline
Tokens included System Correct Tokens Accuracy
All held-out tokens Lookup baseline 3178 4007 0.7931
All held-out tokens 500-sentence tagger 3641 4007 0.9087
Excluding PUNCT and DET Lookup baseline 2307 3123 0.7387
Excluding PUNCT and DET 500-sentence tagger 2766 3123 0.8857

The lookup baseline assigns each word its most frequent tag from the 500 training sentences. It assigns NOUN to unseen words. It correctly tags 3,178 of 4,007 tokens, or 0.7931. The trained tagger correctly tags 3,641 of 4,007, or about 91 percent. This is about 11.6 points above the lookup baseline.

Of the 4,007 held-out tokens, 3,140 also appear in the training sentences. Those tokens account for 78.4 percent of the held-out set.

The easier tags PUNCT and DET account for 884 of 4,007 held-out tokens, or 22.1 percent. Without them, the trained tagger scores 2,766 of 3,123, or 0.8857. The lookup baseline scores 2,307 of 3,123, or 0.7387. The easier tokens increase both overall scores.

On this learning curve, accuracy increases quickly at first and then more slowly. Increasing training from 100 to 500 sentences adds about 8.5 points. Increasing from 500 to 2,000 adds about 3.4 points. Each step adds blog posts and sentences, so the curve cannot separate training quantity from topic coverage. The intervals also differ proportionally. The first is a five-fold increase, and the second is a four-fold increase.

Keep the limits attached

The score comes from one run over thirteen blog posts. Tokens within a post are not independent because they share vocabulary, topic, and author. Actual variation exceeds what the token count alone suggests. The builder script sets set.seed(20260828).

The RNG and R-version rows describe the current environment, not the committed model’s build-time environment. This page reports only the bundled run. It provides no confidence interval because it neither retrains nor rescores on new samples or domains.

The Riverton text contains job postings and flyer lines, a different type of writing. This lesson does not claim the same accuracy there. It shows individual Riverton mistakes that readers can evaluate.

Universal POS tags provide grammatical categories for this task. Other projects can choose a different tagset.

What to remember

  • A part-of-speech tagger assigns a grammatical tag to each token.
  • Universal POS tags are compact labels such as NOUN, VERB, and ADJ.
  • The local tagger labeled 178 Riverton tokens.
  • Its held-out weblog-post accuracy is 3,641 of 4,007 tokens, or about 91 percent.
  • Examine job-posting errors directly.
  • Weblog-post accuracy does not establish job-posting accuracy.

The team can use the tagger to make a first action-word list, then examine the mistakes before treating the labels as evidence.

Sources