---
title: "Training a Cat Recogniser From Scratch on a MacBook"
date: "2026-04-21"
publish_date: "2026-08-09"
excerpt: "No pretrained weights, no transfer learning — just a from-scratch CNN trained overnight on a MacBook Pro M5. Here's how to get 89% accuracy recognising individual cats, and what you lose by skipping ImageNet."
tags: ["python", "pytorch", "machine-learning", "computer-vision", "cats"]
author: "Martin Harris"
canonical: "https://blog.nakomis.com/2026-04-21-cat-recogniser-from-scratch"
approved: true
---

# Training a Cat Recogniser From Scratch on a MacBook

![A cat on a plinth sketched by mechanical arms onto canvases showing progressively better likenesses](./images/cat-recogniser-from-scratch-header.png)

> **Want to go deeper?** The [companion deep-dive](https://blog.nakomis.com/2026-04-21-cat-training-deep-dive) walks through every significant block of `train_oxford_v2.py` and `finetune_bootboots_v2.py` — the stratified split, Mixup, the resume mechanism, the head swap, all of it.

The sensible way to build an image classifier is to grab a pretrained model, swap the last layer, fine-tune for a few epochs, and call it done. You'll get 94%+ accuracy and be home in time for lunch.

This guide is not about the sensible way.

This is about training a CNN *entirely from scratch* — no pretrained weights, no ImageNet, no borrowed knowledge — and seeing how close you can get to the sensible approach. On a MacBook Pro M5. Overnight.

The answer, it turns out, is: closer than you'd expect. We hit **89.6%** recognising individual cats from ~300 images each. The pretrained model gets 94.6%. That 5-point gap is real, but it's not the chasm most people assume.

All the code is at [github.com/nakomis/cat-from-scratch](https://github.com/nakomis/cat-from-scratch).

---

## What you'll build

By the end of this guide you'll have:

1. A from-scratch ResNet-style CNN (`CatCNNv2`) that achieves **88.5% validation accuracy** classifying 12 Oxford cat breeds
2. A fine-tuned version of that backbone that achieves **89.6%** recognising your own specific cats
3. An understanding of *why* from-scratch training works as well as it does, and where the limits are

You'll also get a live-updating training plot (because staring at log output for 12 hours gets old), and a comparison against EfficientNetV2-S with ImageNet weights so you know exactly what you're trading away.

---

## Prerequisites

- **Python 3.11** — PyTorch's MPS backend on Apple Silicon works well with 3.11; 3.13 has issues
- **A Mac with Apple Silicon** (M1 or later) — if you're on Intel or Linux, swap `mps` for `cuda` or `cpu`
- **Git LFS** — the training data is stored with LFS

```bash
brew install git-lfs
git lfs install
```

- **~25 GB free disk space** — the datasets are large

---

## Get the code

```bash
git clone https://github.com/nakomis/cat-from-scratch
cd cat-from-scratch
git lfs pull   # downloads the BootBoots training data (~145MB)

python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```

---

## Get the data

You'll need two datasets. The Oxford-IIIT Pet dataset is the backbone of the training; Kaggle adds volume.

### Oxford-IIIT Pet Dataset

Download from [robots.ox.ac.uk/~vgg/data/pets](https://www.robots.ox.ac.uk/~vgg/data/pets/):

```bash
cd data/
curl -O https://www.robots.ox.ac.uk/~vgg/data/pets/data/images.tar.gz
curl -O https://www.robots.ox.ac.uk/~vgg/data/pets/data/annotations.tar.gz
tar -xzf images.tar.gz
tar -xzf annotations.tar.gz
cd ..
```

This gives you ~7,400 labelled pet images with PASCAL VOC bounding box annotations. The cat breeds — 12 of them — are the ones with filenames starting with a capital letter: `Abyssinian_1.jpg`, `Bengal_34.jpg`, and so on. The dogs we'll ignore.

### Kaggle cat breeds (optional but recommended)

Install the Kaggle CLI (`pip install kaggle`), grab your API key from kaggle.com/settings, and:

```bash
kaggle datasets download ma7555/cat-breeds-dataset -p data/
cd data && unzip cat-breeds-dataset.zip -d kaggle_cats/ && cd ..
```

This adds ~18,700 more cat images. The training accuracy improves significantly; whether the improvement justifies the extra training time depends on your patience.

---

## The architecture

The model is called `CatCNNv2`, defined in `scripts/model_v2.py`. It's a from-scratch ResNet-style CNN — nothing borrowed, nothing pretrained.

### Residual blocks

The key ingredient is the residual block. Instead of learning a full transformation of the input, each block learns *what to add*:

```python
class ResBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, 3,
                               stride=stride, padding=1, bias=False)
        self.bn1   = nn.BatchNorm2d(out_channels)
        self.conv2 = nn.Conv2d(out_channels, out_channels, 3,
                               padding=1, bias=False)
        self.bn2   = nn.BatchNorm2d(out_channels)
        if stride != 1 or in_channels != out_channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels, out_channels, 1, stride=stride, bias=False),
                nn.BatchNorm2d(out_channels),
            )
        else:
            self.shortcut = nn.Identity()

    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        return F.relu(out + self.shortcut(x))
```

The `shortcut` connection bypasses the two conv layers. The gradient flows straight through during backprop, which is why you can train much deeper networks without them dying. Without this, training 12+ layers from random weights on a small dataset would be an exercise in futility.

### The full model

Four stages, each doubling the channel count whilst halving the spatial resolution:

```mermaid
flowchart LR
    A["Input\n128×128×3"] --> B["Stem\n128×128×64"]
    B --> C["Stage 1\n64×64×64"]
    C --> D["Stage 2\n32×32×128"]
    D --> E["Stage 3\n16×16×256"]
    E --> F["Stage 4\n8×8×512"]
    F --> G["GAP\n512"]
    G --> H["Dropout\n+ Linear"]
    H --> I["Class\nlogits"]
```

The Global Average Pool at the end is worth noting. The naive approach — `Flatten()` — would produce a 8×8×512 = 32,768-dimensional vector before the final linear layer. With only a few thousand training images, that's a recipe for catastrophic overfitting. GAP collapses each feature map to a single number (its average), producing a 512-dimensional vector regardless of input resolution. The model learns to summarise what it sees; not to memorise where it saw it.

17.8M parameters total. Trains overnight on M5.

---

## Training on Oxford cat breeds

With the data in place, training is one command:

```bash
python scripts/train_oxford_v2.py
```

Or with the Kaggle data for better results:

```bash
python scripts/train_oxford_v2.py --extra-data data/kaggle_cats/images/
```

The script handles everything: dataset loading, stratified train/val split (Oxford images only in val — you want to own your labels), data augmentation, training loop, checkpoint saving. It saves the best model by validation accuracy to `models/best_oxford_v2.pt`.

Key hyperparameters at the top of the file — epochs (default 40), batch size (64), learning rate (1e-3 with cosine decay to 0). For the full run that produced our best results, we trained for 140 epochs using a resume mechanism:

```bash
# Initial 50-epoch run
python scripts/train_oxford_v2.py \
  --extra-data data/kaggle_cats/images/ \
  --epochs 50

# Resume for 90 more epochs
python scripts/train_oxford_v2.py \
  --extra-data data/kaggle_cats/images/ \
  --resume models/best_oxford_v2.pt \
  --resume-epoch 50 \
  --epochs 90
```

**One thing to budget for**: 140 epochs with ~21,000 images takes about 10–11 hours on M5. Plan accordingly.

---

## Monitoring training

Staring at log lines for hours is miserable. Open a second terminal and run:

```bash
python scripts/live_plot.py --interval 30
```

It auto-picks the latest log file, parses the epoch lines, and refreshes every 30 seconds with a live accuracy chart showing train (dashed) and val (solid), the best val marked with a red star, and a random-chance baseline at 8.3%.

![Oxford+Kaggle training curve — 140 epochs, best val 88.5%](images/cat-from-scratch-training-curve.png)

---

## Why does train accuracy look lower than val?

You'll notice something odd in the logs: validation accuracy is consistently *higher* than training accuracy. This looks like the model is better on data it hasn't seen, which makes no sense.

It's caused by Mixup.

During training, we blend pairs of images:

![Mixup example: 70% Maine Coon + 30% British Shorthair](images/cat-from-scratch-mixup.png)

Instead of showing the model a pure Maine Coon with label "Maine Coon", we show it 70% Maine Coon blended with 30% British Shorthair, with a soft label of [0.7, 0.3, 0, ...]. The model has to learn to distribute its confidence rather than commit. This is much harder than a clean classification task — the loss stays high because soft labels are genuinely difficult to fit.

Validation never uses Mixup. Clean images, hard labels. The model finds these easier than the blended training examples.

The other factor is data augmentation: training uses `RandomResizedCrop` (random crops at random scales), `RandomHorizontalFlip`, `RandomRotation`, and `ColorJitter`. Val uses a plain `Resize`. A randomly cropped, flipped, jittered image is harder to classify correctly than a clean centre crop.

Neither of these means overfitting. They mean your training is harder than your evaluation, which is exactly the point.

---

## Fine-tuning on your own cats

The overall pipeline looks like this:

```mermaid
flowchart TD
    A["Oxford-IIIT\n~2,400 cat images"] --> C["Train CatCNNv2\n140 epochs"]
    B["Kaggle breeds\n~18,700 images"] --> C
    C --> D["best_oxford_v2.pt\n88.5% on 12 breeds"]
    D --> E["Freeze backbone\nTrain new head\n10 epochs"]
    E --> F["Unfreeze all\nFine-tune at 5e-5\n30 epochs"]
    G["Your cat photos\n~300 per cat"] --> E
    F --> H["best_bootboots_v2.pt\n89.6% on 6 cats"]
```

This is where it gets interesting. The Oxford backbone has learned useful features — fur texture, ear shapes, eye structure — but it doesn't know what *your* cats look like. Fine-tuning adapts it.

### Prepare your dataset

You'll need images organised in ImageFolder format:

```
data_multiclass/
  training/
    CatName1/   img001.jpg  img002.jpg  ...
    CatName2/   ...
    NoCat/      ...   # images with no cat present
  validation/
    CatName1/   ...
    ...
```

Aim for at least 150–200 images per cat for decent results. More is always better, but the fine-tuning is surprisingly effective with limited data — our six-cat model was trained on 156–317 images per cat.

The repo includes a BootBoots dataset (six specific cats from a home cat monitoring system) via Git LFS:

```bash
cd data && tar -xzf bootboots-training-data.tar.gz && cd ..
```

This gives you `data/data_multiclass/` ready to use.

Here are five of the six cats the model learns to distinguish:

![Boots — solid black](images/boots.png) ![Chi — dark brown tabby](images/chi.png) ![Kappa — fluffy near-black](images/kappa.png) ![Mu — fluffy ginger](images/mu.png) ![Tau — tuxedo](images/tau.png)

### Run the fine-tuning

```bash
python scripts/finetune_bootboots_v2.py \
  --backbone models/best_oxford_v2.pt
```

Or point it at your own dataset:

```bash
python scripts/finetune_bootboots_v2.py \
  --backbone models/best_oxford_v2.pt
```

(Edit `DATA_DIR` at the top of the script to point at your `data_multiclass/` directory.)

The script runs two phases:

**Phase 1 — frozen backbone (10 epochs):** Only the new classification head is trained. This warms up the randomly-initialised head without destroying the carefully-learnt backbone features.

**Phase 2 — full fine-tune (30 epochs):** Everything unfreezes. The backbone adapts to your cats at a low learning rate (5e-5) with cosine decay. This is where the real improvement happens.

Expect Phase 2's first epoch to jump 10–15 points immediately as the backbone features start tuning to your specific cats.

![BootBoots fine-tuning curve — best val 89.6%](images/cat-from-scratch-bootboots-curve.png)

Best model saved to `models/best_bootboots_v2.pt`.

---

## The comparison: how much does ImageNet actually help?

To answer the original question, we ran the same fine-tuning against EfficientNetV2-S with ImageNet pretrained weights:

```bash
python scripts/finetune_bootboots_imagenet.py
```

Same dataset. Same two-phase approach. Same number of epochs.

| Model | Backbone | Val accuracy |
|-------|----------|-------------|
| CatCNNv2 | From scratch on Oxford+Kaggle | **89.6%** |
| EfficientNetV2-S | ImageNet (1.2M images, 1,000 classes) | **94.6%** |

![EfficientNetV2-S fine-tuning curve — best val 94.6%](images/cat-from-scratch-imagenet-curve.png)

The gap is 5 points. ImageNet starts stronger out of the gate (75.7% frozen vs our 49.4%), but the from-scratch model catches up fast once unfrozen, and the final difference is smaller than you'd guess given the enormous difference in pretraining data.

A few things to note about this gap:

- **It's real.** 5 points means roughly 1 in 20 correct identifications becomes an error. For a production cat door, that matters.
- **It's not catastrophic.** The from-scratch model still gets 89.6% from ~300 images per cat and zero pretrained knowledge. That's genuinely surprising.
- **The from-scratch model is more instructive.** You can see exactly what it's learning and why. The ImageNet model comes with 1.2 million images' worth of assumptions baked in.

---

## Results summary

| Stage | Task | Val accuracy |
|-------|------|-------------|
| CatCNNv2 (Oxford only, 40 epochs) | 12 breeds | 53.8% |
| CatCNNv2 (Oxford+Kaggle, 140 epochs) | 12 breeds | **88.5%** |
| CatCNNv2 fine-tuned | 6 specific cats | **89.6%** |
| EfficientNetV2-S fine-tuned | 6 specific cats | 94.6% |

The jump from Oxford-only to Oxford+Kaggle (53.8% → 88.5%) is the single biggest improvement, which tells you that for from-scratch training, data volume matters more than architectural cleverness. More data, more epochs, better results.

---

## What to do next

If 89.6% is good enough for your purposes, you're done. The `models/best_bootboots_v2.pt` file is a standard PyTorch state dict — load it with `model.load_state_dict(torch.load(...))` and call `model(image_tensor)` to get class logits.

If you want to push further:

- **Add more training images.** The biggest gains come from data, not architecture. 500+ images per cat should close most of the remaining gap.
- **Use the ImageNet backbone.** Run `finetune_bootboots_imagenet.py`. Five points is five points.
- **Longer fine-tuning.** The BootBoots model was still climbing at epoch 30. More full-fine-tune epochs would help.
- **Test-time augmentation.** Average predictions across several crops/flips of each test image for a free 1–2% boost.

The full training pipeline, including the live monitoring and all the scripts described here, is at [github.com/nakomis/cat-from-scratch](https://github.com/nakomis/cat-from-scratch).

If you'd like to understand *how* the scripts actually work — what happens inside `run_epoch`, why the scheduler needs fast-forwarding when you resume, what `collate_skip_none` is guarding against — the [deep-dive companion post](https://blog.nakomis.com/2026-04-21-cat-training-deep-dive) covers all of it.

---

*Martin Harris connects real hardware to abstract cloud infrastructure. If you've ever trained a neural network overnight on your laptop and woken up to find it had learned to tell your cats apart, you're in exactly the right place.*


{{donate}}
