napoli-pizza-style
A three-class photo classifier that answers one question about a photo scraped from a restaurant listing: is this a pizza, and if so, is it Neapolitan?
| architecture | resnet18, fine-tuned from ImageNet weights |
| classes | not_pizza, napoletana, other_pizza |
| input | 224ร224, resize 256 โ center crop, ImageNet mean/std |
| checkpoint md5 | 01642752b6757d5ea04a367c1192876e |
| file size | 44.8 MB |
| training labels | 1273 human labels |
| split sizes | train 904 / val 173 / test 196 |
Results
Headline is macro-F1 0.8188 on a shared test split re-scored by just model-eval.
| class | precision | recall | F1 | support |
|---|---|---|---|---|
not_pizza |
0.9407 | 0.9407 | 0.9407 | 118 |
napoletana |
0.8611 | 0.7750 | 0.8158 | 40 |
other_pizza |
0.6667 | 0.7368 | 0.7000 | 38 |
Accuracy on the same split was 0.8673, and that number
should be ignored. Roughly 70% of the photos in this corpus are not_pizza,
so a model that answers not_pizza every single time scores 0.70 accuracy and
is worth nothing. Macro-F1 is the selection metric here, always. A smoke run
during development scored accuracy 1.00 and macro-F1 0.33 โ that is the whole
argument in one line.
Intended use
Bulk-labelling photos for a map of pizzerias: every place gets a verdict from a vote over its photos rather than from any single prediction, and predictions below 0.80 confidence are discarded before the vote. It is a filter for a map legend, not a judgement about a restaurant.
Limitations and biases
- Trained on Google Maps listing photos, which are lit, framed and selected the way restaurant photos are. Home-cooked or professionally-plated pizza is out of distribution.
other_pizzais the weak class โ it is the residual category, and it is where the confusion concentrates.not_pizzais easy; the pizza/pizza boundary is not.- "Neapolitan" here means what the labellers called Neapolitan, from a photo alone. Leopard-spotted cornicione and a wet centre are visual cues, not a certification, and no photo can show you a dough hydration or an oven.
- Training data is Europe-heavy, Seoul aside. It has not been tested against US or Latin American pizza styles.
- The corpus was compacted to 512px WebP after this model was trained, so a retrain sees lossy inputs where this run saw the originals.
Usage
import torch
from torchvision import models, transforms
from PIL import Image
ckpt = torch.load('best.pt', map_location='cpu')
net = models.resnet18()
net.fc = torch.nn.Linear(net.fc.in_features, len(ckpt['classes']))
net.load_state_dict(ckpt['state_dict'])
net.eval()
tf = transforms.Compose([
transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
])
with torch.no_grad():
p = net(tf(Image.open('photo.jpg').convert('RGB'))[None]).softmax(-1)[0]
print(dict(zip(ckpt['classes'], p.tolist())))
Training notes
- Split by PLACE, not by image. One restaurant's photos are near-duplicates of each other; a random image-level split leaks a twin into validation and inflates the score by roughly 15 points.
- Weak labels describe the restaurant, not the photo. A chain's storefront picture is a building, and training on weak labels alone teaches the model what a chain's photography looks like.
- Promotion is gated: a new checkpoint replaces the live one only after both are scored on the same split built from today's labels, and it is refused if it scores worse. Two runs' own reported numbers are two different exams.
metrics.json in this repo is the full record for these bytes.
License
MIT.