Encoding H3 Hexagon Cells as Model Features

Turn coordinates into H3 hexagon indices and use them as ML features: choosing a resolution, parent-cell hierarchies, neighbour rings, and avoiding cell-id-as-number mistakes.

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.

One point, three resolutions, three categorical features A single coordinate is indexed at three H3 resolutions. At resolution 6 it falls in a large hexagon roughly three kilometres across, at resolution 7 in a smaller hexagon roughly twelve hundred metres across, and at resolution 8 in a hexagon roughly four hundred and sixty metres across. Each level becomes its own categorical column. The same point at three scales — let the model choose res 6 edge ≈ 3.2 km regional effects res 7 edge ≈ 1.2 km district effects res 8 edge ≈ 460 m neighbourhood effects 7 children 7 children cell_to_parent() moves up the hierarchy without re-indexing the coordinates.

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")
Hexagons give an isotropic neighbourhood; squares do not On the left a central hexagon is surrounded by six neighbours, each labelled with the same centre-to-centre distance of one. On the right a central square is surrounded by four edge neighbours at distance one and four diagonal neighbours at distance one point four one, so a square ring mixes two different scales. H3 ring: one distance square ring: two distances 1.0 All six neighbours are equidistant — no distance weighting needed. 1.41 1.0 1.41 1.0 1.0 1.41 1.0 1.41 Diagonals are 41% further, so an unweighted square ring mixes two scales silently.

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.

Choosing a resolution is a cardinality trade-off Across H3 resolutions five to ten, the number of distinct cells rises steeply while the average number of samples per cell falls. A shaded band highlights resolutions seven and eight, where cells hold roughly twenty to two hundred samples, which is the usable range for target encoding. Too coarse carries no signal; too fine memorises single samples usable range res 567 8910 cells samples/cell Run the sweep in Step 2 on your own data — the crossing point moves with sample size.

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.


Part of: Encoding Categorical Geographic Features Part of: Spatial Feature Engineering for Machine Learning