NutriWeb / nutriweb /scoring /nutriscore.py
Krish264's picture
Deploy: Pin Streamlit to 1.61.1; add deployment parity tests
89d9642
Raw
History Blame Contribute Delete
11.8 kB
"""Nutri-Score 2023 (v2) — a faithful port of Open Food Facts' reference implementation.
Why this exists
---------------
58% of US products in Open Food Facts carry `nutriscore_grade = 'unknown'`. Nutri-Score
is a European scheme and OFF often cannot assign it to US items, usually because the
category needed to pick the algorithm variant is missing. Ranking "healthier alternatives"
on OFF's grade alone would therefore silently discard most of the US catalog.
So NutriWeb computes the grade itself wherever the input nutrients exist. We keep OFF's
own grade alongside ours and never merge the two — the UI always states which is which.
Provenance
----------
Ported from `lib/ProductOpener/Nutriscore.pm` (`compute_nutriscore_score_2023`) and the
category predicates in `lib/ProductOpener/Food.pm`, from openfoodfacts-server @ main.
Threshold tables are reproduced verbatim; the irregular steps (e.g. beverage energy
jumping 30/90/150/210 then by 30) are intentional and match upstream.
Reference: Nutri-Score 2023 main algorithm update, Santé publique France.
"""
from __future__ import annotations
from dataclasses import dataclass, field
# --------------------------------------------------------------------------
# Threshold tables — verbatim from Nutriscore.pm %points_thresholds_2023
# --------------------------------------------------------------------------
# A value scores one point per threshold it exceeds (strictly greater than),
# except saturated_fat_ratio which uses >=.
THRESHOLDS: dict[str, list[float]] = {
# negative
"energy": [335, 670, 1005, 1340, 1675, 2010, 2345, 2680, 3015, 3350], # kJ/100g
"energy_beverages": [30, 90, 150, 210, 240, 270, 300, 330, 360, 390],
"sugars": [3.4, 6.8, 10, 14, 17, 20, 24, 27, 31, 34, 37, 41, 44, 48, 51], # g/100g
"sugars_beverages": [0.5, 2, 3.5, 5, 6, 7, 8, 9, 10, 11],
"saturated_fat": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], # g/100g
"salt": [0.2, 0.4, 0.6, 0.8, 1, 1.2, 1.4, 1.6, 1.8, 2,
2.2, 2.4, 2.6, 2.8, 3, 3.2, 3.4, 3.6, 3.8, 4], # g/100g
# fats/oils/nuts/seeds variant
"energy_from_saturated_fat": [120, 240, 360, 480, 600, 720, 840, 960, 1080, 1200],
"saturated_fat_ratio": [10, 16, 22, 28, 34, 40, 46, 52, 58, 64], # %
# positive
# 80 repeats so that >80% earns the full 5 points; this is upstream's encoding.
"fruits_vegetables_legumes": [40, 60, 80, 80, 80], # %
"fruits_vegetables_legumes_beverages": [40, 40, 60, 60, 80, 80], # yields 0/2/4/6
"fiber": [3.0, 4.1, 5.2, 6.3, 7.4], # g/100g, AOAC
"proteins": [2.4, 4.8, 7.2, 9.6, 12, 14, 17], # g/100g
"proteins_beverages": [1.2, 1.5, 1.8, 2.1, 2.4, 2.7, 3.0],
}
# Energy density of fat, used to derive energy-from-saturates for the fats variant.
KJ_PER_G_FAT = 37.0
# --------------------------------------------------------------------------
# Category predicates — from Food.pm
# --------------------------------------------------------------------------
# OFF's `categories_tags` is the *expanded* tag list: it already contains every
# ancestor category, so a plain membership test is equivalent to upstream's
# has_tag() walk up the taxonomy.
BEVERAGE_ROOTS = {"en:beverages", "en:beverage-preparations"}
NOT_BEVERAGES_2023 = {"en:meal-replacement", "en:soups"}
ALSO_BEVERAGES_2023 = {
"en:milks", "en:plant-based-milk-alternatives", "en:dairy-drinks",
"en:plant-based-beverages", "en:tea-based-beverages", "en:iced-teas",
"en:herbal-tea-beverages", "en:coffee-beverages", "en:coffee-drinks",
"en:coffees", "en:herbal-teas", "en:teas",
}
FAT_OIL_NUTS_SEEDS_ROOTS = {"en:fats", "en:creams", "en:seeds"}
# Products OFF exempts from Nutri-Score entirely. We honour the same list so we
# don't invent grades for salt, spices, or alcohol.
EXEMPTED = {
"en:alcoholic-beverages", "en:baby-foods", "en:baby-milks", "en:chewing-gum",
"en:food-additives", "en:dietary-supplements", "en:meal-replacements",
"en:salts", "en:spices", "en:sugar-substitutes", "en:vinegars",
"en:non-food-products",
}
NOT_EXEMPTED = {
"en:tea-based-beverages", "en:iced-teas", "en:herbal-tea-beverages",
"en:coffee-beverages", "en:coffee-drinks",
}
@dataclass
class CategoryFlags:
"""Which algorithm variant applies, derived from `categories_tags`."""
is_beverage: bool = False
is_water: bool = False
is_cheese: bool = False
is_fat_oil_nuts_seeds: bool = False
is_red_meat_product: bool = False
def category_flags(
categories_tags: list[str] | None,
fat_oil_nuts_seeds_ids: frozenset[str] = frozenset(),
red_meat_ids: frozenset[str] = frozenset(),
) -> CategoryFlags:
"""Derive the Nutri-Score variant flags from a product's category tags.
`fat_oil_nuts_seeds_ids` and `red_meat_ids` come from
`pipeline/taxonomy.py`, which resolves the WCO Harmonized-System properties
that upstream reads via taxonomy inheritance. Passing empty sets falls back
to the explicit root categories only.
"""
tags = set(categories_tags or ())
is_beverage = bool(tags & BEVERAGE_ROOTS) and not (tags & NOT_BEVERAGES_2023)
if tags & ALSO_BEVERAGES_2023:
is_beverage = True
is_water = "en:spring-waters" in tags and not (
tags & {"en:flavored-waters", "en:flavoured-waters"}
)
is_cheese = "en:cheeses" in tags and "fr:fromages-blancs" not in tags
if "en:chestnuts" in tags:
is_fat = False
else:
is_fat = bool(tags & FAT_OIL_NUTS_SEEDS_ROOTS) or bool(tags & fat_oil_nuts_seeds_ids)
return CategoryFlags(
is_beverage=is_beverage,
is_water=is_water,
is_cheese=is_cheese,
is_fat_oil_nuts_seeds=is_fat,
is_red_meat_product=bool(tags & red_meat_ids),
)
def is_exempt(categories_tags: list[str] | None) -> bool:
"""True if OFF excludes this product from Nutri-Score (salt, spices, alcohol...)."""
tags = set(categories_tags or ())
return bool(tags & EXEMPTED) and not (tags & NOT_EXEMPTED)
# --------------------------------------------------------------------------
# Point assignment
# --------------------------------------------------------------------------
def _points(value: float | None, table_key: str) -> int:
"""One point per threshold exceeded. Missing values score 0, per upstream."""
if value is None:
return 0
table = THRESHOLDS[table_key]
# The saturated fat ratio table is the one that uses >= rather than >.
if table_key == "saturated_fat_ratio":
return sum(1 for t in table if value >= t)
return sum(1 for t in table if value > t)
def _table(nutrient: str, is_beverage: bool) -> str:
"""Beverages use their own table where one exists."""
key = f"{nutrient}_beverages"
return key if is_beverage and key in THRESHOLDS else nutrient
@dataclass
class NutriScoreResult:
score: int
grade: str
negative_points: int
positive_points: int
counted_proteins: bool
detail: dict[str, int] = field(default_factory=dict)
def compute(
*,
energy_kj: float | None,
sugars: float | None,
saturated_fat: float | None,
salt: float | None,
fiber: float | None,
proteins: float | None,
fruits_vegetables_legumes: float | None,
fat: float | None = None,
has_non_nutritive_sweeteners: bool = False,
flags: CategoryFlags | None = None,
) -> NutriScoreResult | None:
"""Compute the 2023 Nutri-Score. All nutrients are per 100 g / 100 ml.
Returns None when there is too little data to score honestly — see
`has_sufficient_data`. Absent *optional* nutrients (fiber, fruit content)
score 0 points, matching upstream, which is a conservative penalty.
"""
flags = flags or CategoryFlags()
if not has_sufficient_data(
energy_kj=energy_kj, sugars=sugars, saturated_fat=saturated_fat,
salt=salt, proteins=proteins,
):
return None
# In the fats/oils/nuts/seeds variant, energy is replaced by energy-from-saturates
# and saturated fat by the saturates-to-fat ratio.
if flags.is_fat_oil_nuts_seeds:
energy_value = (saturated_fat or 0.0) * KJ_PER_G_FAT
energy_key = "energy_from_saturated_fat"
satfat_value = (
(saturated_fat / fat * 100.0) if fat not in (None, 0) and saturated_fat is not None
else None
)
satfat_key = "saturated_fat_ratio"
else:
energy_value, energy_key = energy_kj, "energy"
satfat_value, satfat_key = saturated_fat, "saturated_fat"
bev = flags.is_beverage
detail = {
"energy": _points(energy_value, _table(energy_key, bev)),
"sugars": _points(sugars, _table("sugars", bev)),
"saturated_fat": _points(satfat_value, satfat_key),
"salt": _points(salt, "salt"),
"fiber": _points(fiber, "fiber"),
"proteins": _points(proteins, _table("proteins", bev)),
"fruits_vegetables_legumes": _points(
fruits_vegetables_legumes, _table("fruits_vegetables_legumes", bev)
),
}
# Red meat products cap protein at 2 points, so a fatty sausage cannot
# protein its way to a good grade.
if flags.is_red_meat_product:
detail["proteins"] = min(detail["proteins"], 2)
negative = detail["energy"] + detail["sugars"] + detail["saturated_fat"] + detail["salt"]
# Beverages carrying non-nutritive sweeteners take 4 extra negative points.
if bev:
detail["non_nutritive_sweeteners"] = 4 if has_non_nutritive_sweeteners else 0
negative += detail["non_nutritive_sweeteners"]
# Protein only counts when the product isn't already heavily penalised —
# otherwise processed meats and cheeses would score as health foods.
if bev or flags.is_cheese:
count_proteins = True
elif flags.is_fat_oil_nuts_seeds:
count_proteins = negative < 7
else:
count_proteins = negative < 11
positive = detail["fiber"] + detail["fruits_vegetables_legumes"]
if count_proteins:
positive += detail["proteins"]
score = negative - positive
return NutriScoreResult(
score=score,
grade=grade_for(score, flags),
negative_points=negative,
positive_points=positive,
counted_proteins=count_proteins,
detail=detail,
)
def grade_for(score: int, flags: CategoryFlags) -> str:
"""Map a numeric score to a letter. Cutoffs differ by variant."""
if flags.is_beverage:
if flags.is_water:
return "a" # unflavoured spring water is graded A by definition
if score <= 2:
return "b"
if score <= 6:
return "c"
if score <= 9:
return "d"
return "e"
if flags.is_fat_oil_nuts_seeds:
if score <= -6:
return "a"
if score <= 2:
return "b"
if score <= 10:
return "c"
if score <= 18:
return "d"
return "e"
if score <= 0:
return "a"
if score <= 2:
return "b"
if score <= 10:
return "c"
if score <= 18:
return "d"
return "e"
def has_sufficient_data(
*,
energy_kj: float | None,
sugars: float | None,
saturated_fat: float | None,
salt: float | None,
proteins: float | None,
) -> bool:
"""Whether we have enough to compute a grade we're willing to show.
Upstream treats every missing nutrient as 0 points, which would hand a
product with no nutrition data whatsoever a clean 'A'. We require the four
negative-side drivers plus protein to be present, so a sparse record is
reported as unscored rather than as healthy.
"""
return all(v is not None for v in (energy_kj, sugars, saturated_fat, salt, proteins))