Geocoding place names

Join text mentions to an offline gazetteer

entity enrichment
geocoding
workforce research
Learn how a gazetteer links place names to coordinates while preserving ambiguous and not-found results.

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.

Note

The places, lab, and flyer here belong to an invented Riverton setting.

TipWhat you will learn

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.

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)
Invented Riverton gazetteer used 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"
  )
Joining place mentions to the gazetteer
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))
Each mention receives a geocoding status
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
Resolved, ambiguous, and not-found mentions
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)
The name Riverton has two candidate locations
Mention Latitude Longitude Parent
Riverton 41.821 -71.4120 Marrow County
Riverton 38.441 -75.1002 Tidewater
Parent context narrows the candidate list
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.
  • ambiguous and not found are 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.

Sources