library(readr)
library(dplyr)
library(tibble)
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()
)
)
gazetteer <- read_csv(
"data/riverton/riverton-gazetteer.csv",
na = character(),
col_types = cols(
place_name = col_character(),
place_type = col_character(),
latitude = col_double(),
longitude = col_double(),
parent = col_character()
)
)
metadata <- read_csv(
"data/riverton/riverton-reference-metadata.csv",
na = character(),
col_types = cols(
artifact = col_character(),
description = col_character(),
source = col_character(),
license = col_character(),
created_on = col_character(),
fingerprint = col_character()
)
)
gazetteer_hash <- digest(
paste(read_lines("data/riverton/riverton-gazetteer.csv"), collapse = "\n"),
algo = "sha256",
serialize = FALSE
)
expected_hash <- metadata |>
filter(artifact == "riverton-gazetteer.csv") |>
pull(fingerprint)Geocoding place names
Join text mentions to an offline gazetteer
On the flyer transcript, RIVERTON SKILLS OPEN HOUSE looks ready for a map until Riverton matches two places in the reference table. The coordinator needs the tie to stay visible.
A map needs coordinates, not only a place name. The team can join a mention to a small reference file, then keep the unresolved cases visible.
The places, lab, and flyer here belong to an invented Riverton setting.
By the end of this lesson, you will be able to:
- define geocoding and gazetteers in plain language;
- join place mentions to latitude and longitude;
- detect duplicate place-name matches;
- return a miss when a place is absent; and
- explain why context matters in geocoding.
Load the offline reference files
Geocoding means turning a place name in text into coordinates: a latitude and longitude a map can plot. CSV files supply the examples, dplyr and tibble handle the tables, and digest checks the gazetteer fingerprint. A gazetteer is a list of place names with location information attached. Gazetteers may be public, open-licence, or commercial reference datasets. This one is invented and local so the lesson can run offline.
| Place name | Type | Latitude | Longitude | Parent |
|---|---|---|---|---|
| Riverton | city | 41.8210 | -71.4120 | Marrow County |
| Riverton Heights | neighbourhood | 41.8402 | -71.3988 | Riverton |
| East Riverton | neighbourhood | 41.8117 | -71.3841 | Riverton |
| Marrow County | county | 41.7995 | -71.4503 | Calder |
| Calder | state | 41.6500 | -71.5000 | |
| Bellhaven | city | 41.9331 | -71.2760 | Marrow County |
| Riverton Skills Centre | building | 41.8256 | -71.4077 | Riverton |
| Norwood | city | 41.7042 | -71.6188 | Calder |
| Riverton | city | 38.4410 | -75.1002 | Tidewater |
The reference table has nine rows. The duplicate Riverton rows are deliberate: one sits in Marrow County, Calder, and one sits in Tidewater. The parent column names the larger place that contains a row. The coordinates are arbitrary teaching points, not the locations of real sites.
Riverton Skills Centre is typed as a building here. In the entity-linking lesson, the same words name an organisation. That is not a data-cleaning lesson to hide; the same string can denote a place and an organisation, which is the ORG-versus-place ambiguity that named-entity lessons have to handle.
Join mentions to the gazetteer
The team starts with five hand-identified place mentions. One comes from the Riverton flyer text. Four are constructed checks written in the same setting so we can see resolved, ambiguous, and not-found cases together.
place_mentions <- bind_rows(
sentences |>
filter(sentence_id == "s023") |>
transmute(
mention_id = sentence_id,
source = "Riverton text",
text,
mention = "Riverton"
),
tibble(
mention_id = c("place-02", "place-03", "place-04", "place-05"),
source = "constructed check",
text = c(
"Evening classes at Riverton Skills Centre",
"Applicants in Riverton can apply by October 15",
"Weekend shifts serve Bellhaven",
"The route reaches West Calder"
),
mention = c("Riverton Skills Centre", "Riverton", "Bellhaven", "West Calder")
)
)
geocoded_rows <- place_mentions |>
left_join(
gazetteer,
by = join_by(mention == place_name),
relationship = "many-to-many"
)| Mention ID | Source | Text | Mention | Type | Latitude | Longitude | Parent |
|---|---|---|---|---|---|---|---|
| s023 | Riverton text | RIVERTON SKILLS OPEN HOUSE | Riverton | city | 41.8210 | -71.4120 | Marrow County |
| s023 | Riverton text | RIVERTON SKILLS OPEN HOUSE | Riverton | city | 38.4410 | -75.1002 | Tidewater |
| place-02 | constructed check | Evening classes at Riverton Skills Centre | Riverton Skills Centre | building | 41.8256 | -71.4077 | Riverton |
| place-03 | constructed check | Applicants in Riverton can apply by October 15 | Riverton | city | 41.8210 | -71.4120 | Marrow County |
| place-03 | constructed check | Applicants in Riverton can apply by October 15 | Riverton | city | 38.4410 | -75.1002 | Tidewater |
| place-04 | constructed check | Weekend shifts serve Bellhaven | Bellhaven | city | 41.9331 | -71.2760 | Marrow County |
| place-05 | constructed check | The route reaches West Calder | West Calder | NA | NA | NA | NA |
The relationship argument tells dplyr that one mention may match several rows. That fan-out is the ambiguity this lesson is about, so the code declares it. The join returns seven rows from five mentions because Riverton matches twice. West Calder receives no coordinates because it is not in the gazetteer.
Name the unresolved states
A geocoder should not pretend that every mention is solved. The next table gives each mention a status.
mention_status <- geocoded_rows |>
summarise(
matches = sum(!is.na(latitude)),
candidate_parents = paste(na.omit(parent), collapse = ", "),
.by = c(mention_id, source, text, mention)
) |>
mutate(
candidate_parents = if_else(candidate_parents == "", "none", candidate_parents),
status = case_when(
matches == 0L ~ "not found",
matches > 1L ~ "ambiguous",
TRUE ~ "resolved"
)
)
status_counts <- tibble(status = c("resolved", "ambiguous", "not found")) |>
left_join(
mention_status |>
count(status, name = "mentions"),
by = join_by(status)
) |>
mutate(mentions = coalesce(mentions, 0L))| Mention ID | Source | Text | Mention | Matches | Candidate parents | Status |
|---|---|---|---|---|---|---|
| s023 | Riverton text | RIVERTON SKILLS OPEN HOUSE | Riverton | 2 | Marrow County, Tidewater | ambiguous |
| place-02 | constructed check | Evening classes at Riverton Skills Centre | Riverton Skills Centre | 1 | Riverton | resolved |
| place-03 | constructed check | Applicants in Riverton can apply by October 15 | Riverton | 2 | Marrow County, Tidewater | ambiguous |
| place-04 | constructed check | Weekend shifts serve Bellhaven | Bellhaven | 1 | Marrow County | resolved |
| place-05 | constructed check | The route reaches West Calder | West Calder | 0 | none | not found |
| Status | Mentions |
|---|---|
| resolved | 2 |
| ambiguous | 2 |
| not found | 1 |
Two mentions resolve to one row. Two are ambiguous. One is not found. These counts describe only the five mentions in this lesson.
Add context when the name is not enough
The name Riverton alone cannot choose between the two rows. No county appears in the flyer, so the context below is supplied by the analyst to show the mechanism. In a pipeline that value would need to come from a document header, a posting address, or a stated scope for the collection.
riverton_candidates <- geocoded_rows |>
filter(mention_id == "s023") |>
select(mention, latitude, longitude, parent)
supplied_context <- tibble(
mention = "Riverton",
parent_context = "Marrow County"
)
with_parent_context <- riverton_candidates |>
inner_join(supplied_context, by = join_by(mention)) |>
filter(parent == parent_context)| Mention | Latitude | Longitude | Parent |
|---|---|---|---|
| Riverton | 41.821 | -71.4120 | Marrow County |
| Riverton | 38.441 | -75.1002 | Tidewater |
| Mention | Latitude | Longitude | Parent | Context supplied |
|---|---|---|---|---|
| Riverton | 41.821 | -71.412 | Marrow County | Marrow County |
The context row shows a decision rule, not evidence from the flyer. Without the parent region, a prior belief, or surrounding text, the two Riverton rows remain tied.
Geocoding in production
The local exact-match join in this lesson shows the mechanism of geocoding, but production systems rely on specialized geocoding APIs (such as OpenStreetMap/Nominatim) or packages (like tidygeocoder in R or geopy in Python) rather than raw string joins.
When using a live service, several boundaries and limits apply: - Match precision: APIs can perform fuzzy matching, returning a confidence score. A low score requires uncertain-result inspection. - Service boundaries: Rate limits and Terms of Service often dictate how many requests you can make. - Caching: Storing results locally prevents redundant network calls and speeds up repeated processing. - Privacy and re-identification: Sending text containing personal locations to an external API can leak private data. Self-hosted geocoders (like a local Nominatim instance) keep sensitive text entirely on your own infrastructure.
What to remember
- A gazetteer is a reference list of place names with locations attached.
- Geocoding turns a place mention into coordinates.
- Joining by name can return more than one coordinate pair.
- A miss should stay a miss rather than becoming a guess.
- Context such as a parent region can reduce the candidate list.
ambiguousandnot foundare first-class geocoding answers.
This teaching gazetteer can map Riverton Skills Centre and Bellhaven. The word Riverton still has two candidate locations, and West Calder has none.