[01-early-stopping] fights overfitting by stopping training early; data augmentation fights the SAME underlying problem from a completely different angle: instead of stopping the model from memorizing a fixed training set, give it a training set that's effectively bigger and more varied in the first place, so there's less to memorize and more genuine variation to actually learn from. If you have 10,000 labeled photos of dogs and cats, and on every single epoch you feed the model a randomly flipped, randomly cropped, or slightly noised version of each photo instead of the exact same pixels every time, the model never sees the EXACT same input twice across the whole training run, which makes naive memorization far less useful, while the underlying semantic content (this is still a photo of a dog) hasn't changed at all.
The defining property every augmentation needs is right there in the name of this question: LABEL-PRESERVING. A horizontal flip of a photo of a dog is still, unambiguously, a photo of a dog; the augmented image can be safely paired with the exact same label the original had. (Not every transformation has this property: flipping a photo of a handwritten "6" upside down would turn it into something that looks like a "9", a genuinely label-CHANGING transformation you'd never want to use for this purpose.)
Implement three small, independent augmentations. horizontal_flip(image) mirrors an image left-to-right. random_crop(image, crop_h, crop_w, rng) extracts a (crop_h, crop_w)-sized region from a random valid position, using the passed-in rng (a np.random.RandomState) to choose that position, so results are reproducible given the same rng state. add_gaussian_noise(image, std, rng) adds independent random noise to every pixel, again using rng for the actual random draws.
horizontal_flip must flip along the WIDTH axis (axis 1, for an image shaped (H, W, ...)), not the height axis.random_crop's chosen top-left corner must always keep the FULL (crop_h, crop_w) region within the image's bounds (never cropping partially off the edge).rng argument, not from NumPy's global random state, so results are exactly reproducible given the same rng.image in place; each should return a new array.image[:, ::-1, ...] reverses the WIDTH axis (axis 1) while leaving height (axis 0) and any remaining axes (like color channels) untouched; ... (Ellipsis) matches however many trailing axes the image actually has.
The top-left corner's row can range from 0 up to (and including) height - crop_h, so use rng.randint(0, height - crop_h + 1) (NumPy's randint upper bound is EXCLUSIVE, hence the +1). Same logic for the column, using width - crop_w.
rng.normal(loc=0.0, scale=std, size=image.shape) draws one independent noise value per pixel, matching image's exact shape; add it directly to image and return the sum (this naturally creates a NEW array, rather than modifying image in place).
Click "Run Tests" to test your implementation