Skip to content

Inference correctness

Assertions

from pytest_torch import (
    assert_tensors_close, assert_outputs_close, assert_finite, assert_batch_invariant,
)

assert_tensors_close(actual, expected, rtol=1e-4)      # dtype-aware default tolerances
assert_outputs_close(out, reference_out)               # nested outputs, reports the path of each mismatch
assert_finite(out, "output")                           # NaN/Inf anywhere in the structure
assert_batch_invariant(model, batch)                   # model(batch)[i] == model(batch[i:i+1])[0]

Floating-point tensors use dtype-aware tolerances; complex tensors compare real and imaginary parts; integer and bool tensors compare exactly (also above 2^53) unless you pass rtol/atol. NaNs must sit at the same positions and count as equal unless equal_nan=False. assert_outputs_close also reports structure changes, including empty containers ([] vs {}); a tuple and a list with the same contents count as the same.

Failures say how many elements differ, where the worst one is, and the absolute and relative error:

AssertionError: Mask invariance violated (masked tokens leak): 1 problem(s)
  output['pooled']: 11/24 elements differ beyond tolerance (rtol=1.3e-06, atol=1e-05)
    max abs error: 2.1e-01 at index (1, 5) (actual=..., expected=...)

Inference contract

One call runs the checks every inference model should satisfy and explains failures:

report = check_inference_contract(model, x, raise_on_failure=False)
print(report)
Inference contract: 4 passed, 1 failed, 2 skipped
  [PASS] eval_mode
  [PASS] finite_outputs
  [PASS] deterministic
  [FAIL] batch_invariance
         per-sample results differ from the batched run
         hint: Output depends on batch composition: check batch norm in training mode, ...
  [SKIP] device_parity (no other device available)
  [SKIP] dtype_parity (no dtypes requested, e.g. dtypes=(torch.bfloat16,))
  [PASS] no_state_mutation

device_parity compares against the other available devices; when --torch-device pins devices, only against the other pinned ones, so --torch-device=cpu never touches a GPU. Select checks with checks=[...] / skip=[...] (selecting none is an error, raised before the model runs); tune tolerances with rtol, device_rtol, dtype_rtol, ...

no_state_mutation covers every registered parameter and buffer, non-persistent buffers included, and reports state added or removed during inference. Plain Python attributes, such as a call counter, are outside its scope. The first output is copied as soon as it's produced, so a model that returns a buffer it overwrites in place on later calls fails the determinism check instead of passing it.

Find the first diverging layer

from pytest_torch import localize_divergence

report = localize_divergence(model_cpu, model_gpu, x)
report.raise_if_diverged()
Divergence: 3 of 12 layers exceed tolerance (rtol=0.0001, atol=0.0001). First: 'encoder.1' (LayerNorm)
  layer       type        max abs   max rel  inputs
  encoder.1   LayerNorm  3.2e-02   1.1e-01  ok
The first diverging layer receives matching inputs: the error originates inside it.

inputs=ok means the error started inside that layer; inputs=DIFF means it entered between layers (a functional op or residual add). Use module_map when layer names differ between the two models.

Invariance and metamorphic tests

from pytest_torch import (
    assert_padding_invariant, assert_mask_invariant,
    assert_permutation_invariant, assert_permutation_equivariant,
    assert_invariant, assert_equivariant,
)

assert_padding_invariant(model, batch, extra=4, side="right")  # padding must not change real tokens
assert_mask_invariant(model, batch)                            # masked ids must not leak
assert_permutation_invariant(set_model, points, dim=1)         # order of a set must not matter
assert_permutation_equivariant(per_token_model, tokens)        # permuted in -> permuted out
assert_invariant(model, x, lambda t: -t, name="sign flip")     # any custom relation

batch is a dict like {"input_ids": ..., "attention_mask": ...}. With left padding (side="left"), models with absolute position ids can legitimately differ: use side="right", or pass position_ids. Sequence-aligned outputs are compared only on real tokens; pooled outputs are compared as is.

Golden files

@pytest.mark.torch_golden("classifier_v1")
def test_matches_golden(model, make_tensor, golden):
    golden.check(model(make_tensor(8, 16)), atol=1e-4)
pytest --torch-update-golden --torch-device=cpu    # (re)generate a portable reference on CPU

A missing golden file fails: CI must never pass vacuously.

Hypothesis strategies

from hypothesis import given, settings
from pytest_torch.strategies import tensors

MODEL = build_model().eval()

@settings(max_examples=50, deadline=None)
@given(x=tensors(shape=(4, 16)))              # zeros, constants, extremes, tiny values, ...
def test_finite_on_edge_cases(x):
    assert torch.isfinite(MODEL(x)).all()

Build models at module scope: Hypothesis rejects function-scoped fixtures in @given tests by default.