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
Turn pay mentions into amounts with units
After labelling sentences from the job board and training flyer, the team faces a new question from the coordinator: which notices say anything concrete about pay?
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 can extract a value and also say when the text did not contain a 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; and
- compare a small rule set with this page’s expected answers.
Build examples with expected answers
The setup reads the Riverton CSV with readr, shapes tables with dplyr and tibble, and asks stringr to match the money-like patterns. 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.
Pull out the visible pieces
The pattern below uses the ICU regular expression engine through stringr. A regular expression is a compact search pattern. This one looks for an optional dollar sign, a number that may contain commas or cents, and 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, which would be wrong on a board that mixed US, Canadian, and Australian postings. Units outside this small set, such as monthly pay, would need another branch.
Count the special cases
A parser is easier to trust when it reports what it could not settle. The code below counts the rows that ask for 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.
Score the parser against the marking
Because the parser and expected answers come from the same author, a high score here does not estimate 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 comma-free case would have been dangerous without the wider number pattern: $38000 must not be cut to $380.
Check the Riverton stipend line
The real Riverton teaching file contains a stipend phrase, but not a stipend amount. The rule should return a miss rather than inventing 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 some stipend exists. It does not say how much. A number without a unit is not a price, and a unit without a number is not an amount the coordinator can compare.
The limits of custom rules
A custom regular expression is a quick way to find highly constrained formats, but it breaks quickly on varied real-world 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: it matches1.250without warning and yields an amount of 1.25 because it expects commas as thousands separators. - Ambiguous currencies: It assumes
$means USD, which is wrong for CAD.
In standard NLP workflows, relying entirely on custom regex for entity parsing is an anti-pattern. Systems use Named Entity Recognition (like MONEY entities in spaCy) to detect relevant spans, and separate value parsers (like Duckling) to normalize amounts, currencies, and locales 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 stipend line can be flagged as pay-related, but it cannot support a wage comparison because it gives no amount.