Nutri-Score Classifier
A fine-tuned text classifier that predicts a packaged food product's Nutri-Score grade (A–E) from ingredient text and category alone — no numeric nutrition-panel data is used as input. Trained on global Open Food Facts data and evaluated separately on an India-specific holdout to measure how well it generalizes beyond its training distribution.
For commercial licensing, partnerships, or to use this model in a product, contact the author (see the "Model Card Contact" section below). The model is public for transparency and evaluation, but redistribution and commercial use are not permitted without permission.
Why text-only?
Nutri-Score is normally a deterministic formula over a nutrient panel (energy, sugar, saturated fat, salt, fiber, protein, fruit/veg %). Feeding those exact numbers to a model would let it trivially reproduce the formula. Instead, this model only ever sees category + ingredient list, and has to infer how sugar/fat/salt-heavy a product is from wording and ingredient order (ingredient lists are legally ordered by descending weight, so order carries real — but never exact — signal). That's a genuine language-understanding task with an honest accuracy ceiling, not a disguised lookup.
Model details
- Architecture:
distilbert-base-uncased(abert-basecandidate was also benchmarked during development; the better checkpoint is the one pushed here). - Task: 5-way sequence classification, labels
A, B, C, D, E→ ids0..4. - Input format:
Category: <category tags>. Ingredients: <ingredient text>(max length 256 tokens). - Loss: class-weighted cross-entropy (inverse-frequency weights from the global training split).
- Labels are self-computed from the official 2023 Nutri-Score formula (all four buckets: general foods, cheese, beverages, fats/oils/nuts/seeds), not copied from Open Food Facts' own (mixed-version, often-sentinel) grade field. See
src/labelling.pyin the repo.
Training data
- Source: Open Food Facts (ODbL).
- Dataset: vishsec/nutri-score-dataset.
- Training set: 925,980 global products (India excluded).
- India eval set: 3,156 India-tagged products, held out and never used in training (zero product-code overlap, asserted at build time).
- The model is trained only on global data; the India set is a pure generalization test.
Training procedure
- Fine-tuned on a single free-tier Colab T4 GPU.
- Train/val split: 5% global holdout (stratified by grade, seed 42) used for early stopping / checkpoint selection; India is separate.
- Early stopping on validation balanced-accuracy; LR sweep over
{2e-5, 3e-5, 5e-5};fp16, weight decay0.01. - Plain water is corrected to grade A (the dataset build otherwise hardwires
is_water=False).
Evaluation
Reported on the global validation split and the India holdout. Grades are ordinal, so off-by-one (prediction within 1 grade of truth) is reported alongside exact-match accuracy.
| Metric | Global | India |
|---|---|---|
| Accuracy | 68.6% | 59.5% |
| Balanced accuracy | 67.8% | 54.3% |
| Off-by-one accuracy | 92.8% | 87.3% |
Headline finding — domain shift: the model generalizes meaningfully worse to India than to its training distribution. It beats the majority-class floor by +41.4 points globally but only +16.6 points on India, and the balanced-accuracy gap (67.8% → 54.3%, −13.5 pts) is larger than the plain-accuracy gap (68.6% → 59.5%, −9 pts) — i.e. it is disproportionately weaker on specific grades in India, not uniformly weaker. Accuracy is also lowest on products sitting near a real grade cutoff (boundary cases), as shown by the boundary-distance breakdown.
Note: the headline numbers above are from the first 1-epoch run; re-running the updated notebook (more epochs + LR sweep + water fix) is expected to improve them. Update this table after your training run.
Limitations
- India domain shift (above): do not assume global accuracy transfers to other markets.
- Boundary cases: products near a grade cutoff are the hardest; off-by-one is a better success measure than exact-match for this task.
- Text-only ceiling: ingredient wording can't perfectly recover hidden nutrient quantities, so the model cannot match the formula's own accuracy.
- Plain-water gap is patched at training time, but other beverage nuances (e.g. sweetened waters) may still be imperfect.
- Trained mainly on English / European-market text; non-Latin or very short ingredient lists are weaker.
How to use
from transformers import AutoModelForSequenceClassification, AutoTokenizer
import torch
MODEL_REPO_ID = "vishsec/nutri-score-classifier-model"
tokenizer = AutoTokenizer.from_pretrained(MODEL_REPO_ID)
model = AutoModelForSequenceClassification.from_pretrained(MODEL_REPO_ID)
model.eval()
def predict(category: str, ingredients: str):
text = f"Category: {category}. Ingredients: {ingredients}"
enc = tokenizer(text, truncation=True, max_length=256, return_tensors="pt")
with torch.no_grad():
probs = torch.softmax(model(**enc).logits[0], dim=-1).tolist()
id2label = model.config.id2label
return {id2label[i]: round(p, 4) for i in range(len(probs))}
print(predict("breads, whole wheat bread", "Whole wheat flour, water, salt, yeast"))
Intended use & restrictions
- Permitted: research, evaluation, education, and non-commercial experimentation.
- Not permitted without permission: commercial use, redistribution of the weights, or serving this model behind a paid product.
- Contact the author for licensing.
Citation / contact
- Author: vishsec
- Contact: open an issue on the model repo or reach out via the linked profile.
- Data: Open Food Facts, ODbL. Code: MIT.