Convert each coordinate to an H3 index with h3.latlng_to_cell(lat, lon, res), then use that index as a categorical key — never as a number. From there you have three useful features: the cell key itself (target-encoded), aggregates computed over the cell and its neighbour ring, and the parent cell at a coarser resolution for a multi-scale view. The single most common mistake is passing the raw 64-bit index into a model as an integer.
This page covers the H3 workflow. For the encoding methods it depends on, and for the alternatives, see Encoding Categorical Geographic Features.
Why This Fails in Geospatial ML Pipelines
An H3 index looks like a number, and that is the trap. 8928308280fffff parsed as an integer is a bit-packed record: four reserved bits, a mode, a resolution, a base-cell number, and fifteen three-bit digits describing the path down the hierarchy. Two adjacent hexagons can differ in a high-order digit and therefore by an enormous numeric gap, while two cells on opposite sides of the planet can sit next to each other numerically. A gradient-boosted tree splitting on that integer draws boundaries that have no geographic meaning whatsoever — and because the feature still carries some signal (base cells are geographic), it does not look useless in an importance ranking.
The second failure is resolution mismatch between training and serving. H3 resolution is a parameter, and a pipeline that indexes at resolution 9 during training and resolution 8 at inference produces keys that share no values at all: every inference row is an unseen category. If the encoder falls back to a prior, the whole feature degrades to a constant; if it fails on NaN, the batch dies. Pinning the resolution in configuration and writing it into the model artifact is the fix, and it belongs in the same class of discipline as scaling features consistently between training and inference.
Third, high resolutions explode cardinality. Resolution 9 covers a mid-sized country in millions of cells, most containing a single sample. Target-encoded, those cells memorise their own labels; one-hot encoded, they produce a matrix nothing can fit. Cardinality has to be chosen deliberately against sample count, not inherited from whatever resolution a tutorial used.
Core Principles
- The index is a key, never a magnitude. Store it as a string, encode it as a category.
- Pin the resolution in configuration. Write it into the model artifact and assert it at inference.
- Generate two or three resolutions in parallel. Let importance select the scale rather than guessing it.
- Aggregate over rings, not radii.
grid_disk(cell, k)gives an isotropic neighbourhood for free. - Watch cardinality against sample count. Aim for at least 20–30 samples per cell at the finest resolution you encode.
- Use the parent cell for the folds. Coarse H3 cells make natural, reproducible spatial blocks.
Production-Ready Code
from __future__ import annotations
import logging
from dataclasses import dataclass
import geopandas as gpd
import h3
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class H3Config:
"""Pinned H3 settings. Ship this with the model artifact."""
base_res: int = 8 # finest resolution used as a key
parent_levels: tuple = (7, 6)
ring_k: int = 1 # neighbourhood radius in cells
def validate(self) -> None:
if not 0 <= self.base_res <= 15:
raise ValueError(f"base_res {self.base_res} outside H3 range 0-15")
if any(p >= self.base_res for p in self.parent_levels):
raise ValueError("parent levels must be coarser than base_res")
def add_h3_keys(gdf: gpd.GeoDataFrame, cfg: H3Config) -> gpd.GeoDataFrame:
"""Attach H3 keys at the base resolution and each configured parent level.
Requires point geometries in EPSG:4326, because H3 indexes latitude/longitude.
"""
cfg.validate()
if gdf.crs is None:
raise ValueError("GeoDataFrame has no CRS")
pts = gdf.to_crs("EPSG:4326") if gdf.crs.to_epsg() != 4326 else gdf
if not (pts.geometry.geom_type == "Point").all():
pts = pts.copy()
pts["geometry"] = pts.geometry.centroid
out = gdf.copy()
out[f"h3_{cfg.base_res}"] = [
h3.latlng_to_cell(y, x, cfg.base_res)
for x, y in zip(pts.geometry.x, pts.geometry.y)
]
for level in cfg.parent_levels:
out[f"h3_{level}"] = [h3.cell_to_parent(c, level) for c in out[f"h3_{cfg.base_res}"]]
counts = out[f"h3_{cfg.base_res}"].value_counts()
logger.info("res %d: %d cells, median %d samples/cell, %d singleton cells",
cfg.base_res, len(counts), int(counts.median()), int((counts == 1).sum()))
return out
def ring_aggregate(frame: pd.DataFrame, cfg: H3Config, value_col: str,
stat: str = "mean") -> pd.Series:
"""Aggregate a value over each cell's k-ring neighbourhood.
Uses only the cells present in `frame`; missing neighbours are simply absent
from the aggregate rather than imputed, which keeps edge cells honest.
"""
key = f"h3_{cfg.base_res}"
per_cell = frame.groupby(key)[value_col].agg(stat)
lookup = per_cell.to_dict()
values = []
for cell in frame[key]:
neighbours = h3.grid_disk(cell, cfg.ring_k)
present = [lookup[n] for n in neighbours if n in lookup]
values.append(float(np.mean(present)) if present else np.nan)
return pd.Series(values, index=frame.index, name=f"{value_col}_ring{cfg.ring_k}")
def cell_area_km2(cell: str) -> float:
"""Real area of a specific cell — hexagons are not all the same size."""
return h3.cell_area(cell, unit="km^2")Step-by-Step Walkthrough
Step 1 — Index the samples
cfg = H3Config(base_res=8, parent_levels=(7, 6), ring_k=1)
samples = add_h3_keys(gpd.read_file("observations.gpkg"), cfg)
print(samples[["h3_8", "h3_7", "h3_6"]].head())Check the log line. If the median samples-per-cell is 1, the resolution is too fine for this dataset and every cell key will be a private identifier for one observation.
Step 2 — Choose the resolution from the data, not from habit
for res in range(5, 11):
keys = [h3.latlng_to_cell(y, x, res)
for x, y in zip(samples.geometry.x, samples.geometry.y)]
n_cells = len(set(keys))
print(f"res {res:>2}: {n_cells:>7,} cells, {len(samples) / n_cells:>7.1f} samples/cell")Take the finest resolution that still averages a few dozen samples per cell, and one or two coarser levels alongside it.
Step 3 — Encode the keys, without leaking
folds = samples["h3_6"].astype("category").cat.codes.to_numpy() % 5 # coarse spatial folds
encoder = SmoothedTargetEncoder(smoothing=20.0)
samples["h3_8_te"] = encoder.fit_transform_oof(
samples, key="h3_8", target="yield_t_ha", folds=folds
)Using the resolution-6 parent as the fold key means a whole 3 km hexagon moves together, so the encoding is never validated against its own neighbourhood — the leakage-safe recipe from target encoding administrative regions without leakage.
Step 4 — Add neighbourhood aggregates
samples["ndvi_ring1"] = ring_aggregate(samples, cfg, value_col="ndvi_mean")
samples["ndvi_delta"] = samples["ndvi_mean"] - samples["ndvi_ring1"]
print(samples[["ndvi_mean", "ndvi_ring1", "ndvi_delta"]].describe())The difference between a cell and its ring is often more predictive than either alone: it encodes local anomaly rather than absolute level, which is the same idea as the topographic position index in DEM and terrain derivative features.
Step 5 — Persist the configuration with the model
import json
with open("artifacts/h3_config.json", "w") as fh:
json.dump({"base_res": cfg.base_res, "parent_levels": list(cfg.parent_levels),
"ring_k": cfg.ring_k}, fh)
encoder.save("artifacts/h3_target_encoder.json")Verification
import h3
import numpy as np
def test_index_is_not_ordinal():
"""Numeric ordering of H3 indices must not track geographic proximity."""
a = h3.latlng_to_cell(52.5200, 13.4050, 8) # Berlin
b = h3.latlng_to_cell(52.5210, 13.4060, 8) # ~130 m away
c = h3.latlng_to_cell(-33.8688, 151.2093, 8) # Sydney
near = abs(int(a, 16) - int(b, 16))
far = abs(int(a, 16) - int(c, 16))
assert not (near < far and near < 10), (
"if this ever passes as an ordering, the test is wrong — indices are not distances"
)
def test_parent_contains_child():
cell = h3.latlng_to_cell(52.52, 13.405, 9)
parent = h3.cell_to_parent(cell, 6)
assert cell in h3.cell_to_children(parent, 9)
def test_ring_is_symmetric():
"""Every neighbour of a cell must list that cell as its own neighbour."""
cell = h3.latlng_to_cell(52.52, 13.405, 8)
for n in h3.grid_disk(cell, 1):
assert cell in h3.grid_disk(n, 1)
def test_resolution_pinned_between_train_and_infer():
cfg = H3Config(base_res=8)
saved = {"base_res": 8}
assert cfg.base_res == saved["base_res"], "resolution drifted between train and serve"For a data-side check, plot the samples-per-cell histogram. A long spike at one sample per cell means the resolution is too fine; a distribution with almost every sample in a handful of cells means it is too coarse and the feature carries no local information.
FAQ
Can I feed an H3 index straight into a model as a number?
No. The index is a bit-packed identifier — resolution, base cell, and a digit path — so its numeric order has almost nothing to do with geographic proximity. Treat it as a categorical key and encode it, or aggregate other features over the cell.
Which H3 resolution should I use?
Match the average edge length to the process you are modelling: resolution 8 (≈460 m) for neighbourhood effects, resolution 6 (≈3.2 km) for regional ones. Since the informative scale is rarely obvious, generate two or three levels and let importance decide, as in model explainability for spatial predictions.
Are hexagons actually better than a square grid?
For neighbourhood aggregation, yes. All six neighbours sit at the same centre-to-centre distance, so a ring is isotropic without distance weighting. A square grid mixes edge neighbours and diagonal neighbours that are 41% further away.
Related
- Encoding Categorical Geographic Features — the full range of encoding strategies
- Target Encoding Administrative Regions Without Leakage — the encoder used above
- Spatial Lag and Neighborhood Statistics — the weights-matrix approach to the same neighbourhood idea
- Choosing Block Size for Spatial Block Cross-Validation — using coarse cells as folds
Part of: Encoding Categorical Geographic Features Part of: Spatial Feature Engineering for Machine Learning