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
The flyer transcript contains RIVERTON SKILLS OPEN HOUSE, but Riverton matches two places in the reference table. The coordinator needs both possibilities to remain 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:
- explain 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.
- explain why context matters in geocoding.
Load the offline reference files
Geocoding links a place name in text to coordinates, the latitude and longitude that locate a point on a map. CSV files supply examples, while dplyr and tibble prepare tables. digest calculates a file fingerprint, a code that the lesson compares with the expected fingerprint.
A gazetteer lists place names and their locations. Gazetteers may be public, open-licence, or commercial reference datasets. This invented local gazetteer lets the lesson execute 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 is in Marrow County, Calder, and one is in Tidewater. The parent column names the larger place that contains each location. The coordinates are arbitrary teaching points, not the locations of real sites.
This table classifies Riverton Skills Centre as a building. The entity-linking lesson uses the same name for an organisation. The same string can denote either a place or an organisation. Named-entity systems must account for this ORG-versus-place ambiguity.
Join mentions to the gazetteer
The team starts with five manually selected 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. The code explicitly permits these multiple matches to preserve ambiguity. 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 must report unresolved mentions. 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 does not distinguish the two rows. The flyer gives no county, so the analyst supplies the context below to demonstrate the rule. A pipeline needs that value from a document header, posting address, or stated collection limits.
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 equally possible.
Geocoding in production
This local exact-match join demonstrates geocoding. Production systems use specialized APIs, such as OpenStreetMap/Nominatim, rather than raw string joins. Packages such as tidygeocoder in R or geopy in Python provide interfaces to those services.
Live services have several limits:
- Match precision: APIs can use fuzzy matching to compare similar names rather than require identical strings. Examine geocoding results with low confidence scores.
- Service boundaries: Rate limits and Terms of Service often dictate how many requests you can make.
- Caching: Locally stored results prevent redundant network calls and reduce processing time.
- Privacy and re-identification: Text sent to an external API can reveal personal locations. A self-hosted geocoder, such as a local Nominatim instance, keeps that text on your 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 valid geocoding results.
This teaching gazetteer can map Riverton Skills Centre and Bellhaven. The word Riverton still has two candidate locations, and West Calder has none.