library(readr)
library(dplyr)
library(tibble)
library(stringr)
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()
)
)
pay_examples <- tibble(
example_id = sprintf("pay-%02d", 1:13),
text = c(
"$18.50 per hour",
"18.50/hr",
"$38,000 a year",
"$17-$19 an hour",
"competitive pay",
"$1,250 stipend",
"$20 hourly",
"Pay starts at $16 per hour",
"$42,500 annually",
"Stipend of 750 dollars",
"Up to $21/hour",
"18 dollars an hour",
"$38000 a year"
),
expected_currency = c(
"USD", NA, "USD", "USD", NA, "USD",
"USD", "USD", "USD", "USD", "USD", "USD",
"USD"
),
expected_unit = c(
"hour", "hour", "year", "hour", NA, "stipend",
"hour", "hour", "year", "stipend", "hour", "hour",
"year"
),
expected_lower = c(
18.50, 18.50, 38000, 17, NA, 1250,
20, 16, 42500, 750, 21, 18,
38000
),
expected_upper = c(
NA, NA, NA, 19, NA, NA,
NA, NA, NA, NA, NA, NA,
NA
)
)Parsing prices from text
Extract pay amounts and units
The team gives labels to sentences from the job board and training flyer. The coordinator then asks which notices contain specific pay information.
A phrase such as $18.50 per hour looks easy. A phrase such as competitive pay looks useful to a reader but gives a computer no amount to compare. The team needs a rule that extracts values and reports when the text contains no usable price.
The Riverton job board and flyer in this lesson are fictional teaching materials.
By the end of this lesson, you will be able to:
- explain why a number alone is not a price.
- write
stringrpatterns for money-like text. - separate amount, currency, and pay period.
- keep ranges and missing values visible.
- compare a small rule set with this page’s expected answers.
Build examples with expected answers
readr reads the Riverton CSV, while dplyr and tibble prepare tables. stringr matches patterns for monetary amounts. The examples are author-created pay strings in the Riverton style. A price here means an amount, a currency, and a unit such as hour, year, or stipend.
| Example ID | Text | Expected currency | Expected unit | Expected lower amount | Expected upper amount |
|---|---|---|---|---|---|
| pay-01 | $18.50 per hour | USD | hour | 18.5 | NA |
| pay-02 | 18.50/hr | NA | hour | 18.5 | NA |
| pay-03 | $38,000 a year | USD | year | 38000.0 | NA |
| pay-04 | $17-$19 an hour | USD | hour | 17.0 | 19 |
| pay-05 | competitive pay | NA | NA | NA | NA |
| pay-06 | $1,250 stipend | USD | stipend | 1250.0 | NA |
| pay-07 | $20 hourly | USD | hour | 20.0 | NA |
| pay-08 | Pay starts at $16 per hour | USD | hour | 16.0 | NA |
| pay-09 | $42,500 annually | USD | year | 42500.0 | NA |
| pay-10 | Stipend of 750 dollars | USD | stipend | 750.0 | NA |
| pay-11 | Up to $21/hour | USD | hour | 21.0 | NA |
| pay-12 | 18 dollars an hour | USD | hour | 18.0 | NA |
| pay-13 | $38000 a year | USD | year | 38000.0 | NA |
The table contains 13 examples. One writes a five-digit amount without a comma. That case matters because a partial match would look like a real answer.
When an example gives one amount, the expected answers put it in the lower-amount column. That is correct for Pay starts at $16 per hour, because that text gives the minimum pay. In Up to $21/hour, the amount is the maximum pay. A correct answer would put 21 in the upper-amount column and leave the lower amount missing. The parser below has no rule for up to, and a production parser needs one.
Extract the visible components
The pattern below uses the ICU regular expression engine through stringr. A regular expression is a compact search pattern. This pattern matches an optional dollar sign followed by a number that may contain commas or cents. It also matches an optional second number after a hyphen or the word to.
parse_prices <- function(data) {
number_pattern <- "(?:\\d{1,3}(?:,\\d{3})+|\\d+)(?:\\.\\d+)?"
amount_pattern <- paste0(
"(?i)(?:\\$\\s*)?", number_pattern,
"(?:\\s*(?:-|to)\\s*(?:\\$\\s*)?", number_pattern, ")?"
)
data |>
mutate(
amount_text = str_extract(text, amount_pattern),
currency = case_when(
str_detect(text, fixed("$")) ~ "USD",
str_detect(str_to_lower(text), "\\bdollars?\\b") ~ "USD",
TRUE ~ NA_character_
),
unit = case_when(
str_detect(str_to_lower(text), "per hour|/hr|/hour|an hour|hourly") ~ "hour",
str_detect(str_to_lower(text), "a year|annually|annual|year") ~ "year",
str_detect(str_to_lower(text), "stipend") ~ "stipend",
TRUE ~ NA_character_
),
amount_clean = str_remove_all(amount_text, "[$,]"),
amount_lower = as.numeric(str_extract(amount_clean, "\\d+(?:\\.\\d+)?")),
amount_upper = as.numeric(
str_match(amount_clean, "(?:-|to)\\s*(\\d+(?:\\.\\d+)?)")[, 2]
),
parser_result = case_when(
is.na(amount_lower) ~ "not a price",
is.na(unit) ~ "number without unit",
!is.na(amount_upper) ~ "range",
TRUE ~ "single price"
)
)
}
price_results <- parse_prices(pay_examples)| Example ID | Text | Matched amount | Currency | Unit | Lower amount | Upper amount | Parser result |
|---|---|---|---|---|---|---|---|
| pay-01 | $18.50 per hour | $18.50 | USD | hour | 18.5 | NA | single price |
| pay-02 | 18.50/hr | 18.50 | NA | hour | 18.5 | NA | single price |
| pay-03 | $38,000 a year | $38,000 | USD | year | 38000.0 | NA | single price |
| pay-04 | $17-$19 an hour | $17-$19 | USD | hour | 17.0 | 19 | range |
| pay-05 | competitive pay | NA | NA | NA | NA | NA | not a price |
| pay-06 | $1,250 stipend | $1,250 | USD | stipend | 1250.0 | NA | single price |
| pay-07 | $20 hourly | $20 | USD | hour | 20.0 | NA | single price |
| pay-08 | Pay starts at $16 per hour | $16 | USD | hour | 16.0 | NA | single price |
| pay-09 | $42,500 annually | $42,500 | USD | year | 42500.0 | NA | single price |
| pay-10 | Stipend of 750 dollars | 750 | USD | stipend | 750.0 | NA | single price |
| pay-11 | Up to $21/hour | $21 | USD | hour | 21.0 | NA | single price |
| pay-12 | 18 dollars an hour | 18 | USD | hour | 18.0 | NA | single price |
| pay-13 | $38000 a year | $38000 | USD | year | 38000.0 | NA | single price |
The number and the unit come from different parts of the phrase. 18.50/hr contains an amount and a unit, but the rule marks the currency as missing. The rule treats every dollar sign as US dollars. That assumption fails on a board with US, Canadian, and Australian postings. Units outside this small set, such as monthly pay, require another branch.
Count the special cases
A parser should report unresolved cases. The code below counts rows that require special handling.
case_counts <- tibble(
case = c(
"thousands separator",
"range with two numbers",
"missing currency",
"no number",
"plain four-plus digit number"
),
examples = c(
sum(str_detect(price_results$amount_text, fixed(",")), na.rm = TRUE),
sum(!is.na(price_results$amount_upper)),
sum(is.na(price_results$currency)),
sum(is.na(price_results$amount_lower)),
sum(
str_detect(str_remove_all(price_results$amount_text, "[$,]"), "\\d{4,}") &
!str_detect(price_results$amount_text, fixed(",")),
na.rm = TRUE
)
)
)| Case | Examples |
|---|---|
| thousands separator | 3 |
| range with two numbers | 1 |
| missing currency | 2 |
| no number | 1 |
| plain four-plus digit number | 1 |
Three examples contain thousands separators. One is a range with two amounts. Two lack a dollar sign or dollar word. One has no number. The categories overlap, so this table is a checklist rather than a count of distinct rows.
Compare the parser with expected answers
The parser and expected answers come from the same author. A high score here does not give an estimate of accuracy. Read it only as a check that the code behaves as this page says it should.
values_match <- function(observed, expected) {
(is.na(observed) & is.na(expected)) |
(!is.na(observed) & !is.na(expected) & observed == expected)
}
price_score <- price_results |>
summarise(
examples = n(),
lower_amount_matches = sum(values_match(amount_lower, expected_lower)),
upper_amount_matches = sum(values_match(amount_upper, expected_upper)),
unit_matches = sum(values_match(unit, expected_unit)),
currency_matches = sum(values_match(currency, expected_currency))
)| Examples | Lower amount matches | Upper amount matches | Unit matches | Currency matches |
|---|---|---|---|---|
| 13 | 13 | 13 | 13 | 13 |
The parser matches the expected lower amount, upper amount, unit, and currency on all 13 examples. The wider number pattern prevents an incomplete match for the comma-free case. The parser must not read $38000 as $380.
Examine the Riverton stipend line
The Riverton teaching file contains a stipend phrase but no stipend amount. The rule should report a missing amount rather than invent one.
riverton_pay_line <- sentences |>
filter(sentence_id == "s025") |>
transmute(
example_id = sentence_id,
text,
expected_currency = NA_character_,
expected_unit = "stipend",
expected_lower = NA_real_,
expected_upper = NA_real_
)
riverton_price_result <- parse_prices(riverton_pay_line)| Sentence ID | Text | Amount | Currency | Unit | Parser result |
|---|---|---|---|---|---|
| s025 | Paid training stipend | NA | NA | stipend | not a price |
The line tells a reader that a stipend exists. It does not say how much. A number without a unit is not a price. A unit without a number gives the coordinator no amount to compare.
The limits of custom rules
A custom regular expression can find a restricted set of formats. It can fail on more varied data.
failure_examples <- tibble(
text = c("thirty-eight thousand a year", "1.250,50 / hr", "$40/hr (CAD)")
)
failure_results <- parse_prices(failure_examples)| text | amount_lower | currency |
|---|---|---|
| thirty-eight thousand a year | NA | NA |
| 1.250,50 / hr | 1.25 | NA |
| $40/hr (CAD) | 40.00 | USD |
- Value normalization: The pattern misses
"thirty-eight thousand"entirely because it only looks for digits. - Locale formats: It reads the European format
1.250,50incorrectly. The pattern expects commas as thousands separators, so it matches1.250without warning and returns 1.25. - Ambiguous currencies: It assumes
$means USD, which is wrong for CAD.
Custom regular expressions alone are a poor choice for general entity parsing. Systems use named entity recognition, such as spaCy MONEY entities, to find relevant text spans. Separate value parsers, such as Duckling, normalize amounts, currencies, and locale-specific formats into structured data.
What to remember
- A usable price needs an amount, a currency, and a unit.
- Ranges should keep both numbers.
- A parser must not return a partial number when the text contains a longer one.
- These counts describe 13 teaching examples and one Riverton line.
The parser can put a pay-related mark on the stipend line. The line gives no amount for a wage comparison.