Spaces:
Sleeping
Sleeping
| """ | |
| Nutri-Score Calculation Algorithm | |
| Based on the official French Nutri-Score methodology (March 2025) | |
| """ | |
| import pandas as pd | |
| import numpy as np | |
| class NutriScoreCalculator: | |
| """ | |
| Official Nutri-Score calculator implementing the French algorithm. | |
| References: | |
| - ANSES scientific and technical support report | |
| - Tables 1, 2, 3 from Project documentation | |
| """ | |
| # Table 1: Negative component thresholds (nutrients to limit) | |
| ENERGY_THRESHOLDS = [335, 670, 1005, 1340, 1675, 2010, 2345, 2680, 3015, 3350] | |
| SATURATED_FAT_THRESHOLDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] | |
| SUGARS_THRESHOLDS = [3.4, 6.8, 10, 14, 17, 20, 24, 27, 31, 34, 37, 41, 44, 48, 51] | |
| SALT_THRESHOLDS = [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] | |
| # Table 2: Positive component thresholds (nutrients to favor) | |
| PROTEINS_THRESHOLDS = [2.4, 4.8, 7.2, 9.6, 12, 14, 17] | |
| FIBER_THRESHOLDS = [3.0, 4.1, 5.2, 6.3, 7.4] | |
| FRUIT_VEG_THRESHOLDS = [40, 60, 80] # Percentage | |
| # Table 3: Score to class mapping | |
| CLASS_THRESHOLDS = { | |
| 'A': (-17, 0), | |
| 'B': (1, 2), | |
| 'C': (3, 10), | |
| 'D': (11, 18), | |
| 'E': (19, 55) | |
| } | |
| def _get_points_from_thresholds(value, thresholds): | |
| """Get points based on value and threshold list.""" | |
| if pd.isna(value): | |
| return 0 | |
| points = 0 | |
| for threshold in thresholds: | |
| if value > threshold: | |
| points += 1 | |
| else: | |
| break | |
| return points | |
| def calculate_negative_points(energy_kj, saturated_fat_g, sugars_g, salt_g): | |
| """ | |
| Calculate negative component N (nutrients to limit). | |
| Args: | |
| energy_kj: Energy in kJ per 100g | |
| saturated_fat_g: Saturated fatty acids in g per 100g | |
| sugars_g: Sugars in g per 100g | |
| salt_g: Salt in g per 100g | |
| Returns: | |
| int: Negative points (0-55) | |
| """ | |
| energy_pts = NutriScoreCalculator._get_points_from_thresholds( | |
| energy_kj, NutriScoreCalculator.ENERGY_THRESHOLDS | |
| ) | |
| sat_fat_pts = NutriScoreCalculator._get_points_from_thresholds( | |
| saturated_fat_g, NutriScoreCalculator.SATURATED_FAT_THRESHOLDS | |
| ) | |
| sugars_pts = NutriScoreCalculator._get_points_from_thresholds( | |
| sugars_g, NutriScoreCalculator.SUGARS_THRESHOLDS | |
| ) | |
| salt_pts = NutriScoreCalculator._get_points_from_thresholds( | |
| salt_g, NutriScoreCalculator.SALT_THRESHOLDS | |
| ) | |
| return energy_pts + sat_fat_pts + sugars_pts + salt_pts | |
| def calculate_positive_points(proteins_g, fiber_g, fruit_veg_pct, negative_points): | |
| """ | |
| Calculate positive component P (nutrients to favor). | |
| Special rule: If N >= 11 and fruit/veg <= 80%, proteins are NOT counted. | |
| Args: | |
| proteins_g: Proteins in g per 100g | |
| fiber_g: Fiber in g per 100g | |
| fruit_veg_pct: Fruits/vegetables/nuts in % per 100g | |
| negative_points: Already calculated negative points N | |
| Returns: | |
| int: Positive points (0-17) | |
| """ | |
| # Fiber points (0-5) | |
| fiber_pts = NutriScoreCalculator._get_points_from_thresholds( | |
| fiber_g, NutriScoreCalculator.FIBER_THRESHOLDS | |
| ) | |
| # Fruit/veg points (0, 1, 2, or 5) | |
| fruit_veg_pct = fruit_veg_pct if not pd.isna(fruit_veg_pct) else 0 | |
| if fruit_veg_pct > 80: | |
| fruit_veg_pts = 5 | |
| elif fruit_veg_pct > 60: | |
| fruit_veg_pts = 2 | |
| elif fruit_veg_pct > 40: | |
| fruit_veg_pts = 1 | |
| else: | |
| fruit_veg_pts = 0 | |
| # Protein points (0-7) - with special rule | |
| # If N >= 11 and fruit/veg <= 80%, do NOT count proteins | |
| if negative_points >= 11 and fruit_veg_pct <= 80: | |
| protein_pts = 0 | |
| else: | |
| protein_pts = NutriScoreCalculator._get_points_from_thresholds( | |
| proteins_g, NutriScoreCalculator.PROTEINS_THRESHOLDS | |
| ) | |
| return protein_pts + fiber_pts + fruit_veg_pts | |
| def calculate_score(energy_kj, saturated_fat_g, sugars_g, salt_g, | |
| proteins_g, fiber_g, fruit_veg_pct): | |
| """ | |
| Calculate Nutri-Score numerical score. | |
| Formula: Score = N - P | |
| Returns: | |
| tuple: (score, negative_points, positive_points) | |
| """ | |
| N = NutriScoreCalculator.calculate_negative_points( | |
| energy_kj, saturated_fat_g, sugars_g, salt_g | |
| ) | |
| P = NutriScoreCalculator.calculate_positive_points( | |
| proteins_g, fiber_g, fruit_veg_pct, N | |
| ) | |
| score = N - P | |
| return score, N, P | |
| def score_to_class(score): | |
| """ | |
| Convert numerical score to letter class (A, B, C, D, E). | |
| Table 3: | |
| - A (dark green): -17 to 0 | |
| - B (light green): 1 to 2 | |
| - C (yellow): 3 to 10 | |
| - D (light orange): 11 to 18 | |
| - E (red/dark orange): 19 to 55 | |
| """ | |
| for class_label, (min_score, max_score) in NutriScoreCalculator.CLASS_THRESHOLDS.items(): | |
| if min_score <= score <= max_score: | |
| return class_label | |
| # Edge cases | |
| if score < -17: | |
| return 'A' | |
| if score > 55: | |
| return 'E' | |
| return 'Unknown' | |
| def calculate_nutriscore(energy_kj, saturated_fat_g, sugars_g, salt_g, | |
| proteins_g, fiber_g, fruit_veg_pct): | |
| """ | |
| Calculate complete Nutri-Score (score + class). | |
| Returns: | |
| dict: { | |
| 'score': int, | |
| 'class': str, | |
| 'negative_points': int, | |
| 'positive_points': int | |
| } | |
| """ | |
| score, N, P = NutriScoreCalculator.calculate_score( | |
| energy_kj, saturated_fat_g, sugars_g, salt_g, | |
| proteins_g, fiber_g, fruit_veg_pct | |
| ) | |
| class_label = NutriScoreCalculator.score_to_class(score) | |
| return { | |
| 'score': score, | |
| 'class': class_label, | |
| 'negative_points': N, | |
| 'positive_points': P | |
| } | |
| def calculate_for_dataframe(df): | |
| """ | |
| Calculate Nutri-Score for an entire DataFrame. | |
| Expected columns: | |
| - energy_kj | |
| - saturated_fat_g | |
| - sugars_g | |
| - salt_g | |
| - proteins_g | |
| - fiber_g | |
| - fruit_veg_pct | |
| Returns: | |
| DataFrame with added columns: computed_score, computed_class, N, P | |
| """ | |
| results = df.apply( | |
| lambda row: NutriScoreCalculator.calculate_nutriscore( | |
| row.get('energy_kj', 0), | |
| row.get('saturated_fat_g', 0), | |
| row.get('sugars_g', 0), | |
| row.get('salt_g', 0), | |
| row.get('proteins_g', 0), | |
| row.get('fiber_g', 0), | |
| row.get('fruit_veg_pct', 0) | |
| ), | |
| axis=1 | |
| ) | |
| df_result = df.copy() | |
| df_result['computed_score'] = results.apply(lambda x: x['score']) | |
| df_result['computed_class'] = results.apply(lambda x: x['class']) | |
| df_result['N'] = results.apply(lambda x: x['negative_points']) | |
| df_result['P'] = results.apply(lambda x: x['positive_points']) | |
| return df_result | |
| def test_nutriscore(): | |
| """Test on examples from the PDF.""" | |
| print("=" * 60) | |
| print("TESTING NUTRI-SCORE CALCULATOR") | |
| print("=" * 60) | |
| # Example 1: Gerble-Sesame Cookie (from PDF Figure 2) | |
| # Expected: N=11, P=2, Score=9, Class=C | |
| print("\n1. Gerble-Sesame Cookie 230g") | |
| print("-" * 60) | |
| result = NutriScoreCalculator.calculate_nutriscore( | |
| energy_kj=1961, | |
| saturated_fat_g=2, | |
| sugars_g=17, | |
| salt_g=0.38, | |
| proteins_g=0, # Not counted because N>=11 and fruit/veg<=80% | |
| fiber_g=4.6, | |
| fruit_veg_pct=0 | |
| ) | |
| print(f"Negative points (N): {result['negative_points']} (expected: 11)") | |
| print(f"Positive points (P): {result['positive_points']} (expected: 2)") | |
| print(f"Score: {result['score']} (expected: 9)") | |
| print(f"Class: {result['class']} (expected: C)") | |
| # Example 2: Pain de mie grandes tranches Seigle (from PDF Figure 3) | |
| # Expected: N=8, P=8, Score=0, Class=A | |
| print("\n2. Pain de mie grandes tranches Seigle & Graines") | |
| print("-" * 60) | |
| result = NutriScoreCalculator.calculate_nutriscore( | |
| energy_kj=1246, | |
| saturated_fat_g=1, | |
| sugars_g=4.2, | |
| salt_g=0.96, | |
| proteins_g=12, | |
| fiber_g=7.2, | |
| fruit_veg_pct=0.5 | |
| ) | |
| print(f"Negative points (N): {result['negative_points']} (expected: 8)") | |
| print(f"Positive points (P): {result['positive_points']} (expected: 8)") | |
| print(f"Score: {result['score']} (expected: 0)") | |
| print(f"Class: {result['class']} (expected: A)") | |
| print("\n" + "=" * 60) | |
| print("✓ Test complete!") | |
| print("=" * 60) | |
| if __name__ == "__main__": | |
| test_nutriscore() | |