library(readr)
library(dplyr)
library(tibble)
library(purrr)
library(stringr)
library(tokenizers)
library(spacyr)
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()
)
)
passage <- str_c(
"RIVERTON SKILLS OPEN HOUSE\n",
"Dr. Vale said shifts start at 6 a.m.\n",
"Pay is $18.50 per hour.\n",
"J. Rivera can apply by Friday\n",
"Bring a photo ID."
)
expected_sentences <- c(
"RIVERTON SKILLS OPEN HOUSE",
"Dr. Vale said shifts start at 6 a.m.",
"Pay is $18.50 per hour.",
"J. Rivera can apply by Friday",
"Bring a photo ID."
)Finding sentence boundaries
Compare three ways to split text into sentences
The coordinator counts sentences on a constructed page with flyer, pay, and job-board text. The lesson then compares sentence boundaries in the separate 28-row Riverton file.
The page contains Dr., a.m., $18.50, and a heading without a period. Those forms complicate sentence counts. A sentence boundary marks where one sentence ends and the next begins. The team must state its boundary rule before interpreting the count.
The Riverton page and CSV rows are fictional teaching data.
By the end of this lesson, you will be able to:
- explain why sentence segmentation is harder than splitting on periods.
- compare an author-marked sentence count with three splitters.
- examine the fragments each splitter creates.
- use spaCy sentence identifiers from
spacy_parse(). - explain why the Riverton CSV already contains an upstream boundary choice.
Build a passage with difficult boundaries
readr reads the Riverton CSV, while dplyr and tibble build tables. purrr and stringr execute the small splitters. tokenizers supplies a package splitter, and spacyr calls a trained parser. A splitter is a rule or model that marks text boundaries.
| Expected sentence | Author-marked text |
|---|---|
| 1 | RIVERTON SKILLS OPEN HOUSE |
| 2 | Dr. Vale said shifts start at 6 a.m. |
| 3 | Pay is $18.50 per hour. |
| 4 | J. Rivera can apply by Friday |
| 5 | Bring a photo ID. |
The expected-answer list has five sentences. Because that list and the examples share an author, agreement with it is a lesson check rather than an accuracy estimate. The first sentence is a flyer-style heading with no punctuation. The next three contain a title, an abbreviation, a decimal, and an initial.
Compare three splitters
The naive rule below splits text at every full stop. tokenizers uses a sentence-splitting rule from an R package. spaCy uses a trained English pipeline. spacy_parse() returns one row per token, and its sentence_id column records the boundary spaCy chose.
split_on_full_stop <- function(text) {
text |>
str_split(fixed(".")) |>
pluck(1) |>
str_trim() |>
discard(\(piece) identical(piece, ""))
}
join_spacy_tokens <- function(tokens) {
str_c(tokens, collapse = " ") |>
str_squish() |>
str_replace_all(fixed(" ."), ".") |>
str_replace_all(fixed(" ,"), ",") |>
str_replace_all(fixed("$ "), "$")
}
naive_pieces <- split_on_full_stop(passage)
tokenizer_pieces <- tokenize_sentences(passage)[[1]]
source("R/use-spacy.R")
pipeline <- use_project_spacy()
spacy_metadata <- spacy_pipeline_version()
spacy_tokens <- spacy_parse(
setNames(passage, "test_passage"),
pos = TRUE,
lemma = TRUE
)
spacy_finalize()
spacy_pieces <- spacy_tokens |>
as_tibble() |>
summarise(piece = join_spacy_tokens(token), .by = sentence_id) |>
pull(piece)
split_results <- bind_rows(
tibble(splitter = "split on full stops", piece = naive_pieces),
tibble(splitter = "tokenizers::tokenize_sentences()", piece = tokenizer_pieces),
tibble(splitter = "spaCy sentence_id", piece = spacy_pieces)
) |>
mutate(
splitter = factor(
splitter,
levels = c(
"split on full stops",
"tokenizers::tokenize_sentences()",
"spaCy sentence_id"
)
)
) |>
arrange(splitter) |>
group_by(splitter) |>
mutate(
piece_number = row_number(),
exact_expected_sentence = piece %in% expected_sentences
) |>
ungroup()
splitter_scores <- split_results |>
summarise(
pieces_found = n(),
exact_expected_pieces = sum(exact_expected_sentence),
.by = splitter
) |>
mutate(
expected_pieces = length(expected_sentences),
count_difference = pieces_found - expected_pieces,
matches_expected_count = pieces_found == expected_pieces
)| Splitter | Pieces found | Exact pieces | Expected pieces | Difference | Count matches |
|---|---|---|---|---|---|
| split on full stops | 7 | 0 | 5 | 2 | FALSE |
| tokenizers::tokenize_sentences() | 5 | 1 | 5 | 0 | TRUE |
| spaCy sentence_id | 2 | 0 | 5 | -3 | FALSE |
| Splitter | Piece | Piece number | Exact expected sentence |
|---|---|---|---|
| split on full stops | RIVERTON SKILLS OPEN HOUSE Dr | 1 | no |
| split on full stops | Vale said shifts start at 6 a | 2 | no |
| split on full stops | m | 3 | no |
| split on full stops | Pay is $18 | 4 | no |
| split on full stops | 50 per hour | 5 | no |
| split on full stops | J | 6 | no |
| split on full stops | Rivera can apply by Friday Bring a photo ID | 7 | no |
| tokenizers::tokenize_sentences() | RIVERTON SKILLS OPEN HOUSE Dr. | 1 | no |
| tokenizers::tokenize_sentences() | Vale said shifts start at 6 a.m. | 2 | no |
| tokenizers::tokenize_sentences() | Pay is $18.50 per hour. | 3 | yes |
| tokenizers::tokenize_sentences() | J. | 4 | no |
| tokenizers::tokenize_sentences() | Rivera can apply by Friday Bring a photo ID. | 5 | no |
| spaCy sentence_id | RIVERTON SKILLS OPEN HOUSE Dr. Vale said shifts start at 6 a.m. Pay is $18.50 per hour. | 1 | no |
| spaCy sentence_id | J. Rivera can apply by Friday Bring a photo ID. | 2 | no |
The naive rule finds seven pieces and none is one of the five expected sentences. It splits Dr., a.m., $18.50, and J.. The package splitter returns the expected count, but only one piece exactly matches an expected sentence. It attaches Dr. to the heading and separates J. from the name.
spaCy returns two pieces. It preserves Dr. Vale and a.m. but combines lines into fewer sentences than the layout implies. The pipeline uses syntax instead of the line-break boundaries that the flyer requires.
The code reconstructs spaCy pieces from tokens and does not preserve the original spaces or line breaks. The exact-match column therefore compares reconstructed text rather than the copied page. Equal piece counts alone do not establish correct segmentation. A splitter can return the expected number of pieces with incorrect boundary positions.
Examine the Riverton rows
The file creator assigned one sentence or flyer line to each of the 28 Riverton rows. This upstream boundary choice occurs before the lesson reads the file.
sentence_piece_counts <- tokenize_sentences(sentences$text) |>
map_int(length)
row_boundary_summary <- sentences |>
mutate(sentence_pieces = sentence_piece_counts) |>
summarise(
rows = n(),
tokenizer_pieces = sum(sentence_pieces),
.by = document_id
)
flyer_rows <- sentences |>
filter(document_id == "F001") |>
nrow()
job_rows <- sentences |>
filter(document_id != "F001") |>
nrow()| Document ID | Rows | Sentence pieces |
|---|---|---|
| J001 | 4 | 4 |
| J002 | 4 | 4 |
| J003 | 4 | 4 |
| J004 | 3 | 3 |
| J005 | 3 | 3 |
| J006 | 4 | 4 |
| F001 | 6 | 6 |
For these stored rows, the package splitter returns one sentence piece for every row. That agreement does not prove the rows are the only possible boundaries. It only shows agreement between this file and this splitter on the teaching set.
What to remember
- Sentence segmentation marks where one sentence ends and another begins.
- A full stop can appear inside titles, initials, abbreviations, and decimals.
- In the test passage, the three splitters return 7, 5, and 2 pieces.
- A correct count can still hide wrong boundaries.
- Rebuilding model output from tokens can change the text that the code scores.
- The 28 Riverton rows already contain one upstream boundary choice.
For the copied page, five expected sentences are a human layout choice. For the CSV, 28 one-row sentence units are an upstream data choice. Both counts depend on the rule named beside them.