Code

Augmentation and balancing — write them yourself

The holes are filled; splitting and normalising work. Two parts remain — this time empty, with no holes: augmentation and class balancing.

What is already written, so you need not

make_loaders has grown. Most importantly, it already knows two things that are easy to get wrong, so you do not write them:

# Two views of the same files: augmented for training, untouched for validation.
train_ds = datasets.ImageFolder(root, transform=build_transform(augment, mean, std))
val_ds = datasets.ImageFolder(root, transform=build_transform(False, mean, std))

The same files are read twice: the training copy with random changes, the validation copy never. One shared ImageFolder with augmentation would mean the validation accuracy is measured on different pictures every time (see step 7).

The only new places in data.py are the two that now say raise NotImplementedError.

1. Augmentation — build_transform

Each time, training pictures are changed a little differently. You do not need new photographs — the same picture is slightly different every epoch.

if augment:
    steps += [
        random horizontal flip,
        random affine change:
            rotation ±10°, shift up to 10 % in each direction,
            scale from 0.9 to 1.1
    ]

In place of raise NotImplementedError write steps += [ ... ] with transforms.RandomHorizontalFlip() and transforms.RandomAffine(...).

Note the order: build_transform first puts Grayscale, then your augmentation, then ToTensor and Normalize. Random changes act on the picture (PIL), while normalisation acts on the numbers.

Think about your categories. A horizontal flip changes nothing for a shoe or a T-shirt. But if your categories are "letter b" and "letter d", a flip turns one into the other — and you have broken the labels yourself. Augmentation must preserve what the network is supposed to recognise.

2. Class balancing — make_loaders

If one class has 20 pictures and the others 200, training batches are mostly made of the big classes. One answer is to draw the rare classes more often:

for each training picture:
    weight = 1 / (how many training pictures are in its class)
sampler = WeightedRandomSampler(weights, num_samples=len(train_idx))

In place of raise NotImplementedError write three lines: a Counter to count the classes (already imported), the list of weights, and a WeightedRandomSampler. The result must land in the variable sampler.

It draws with replacement: the same rare photograph may be drawn several times per epoch. That is one of the costs — see step 7.

The test

test_data.py has grown. The new tests check that:

  • without augmentation a picture loads identically every time;
  • with augmentation — differently;
  • validation pictures with augment=True are the same as without it;
  • without balancing, classes appear as often as they exist ([8, 32, 32]);
  • with balancing, the rare class (10 pictures) appears more than 85 times in 5 epochs (without balancing — 40; an even split would be ~120).

Run python -m unittest test_data (you already have the holes filled):

.EE.....E..
======================================================================
ERROR: test_augmentation_changes_training_images_every_time (...)
NotImplementedError: write the augmentation steps
ERROR: test_balancing_draws_the_rare_class_far_more_often (...)
NotImplementedError: write the balancing sampler
ERROR: test_validation_images_are_never_augmented (...)
NotImplementedError: write the augmentation steps
----------------------------------------------------------------------
Ran 11 tests in 0.8s

FAILED (errors=3)

(Shortened.) Eight tests pass, three are waiting for your code.

data.py is still shown with its holes in the panel — the panel shows the skeleton, not your file. In your copy they are filled.

When it does not pass

  • AssertionError: 7 not greater than 85 : alpha drawn only 7 times in 5 epochs — the weights are inverted: you wrote counts[...] rather than 1.0 / counts[...]. Now big classes are drawn even more often.
  • AssertionError: 40 not greater than 85 — the sampler was built but not assigned to the variable sampler (it stayed None).
  • ValueError: sampler option is mutually exclusive with shuffle — if you try changing the DataLoader lines. You need not: shuffle=(sampler is None) already handles it.