Three settings decide whether an export is usable: the opset version (low enough for the deployed runtime, high enough for the operators), the dynamic axes (batch, and height/width for convolutional models), and — for scikit-learn classifiers — turning off ZipMap so the output is a tensor rather than a list of dictionaries. Get those right and the remaining work is a numerical parity check.
This page is the troubleshooting guide. For why to export at all, and how to serve the result, see ONNX Export for Geospatial Model Inference.
Why This Fails in Geospatial ML Pipelines
The opset mismatch is a deployment-time failure, which is the worst kind. Export with the newest torch on a workstation and the file targets opset 20; the inference image pins onnxruntime==1.14 for GDAL compatibility and supports up to 17. The export succeeded, the file is valid, and the container raises InvalidGraph on load — at 4 a.m., in the tile task, after the sensor has already waited three hours for the scene.
The second failure is a shape baked in by constant folding. dynamic_axes marks an axis as free, but if the model computes something from x.shape[2] in Python rather than with a tensor operation, the tracer records the concrete value. The export runs, the parity check on the same 256-pixel patch passes, and the first 512-pixel tile in production fails with a shape mismatch. Testing two different shapes is the only way to catch it.
Third, ZipMap. skl2onnx wraps classifier probabilities in a sequence of maps, so sess.run(...) returns a list of dictionaries rather than an array. In a per-pixel raster loop that is both a type surprise and a serious slowdown — millions of dictionary allocations per tile. Disabling it changes the output to a plain tensor and typically makes inference several times faster.
Core Principles
- Pin the opset from the runtime, not from the exporter.
- Mark every axis that varies — batch, height, width — and verify with two shapes.
- Disable
ZipMapfor scikit-learn classifiers. - Check parity numerically, with a tolerance you chose deliberately.
- Test-load the artifact with the deployment runtime in CI.
- Record the opset and runtime version in the model card.
Production-Ready Code
from __future__ import annotations
import logging
import numpy as np
import onnx
import onnxruntime as ort
logger = logging.getLogger(__name__)
def runtime_max_opset() -> int:
"""Highest opset the INSTALLED onnxruntime supports."""
domains = ort.get_available_providers() and onnx.defs.onnx_opset_version()
return int(domains)
def export_torch(model, sample: "torch.Tensor", path: str, opset: int) -> None:
"""Export a convolutional model with dynamic batch, height and width."""
import torch
model.eval()
torch.onnx.export(
model, sample, path,
input_names=["input"], output_names=["logits"],
dynamic_axes={"input": {0: "batch", 2: "height", 3: "width"},
"logits": {0: "batch", 2: "height", 3: "width"}},
opset_version=opset, do_constant_folding=True,
)
onnx.checker.check_model(onnx.load(path))
logger.info("exported %s at opset %d", path, opset)
def export_sklearn(pipeline, n_features: int, path: str, opset: int) -> None:
"""Export a scikit-learn classifier WITHOUT ZipMap, with a dynamic row axis."""
from skl2onnx import to_onnx
from skl2onnx.common.data_types import FloatTensorType
initial = [("input", FloatTensorType([None, n_features]))]
model = to_onnx(pipeline, initial_types=initial, target_opset=opset,
options={id(pipeline): {"zipmap": False}})
with open(path, "wb") as fh:
fh.write(model.SerializeToString())
onnx.checker.check_model(onnx.load(path))
logger.info("exported %s at opset %d (zipmap disabled)", path, opset)
def assert_dynamic(path: str, axes: tuple[int, ...] = (0, 2, 3)) -> None:
"""Fail if an axis that should be free was baked to a constant."""
graph = onnx.load(path).graph
dims = graph.input[0].type.tensor_type.shape.dim
for axis in axes:
d = dims[axis]
if d.HasField("dim_value"):
raise ValueError(
f"axis {axis} is fixed at {d.dim_value}; it should be dynamic. "
"A Python-level use of x.shape was traced as a constant.")
def check_parity(path: str, reference_fn, sample: np.ndarray,
atol: float = 1e-4) -> float:
"""Max absolute difference between the ONNX graph and the source model."""
sess = ort.InferenceSession(path, providers=["CPUExecutionProvider"])
got = sess.run(None, {sess.get_inputs()[0].name: sample.astype("float32")})[0]
want = np.asarray(reference_fn(sample))
if got.shape != want.shape:
raise ValueError(f"shape mismatch: onnx {got.shape} vs source {want.shape}")
diff = float(np.max(np.abs(got - want)))
if diff > atol:
raise ValueError(f"numerical parity failed: max |diff| = {diff:.3e} > {atol}")
logger.info("parity ok, max |diff| = %.3e", diff)
return diff
def check_two_shapes(path: str, base: np.ndarray) -> None:
"""The real dynamic-shape test: two different sizes, one session."""
sess = ort.InferenceSession(path, providers=["CPUExecutionProvider"])
name = sess.get_inputs()[0].name
small = base[:, :, :128, :128].astype("float32")
large = base[:, :, :512, :512].astype("float32")
for arr in (small, large):
out = sess.run(None, {name: arr})[0]
assert out.shape[-2:] == arr.shape[-2:], f"shape not preserved for {arr.shape}"Step-by-Step Walkthrough
Step 1 — Pick the opset from the deployment image, not the laptop
docker run --rm geoml-inference:2026.08 python -c \
"import onnxruntime, onnx; print(onnxruntime.__version__, onnx.defs.onnx_opset_version())"
# 1.18.0 21Record that number in configuration. Export targets min(runtime_max, chosen) and never the exporter’s default.
Step 2 — Export, then immediately assert the axes
import torch
TARGET_OPSET = 17
sample = torch.zeros(1, 6, 256, 256)
export_torch(model, sample, "artifacts/unet_v7.onnx", opset=TARGET_OPSET)
assert_dynamic("artifacts/unet_v7.onnx", axes=(0, 2, 3))assert_dynamic is the cheap check that catches constant folding before it reaches production.
Step 3 — Run parity and the two-shape test
import numpy as np
x = np.random.rand(2, 6, 256, 256).astype("float32")
check_parity("artifacts/unet_v7.onnx",
reference_fn=lambda a: model(torch.from_numpy(a)).detach().numpy(),
sample=x, atol=1e-4)
check_two_shapes("artifacts/unet_v7.onnx", x)A parity failure at 1e-4 on a convolutional model usually means the model was exported in training mode — check model.eval() — because batch-norm running statistics differ between modes.
Step 4 — For scikit-learn, kill ZipMap and confirm the output type
export_sklearn(pipeline, n_features=12, path="artifacts/landcover_rf.onnx",
opset=TARGET_OPSET)
sess = ort.InferenceSession("artifacts/landcover_rf.onnx")
out = sess.run(None, {sess.get_inputs()[0].name:
np.random.rand(5, 12).astype("float32")})
print([type(o) for o in out], out[1].shape)
assert isinstance(out[1], np.ndarray), "ZipMap is still enabled"Step 5 — Test-load in the deployment image in CI
- name: verify the artifact loads in the inference runtime
run: |
docker run --rm -v "$PWD/artifacts:/a" geoml-inference:2026.08 \
python -c "import onnxruntime as ort; \
s=ort.InferenceSession('/a/unet_v7.onnx'); \
print([i.shape for i in s.get_inputs()])"This one CI step removes the entire class of “valid file, wrong runtime” incidents.
Verification
import numpy as np
import onnx
import onnxruntime as ort
import pytest
def test_opset_is_within_the_runtime_window():
model = onnx.load("artifacts/unet_v7.onnx")
exported = max(o.version for o in model.opset_import if o.domain in ("", "ai.onnx"))
assert exported <= onnx.defs.onnx_opset_version()
def test_axes_are_dynamic():
assert_dynamic("artifacts/unet_v7.onnx", axes=(0, 2, 3))
def test_two_tile_sizes_both_run():
x = np.random.rand(1, 6, 512, 512).astype("float32")
check_two_shapes("artifacts/unet_v7.onnx", x)
def test_parity_within_tolerance():
x = np.random.rand(2, 6, 256, 256).astype("float32")
diff = check_parity("artifacts/unet_v7.onnx",
reference_fn=torch_reference, sample=x, atol=1e-4)
assert diff < 1e-4
def test_sklearn_output_is_a_tensor():
sess = ort.InferenceSession("artifacts/landcover_rf.onnx")
out = sess.run(None, {sess.get_inputs()[0].name:
np.random.rand(4, 12).astype("float32")})
assert all(isinstance(o, np.ndarray) for o in out), "ZipMap still enabled"
def test_nodata_rows_do_not_change_valid_predictions():
"""A NaN row must not contaminate its batch neighbours."""
sess = ort.InferenceSession("artifacts/landcover_rf.onnx")
name = sess.get_inputs()[0].name
x = np.random.rand(4, 12).astype("float32")
alone = sess.run(None, {name: x[:1]})[0]
x_with_nan = x.copy()
x_with_nan[3] = np.nan
together = sess.run(None, {name: x_with_nan})[0][:1]
assert np.allclose(alone, together, atol=1e-5)The last test is worth keeping for tree models in particular: some export paths handle NaN differently from the source library, and a raster batch always contains nodata rows.
FAQ
Which opset version should I target?
The highest one the deployed runtime accepts, provided it covers your operators. Read it from the inference image, pin it in configuration, and never take the exporter’s default.
How do I export a model that must accept any tile size?
Mark height and width — and batch, if you batch tiles — in dynamic_axes, then verify with two different shapes. Constant folding can bake a shape in even when the axis is declared dynamic.
Why does my exported classifier return a list of dictionaries?
That is skl2onnx’s ZipMap. Disable it with options={id(pipeline): {"zipmap": False}} so the output is a plain probability tensor — both simpler and considerably faster in a raster loop.
Related
- ONNX Export for Geospatial Model Inference — why export, and what it buys
- Serving an ONNX Land Cover Model with onnxruntime — running the artifact this produces
- Pinning GDAL and PROJ Versions in a Container Build — why the runtime version is constrained in the first place
- Scaling Batch Inference with Dask and Ray — distributing the exported session
Part of: ONNX Export for Geospatial Model Inference Part of: Geospatial MLOps and Model Deployment