Skip to content

Seed the augmentation choice in AugMix - #23880

Merged
hertschuh merged 3 commits into
keras-team:masterfrom
Ayoubhm07:fix/augmix-seed-op-choice
Nov 9, 2026
Merged

hertschuh merged 3 commits into
keras-team:masterfrom
Ayoubhm07:fix/augmix-seed-op-choice

Conversation

@Ayoubhm07

@Ayoubhm07 Ayoubhm07 commented Oct 9, 2026 •

Copy link
Copy Markdown

Description

AugMix accepts a seed and serializes it in get_config, but the augmentation choice drew from Python's global random, so AugMix(seed=1234) was not reproducible. Under tf.data the choice was also baked into the graph at trace time, so every batch went through the same augmentations.

Fixes #23871

Following the design in the issue: get_random_transformation draws layer_idxes of shape (num_chains, chain_depth) with self.backend.random.randint(..., len(self._augment_layers) + 1, seed=seed), where the extra index means no augmentation. While tracing it samples the transformation of every candidate for each chain depth; in eager mode the drawn index is already known, so _sample_layer_transformations samples only the drawn candidate and returns None for the others, and get_random_transformation picks the path with _is_tracing(layer_idxes). transform_images and transform_segmentation_masks share _apply_selected_layer, which casts every branch to the input dtype and selects with self.backend.ops.switch while tracing, or with branches[int(layer_idx)] in eager. import random is no longer needed and is gone.

test_graph_issue here differs from the one in rand_augment_test.py in two ways. It maps get_random_transformation(x)["layer_idxes"] instead of the whole dict, because the dict now holds one candidate transformation per layer and auto_contrast and equalization contribute None, which Dataset.map cannot build a TypeSpec for. And it asserts len(np.unique(np.stack(key_list), axis=0)) > 1 instead of the flat len(np.unique(key_list)) != 1: the flat form counts distinct values inside a single draw rather than distinct draws across batches, so a frozen choice still passes it, in 90.8% of 20000 simulated runs for RandAugment's (num_ops,) indices and in 100% of them for the (num_chains, chain_depth) indices used here. Stacking first compares the batches themselves.

Measured on TensorFlow 2.21. In eager mode a call draws at most 9 sub-layer transformations instead of 63, or 99 with all_ops=True; a slot that lands on no augmentation draws nothing, as on master. Eager calls are 3.6x, 5.3x and 3.9x faster than sampling every candidate (minimum of 15 calls, implementations alternated three times). Against master the gap is within run to run drift on this machine.

Under JAX the cost is compile time. @JyotinderSingh measured about 35 s on master, about 270 s with this change and about 300 s with all_ops=True: each of the nine (chain, depth) slots is a switch over every candidate, so the traced graph holds them all nine times. A model containing AugMix pays this once, on the first fit step.

Side effects of the eager shortcut: eager and traced runs no longer consume the same draws, so the same seed gives different augmentations in the two modes (master already differed, since its Python choice was frozen at trace time), and a transformation drawn in eager cannot be replayed under tracing because the undrawn candidates are None. call draws and applies in the same regime and is unaffected. Same-seed reproducibility holds, maximum absolute difference 0.0.

Tested locally with the CI configs: aug_mix_test.py and base_image_preprocessing_layer_test.py pass on tensorflow, jax, numpy and torch (channels_first). Under torch.compile with the eager backend the Python path runs and the output matches eager at the same seed. openvino and a full inductor compile are untested here.

Disclosure: I used an AI coding assistant while investigating this and preparing the patch. I have reviewed and tested every line myself, I understand the mechanics of the fix, and I take full responsibility for the code.

Contributor Agreement

Please review our PR Contribution Policy and AI-Assisted Contribution Policy and check all boxes below before submitting your PR for review:

  • This PR is linked to an issue that has been assigned to me (see Fixes #xxx above).
  • I am a human, and not a bot.
  • I will be responsible for responding to review comments in a timely manner.
  • I will work with the maintainers to push this PR forward until submission.

Note: Failing to adhere to this agreement may result in your future PRs no longer being reviewed. PRs without a linked, assigned issue will be converted to draft.

AugMix accepts a seed and serializes it in get_config, but the
augmentation choice in get_random_transformation drew from Python's
global random module. Two layers built with the same seed produced
different outputs, and under tf.data the choice was baked into the graph
at trace time so every batch went through the same augmentations.

Draw layer_idxes of shape (num_chains, chain_depth) with the layer seed,
using one index past the end of the candidate list to mean no
augmentation, and sample the transformation of every candidate for each
chain depth. transform_images and transform_segmentation_masks share
_apply_selected_layer, which casts every branch to the input dtype and
selects with ops.switch while tracing or with branches[int(layer_idx)]
in eager. The random import is no longer needed.

Add test_seed_reproducibility, which fails without this change, and
test_graph_issue, which compares the stacked per-batch indices so that a
frozen choice cannot pass it.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the AugMix preprocessing layer to support symbolic execution and tracing across different backends. It replaces Python-level random choices with backend-agnostic random operations and introduces dynamic branching using ops.switch when tracing. The review feedback suggests a more robust, backend-agnostic approach using a try...except block in _apply_selected_layer instead of the fragile _is_tracing helper, which would also allow removing the newly added jax_utils import.

Comment on lines +266 to +289
def _is_tracing(self, layer_idx):
if backend_utils.in_tf_graph():
return True
return jax_utils.is_in_jax_tracing_scope(layer_idx)

def _apply_selected_layer(self, inputs, transforms, layer_idx, method):
dtype = self.backend.standardize_dtype(inputs.dtype)

def make_branch(layer_name, transformation):
layer = getattr(self, layer_name)
transform = getattr(layer, method)
return lambda x: self.backend.ops.cast(
transform(x, transformation), dtype
)

branches = [
make_branch(layer_name, transforms[idx])
for idx, layer_name in enumerate(self._augment_layers)
]
branches.append(lambda x: self.backend.ops.cast(x, dtype))

if self._is_tracing(layer_idx):
return self.backend.ops.switch(layer_idx, branches, inputs)
return branches[int(layer_idx)](inputs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Instead of relying on explicit tracing checks (_is_tracing) which can be fragile and fail during Keras functional model construction or under PyTorch compilation (torch.compile), we can use a standard Python try...except block. This is extremely robust, backend-agnostic, and avoids the need for the _is_tracing helper entirely.

    def _apply_selected_layer(self, inputs, transforms, layer_idx, method):
        dtype = self.backend.standardize_dtype(inputs.dtype)

        def make_branch(layer_name, transformation):
            layer = getattr(self, layer_name)
            transform = getattr(layer, method)
            return lambda x: self.backend.ops.cast(
                transform(x, transformation), dtype
            )

        branches = [
            make_branch(layer_name, transforms[idx])
            for idx, layer_name in enumerate(self._augment_layers)
        ]
        branches.append(lambda x: self.backend.ops.cast(x, dtype))

        try:
            idx = int(layer_idx)
            return branches[idx](inputs)
        except (TypeError, ValueError, RuntimeError):
            return self.backend.ops.switch(layer_idx, branches, inputs)

)
from keras.src.random import SeedGenerator
from keras.src.utils import backend_utils
from keras.src.utils import jax_utils

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since we can use a robust try...except block to handle symbolic vs. eager execution in _apply_selected_layer, the jax_utils import is no longer needed and can be removed.

@Ayoubhm07

Copy link
Copy Markdown
Author

I measured both halves of this.

Functional model construction does not reach _apply_selected_layer: instrumenting it and building Input -> AugMix() gives 0 calls, since the layer goes through compute_output_spec on KerasTensors.

Under torch.compile, int(layer_idx) does not raise. Dynamo emits a graph break on Tensor.item() and execution continues: _is_tracing returned False on all 18 calls with a concrete Tensor, and the compiled call returned a correct (2, 8, 8, 3) float32 output. So try/except would never fire there either, and both forms take the same Python path. Selecting switch under dynamo would need an explicit torch-tracing check, which neither version has.

jax.jit works too: the tracing branch fired on all 9 calls and the jit succeeded.

The current form is the one @maitry63 specified in #23871, so I would rather a maintainer decide. If try/except is preferred, I would keep only the conversion inside the try so a branch error propagates directly.

@keerthanakadiri keerthanakadiri self-assigned this Oct 12, 2026
@keerthanakadiri keerthanakadiri added the stat:awaiting keras-eng Awaiting response from Keras engineer label Oct 12, 2026
@codecov-commenter

codecov-commenter commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.65517% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.64%. Comparing base (7bea4c4) to head (27d4e9c).
⚠️ Report is 40 commits behind head on master.

Files with missing lines Patch % Lines
...ayers/preprocessing/image_preprocessing/aug_mix.py 89.65% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #23880      +/-   ##
==========================================
- Coverage   87.41%   84.64%   -2.77%     
==========================================
  Files         479      479              
  Lines       63950    63829     -121     
  Branches    10817    10763      -54     
==========================================
- Hits        55903    54031    -1872     
- Misses       5266     7162    +1896     
+ Partials     2781     2636     -145     
Flag Coverage Δ
keras 84.47% <89.65%> (-2.76%) ⬇️
keras-cpu 84.47% <89.65%> (-2.03%) ⬇️
keras-gpu ?
keras-jax 66.04% <89.65%> (-2.36%) ⬇️
keras-numpy 61.70% <89.65%> (-0.11%) ⬇️
keras-tensorflow 67.96% <89.65%> (-2.28%) ⬇️
keras-torch 67.51% <89.65%> (-2.38%) ⬇️
keras-tpu ?
keras.applications ?
keras.applications-jax ?
keras.applications-numpy ?
keras.applications-tensorflow ?
keras.applications-torch ?
keras.wrappers ?
keras.wrappers-jax ?
keras.wrappers-numpy ?
keras.wrappers-tensorflow ?
keras.wrappers-torch ?

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@JyotinderSingh JyotinderSingh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes are correct. Left a comment about the added cost.

len(self._augment_layers) + 1,
seed=seed,
)
chain_transforms = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR reports numbers for TensorFlow only. Two more costs exist.

JAX compile time. Wrap the layer in jax.jit (8x8 images, CPU). Compilation takes about 270 s with this change. It takes about 300 s with all_ops=True. Master takes about 35 s.

Each of the 9 (chain, depth) slots is now a switch over all 8 branches (12 with all_ops). The traced graph therefore holds every candidate transformation 9 times. Master bakes in one choice. A model that contains AugMix pays this cost on the first fit step under JAX.

This follows the design in the issue. I think the cost is acceptable. But putting it in the description would be good.

Eager cost. In eager mode, the layer samples the transformation of every candidate. It then uses only one. Time per call, master -> this PR:

  • torch: about 35 -> 140 ms
  • TensorFlow: about 770 -> 1700 ms
  • JAX: about 200 -> 280 ms

These are just numbers from a small run I did, so the absolute values are not exact. Your description offers an eager shortcut. Please add it. In eager mode, the drawn index is a known value, so only the drawn candidate needs sampling. Draw layer_idxes first. Then do this:

        if self._is_tracing(layer_idxes):
            chain_transforms = [
                [
                    self._sample_layer_transformations(data)
                    for _ in range(self.chain_depth)
                ]
                for _ in range(self.num_chains)
            ]
        else:
            chain_transforms = [
                [
                    self._sample_layer_transformations(
                        data, selected=int(layer_idxes[i][j])
                    )
                    for j in range(self.chain_depth)
                ]
                for i in range(self.num_chains)
            ]

Change _sample_layer_transformations to (self, data, selected=None). When selected is set, it returns None for each index that is not selected. _apply_selected_layer already calls only the selected branch in eager mode. No other change is necessary.

I tried this change. On torch, the eager call returns to about 36 ms. On torch and JAX, the same seed still gives identical output.

]
branches.append(lambda x: self.backend.ops.cast(x, dtype))

if self._is_tracing(layer_idx):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keep this code as it is. Gemini suggests a try/except block. That block also catches real TypeError, RuntimeError and ValueError errors that a branch raises (for example, from transform_images). It then runs the branch again through ops.switch. The real error is hidden behind a tracing error that is difficult to understand. The issue asks for the explicit check with in_tf_graph() and is_in_jax_tracing_scope().

@Ayoubhm07 Ayoubhm07 Oct 16, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, the eager path now samples only the drawn candidate, using your snippet as is. _apply_selected_layer is unchanged. On TensorFlow a call draws at most 9 sub-layer transformations instead of 63 (99 with all_ops=True), and eager calls run 3.6x to 5.3x faster. Both costs are in the description, with your JAX numbers.
One side effect worth knowing: a transformation drawn in eager can't be replayed under tracing anymore, since the undrawn candidates are None. call is unaffected.

Tracing still samples every candidate, since the index is only known at run time.

@JyotinderSingh JyotinderSingh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the reviews. LGTM.

@JyotinderSingh JyotinderSingh added ready to pull Ready to be merged into the codebase and removed stat:awaiting keras-eng Awaiting response from Keras engineer labels Oct 17, 2026
@Ayoubhm07

Copy link
Copy Markdown
Author

@hertschuh @JyotinderSingh approved this on Oct 17 and it has carried ready to pull since. The branch is 107 commits behind master with no conflicts. Should I rebase, or does it need a run-gpu-tpu-tests pass first?

@hertschuh
hertschuh merged commit beccdfd into keras-team:master Nov 9, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready to pull Ready to be merged into the codebase

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AugMix ignores its seed argument when choosing augmentations

5 participants