← Retour à l'épreuve

Slovakia OAI 2026 · Correction IOAI Training

Predicting the Energy Value of Meals and Designing a Healthier Alternative with AI

Cette page donne toute la méthode. Lisez-la une fois que vous avez cherché.

Métrique
MAE / RMSE (kcal), jury-graded project
Évaluée sur
held-out validation (own generated meals, no official test)
Notre score
MAE 14.4 kcal (23 on unseen foods)
Niveau attendu
no threshold: 0-100 points by the jury

Nous avons résolu l'épreuve nous-mêmes : téléchargé les données, mené les expériences et mesuré le score ci-dessous. L'énoncé et les données appartiennent à leurs organisateurs. Cette correction est rédigée en anglais.

The problem in brief

Using the USDA FoodData Central nutrient tables, you must (1) clean the food data and add ratio features (protein, carbohydrate and fat per kcal, food category), (2) compute the exact energy of a meal given as "150 g apples, 30 g lettuce, ...", build your own dataset of at least 50 meals of 3 to 10 ingredients, and train at least three machine-learning models that predict a meal's energy, evaluated with MAE and RMSE and a true-versus-predicted plot, and (3) for three meals, design an alternative with at least 25 % fewer calories, fewer carbohydrates and a higher share of protein, shown in a comparison table. It is the home round of the Slovak AI Olympiad: an open project graded on quality, originality and explanations.

How you are scored

Out of 100 points, by the jury, on the quality of the result, the ingenuity of the solution and the clarity of the comments. The quantitative parts use

MAE=1n∑i∣yi−y^i∣,RMSE=1n∑i(yi−y^i)2,\text{MAE} = \frac{1}{n}\sum_i |y_i - \hat y_i|, \qquad \text{RMSE} = \sqrt{\frac{1}{n}\sum_i (y_i - \hat y_i)^2},

and the alternative meal must satisfy three hard constraints: kcal ≤0.75×\le 0.75\times original, carbohydrates << original, protein share of energy >> original. No reference score exists; what convinces is a sound pipeline and an honest analysis.

First look at the data

  • The archive mixes several kinds of records; only the 411 foundation_food rows are finished foods (the others are lab sub-samples). Several descriptions appear twice (separate analyses): we average them, leaving 303 distinct foods.
  • Energy is stored in three different nutrients: measured "Energy" (only 135 foods), "Energy (Atwater General Factors)" (322) and "Energy (Atwater Specific Factors)" (312). We take the first available, in that order.
  • Cleaning has real consequences: butter and sunflower oil have no energy row in Foundation Foods, so a strict "drop incomplete foods" removes them. Pure fats often lack a carbohydrate row as well; for foods with at least 80 g of fat per 100 g we set the missing carbohydrates to 0, which is the truth, not a guess. Coconut oil is the only oil left with complete data.

The most important observation is not in the data but in the task: the "true" energy of a meal is a formula,

E(meal)=∑jgj100 ej,E(\text{meal}) = \sum_{j} \frac{g_j}{100}\, e_j ,

where gjg_j is the mass of ingredient jj and eje_j its kcal per 100 g. It is linear in the grams. And by the Atwater system, ej≈4 pj+9 fj+4 cje_j \approx 4\,p_j + 9\,f_j + 4\,c_j (protein, fat, carbohydrate, in g per 100 g). Any model we train should be judged against that knowledge.

A first baseline

We generate 2 000 random meals (3 to 10 distinct foods, 20 to 300 g each, oils 5 to 40 g, sugar 5 to 50 g, salt 1 to 8 g), compute their exact energy (on average 2 000 kcal, mean absolute deviation from the mean 943 kcal), and split them 80/20. The statement asks for at least 50 meals; with a formula generating the labels for free, more meals is strictly better.

A naive representation describes a meal by its total mass, its number of ingredients and the mass from each food category ("120 g of dairy, 300 g of vegetables..."). Best result: linear regression, MAE 291 kcal. Categories are too coarse: 100 g of "dairy" can be skim milk (34 kcal) or cream cheese (343 kcal).

The key idea

Choose the representation that makes the target simple. The same meal can be written three ways, and the right one turns the problem into something a linear model solves exactly or almost.

  1. Bag of foods: a 303-dimensional vector with the grams of each food. Since E=∑jgjej/100E = \sum_j g_j e_j / 100, a linear regression on this vector has the exact answer as its solution: its coefficients are the ej/100e_j/100. It reaches MAE 0.0 on the random test split. But it has learnt a lookup table, one coefficient per food: on meals made of foods it never saw in training, its coefficients are 0 and it collapses (MAE 1 603).
  2. Macronutrient totals: 4 numbers, the grams of protein, fat, carbohydrate and fibre in the meal. Energy is then a near-linear function shared by all foods. Linear regression learns
E^=4.16 P+8.87 F+3.99 C−0.22 Fib+b,\hat E = 4.16\,P + 8.87\,F + 3.99\,C - 0.22\,\text{Fib} + b,

which rediscovers the Atwater factors 4, 9, 4 from the data alone. MAE 14 kcal on random test meals, and 23 kcal on meals of never-seen foods: it generalises because it learnt a law, not a table. The residual error comes from foods whose energy was measured or computed with food-specific factors, and from components we did not include (alcohol, polyols). 3. Why the random forest loses everywhere. Trees predict piecewise constants learnt from the training range. Energy is additive and unbounded: a meal with more oil than any training meal gets the energy of the largest leaf, and sums of many small contributions are approximated by a staircase (MAE 59 on macros, 571 on the bag of foods). Gradient-boosted or random forests are excellent on tabular data with interactions; they are the wrong tool for an additive linear law.

The full solution

solution.py does everything in one run (about 30 seconds):

  1. Load food.csv, nutrient.csv, food_nutrient.csv; keep foundation foods; pivot the needed nutrients (ids 1008/2047/2048 energy, 1003 protein, 1004 fat, 1005 carbohydrate, 1079 fibre); clean as described; add the ratio features and the category name.
  2. Generate the 2 000 meals and their exact energy; build the three representations.
  3. Train linear regression, a random forest (300 trees) and an MLP (two hidden layers of 64, standardised inputs) on each representation; report MAE and RMSE on the 400 test meals.
  4. Generalisation test: hold out 20 % of the foods entirely, train on meals without them, test on 300 meals made only of them.
  5. Healthier alternatives by linear programming (scipy.optimize.linprog).

For part 3, every requirement is linear in the grams of ingredients, so the "healthier version" is an optimisation problem, not a guess. Variables: the grams of each original ingredient, of its allowed healthier substitutes (white flour to whole-wheat, oat or defatted soy flour; feta to low-fat cottage cheese; bacon to lean ham; sour cream, whole milk and whole eggs to non-fat yogurt, skim milk and egg whites...) and of optional add-ons (non-fat Greek yogurt, cottage cheese, broccoli). The objective is to change the recipe as little as possible: minimise grams removed plus grams swapped in plus grams added. Constraints:

∑jejgj/100≤0.73 E0,∑jcjgj/100≤0.95 C0,4P(g)−1.1 s0 E(g)≥0(protein share≥1.1×original s0=4P0/E0),P(g)≥P0,∑jgj≥0.9×original mass,\begin{aligned} &\textstyle\sum_j e_j g_j/100 \le 0.73\,E_0, \qquad \sum_j c_j g_j/100 \le 0.95\,C_0, \\ &4P(g) - 1.1\, s_0\, E(g) \ge 0 \quad (\text{protein share} \ge 1.1 \times \text{original } s_0 = 4P_0/E_0), \\ &P(g) \ge P_0, \qquad \textstyle\sum_j g_j \ge 0.9 \times \text{original mass}, \end{aligned}

plus, for each ingredient "slot", at most its original mass and at least half of it (20 % for fats and sugar). The ratio constraint "protein share higher" becomes linear by multiplying out the denominator. The last two lines are our addition: without them the optimiser simply serves a smaller plate (we tried; it met the letter of the task with less protein in absolute terms).

Results

Energy prediction, 400 test meals:

representationmodelMAE (kcal)RMSE (kcal)
mass per food categorylinear regression291.2401.8
mass per food categoryrandom forest361.0486.3
mass per food categoryMLP427.4576.9
grams of each foodlinear regression0.00.0
grams of each foodrandom forest570.6774.4
grams of each foodMLP284.7375.4
protein / fat / carbs / fibrelinear regression14.422.7
protein / fat / carbs / fibrerandom forest59.4134.0
protein / fat / carbs / fibreMLP14.822.9

On meals made only of foods unseen in training: bag of foods 1 603 (linear) and 971 (forest); macros 23 (linear) and 82 (forest).

Left: true versus predicted energy of the 400 test meals; the linear model on macronutrients lies on the diagonal while the random forest scatters, especially for large meals. Right: MAE on meals of unseen foods: 1603 and 971 kcal for the bag-of-foods models, 23 and 82 kcal for the macronutrient models.

Healthier alternatives (coconut oil and sour cream stand in for the sunflower oil and butter missing from the data; feta stands in for bryndza):

mealquantityoriginalalternativechange
Bryndzové haluškykcal1 4751 076−27.1-27.1 %
carbohydrates (g)179.8104.6−41.8-41.8 %
protein (g)68.868.7−0.2-0.2 %
fat (g)53.442.4−20.6-20.6 %
protein share of energy18.7 %25.5 %
Vyprážaný syr with frieskcal1 4231 040−26.9-26.9 %
carbohydrates (g)90.372.2−20.0-20.0 %
protein (g)59.459.7+0.4+0.4 %
fat (g)93.957.0−39.3-39.3 %
protein share of energy16.7 %23.0 %
Palacinky with strawberrieskcal1 028748−27.3-27.3 %
carbohydrates (g)147.0101.8−30.8-30.8 %
protein (g)34.634.4−0.6-0.6 %
fat (g)35.022.8−34.9-34.9 %
protein share of energy13.5 %18.4 %

What the optimiser chose, in kitchen terms: for halušky, the flour cut from 150 g to 75 g with more than half of it defatted soy flour, less bacon (50 to 20 g) and a spoon of Greek yogurt; for the fried cheese, much less frying fat (40 to 8 g, the single biggest saving) and defatted soy flour instead of white flour in the coating and a little less cheese; for the pancakes, much less sugar and oil and a little less flour. All three meet the three required constraints. The protein changes of −0.6-0.6 to +0.4+0.4 % come from rounding the grams to integers. The LP also produces a few tiny amounts (1 g of flour, 5 g of ham) that a cook would round away: a minimum-amount constraint would need integer variables, a natural extension.

What to remember

  • When the target is a known formula, the representation is everything. Energy is linear in grams and almost linear in macronutrients; with those features a linear regression is exact or nearly so, and it even rediscovers the 4/9/4 Atwater factors.
  • Test generalisation the way the model will be used. A random split rewarded the lookup table (MAE 0); a split by unseen foods revealed that only the macronutrient model has learnt something transferable.
  • Tree ensembles cannot extrapolate an additive law: they predict constants learnt inside the training range.
  • Data cleaning decisions change results: missing rows can be "unknown" or "truly zero" (carbohydrates of oil); document each choice.
  • "Design a healthier alternative" is an optimisation problem. Write the goals as linear constraints (a ratio constraint becomes linear after multiplying out), choose a sensible objective (smallest change to the recipe), and let a linear-programming solver find it.
Code complet (solution.py)
"""Slovak AI Olympiad 2025/26, home round task 1: meal energy prediction + healthier alternatives.

Data: USDA FoodData Central, Foundation Foods CSV (2025-04-24), unzipped in work/fdc/.
  python solution.py
Part 1  clean the food table, build a dataset of 2 000 random meals (3-10 ingredients) with their exact
        energy, train 3 models on 3 different meal representations, report MAE / RMSE.
Part 2  for 3 Slovak-style meals, find an alternative with >= 25 % fewer kcal, fewer carbohydrates and a
        higher protein share, by LINEAR PROGRAMMING over ingredient amounts and allowed substitutions.
"""
import numpy as np, pandas as pd, json
from scipy.optimize import linprog
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.neural_network import MLPRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.metrics import mean_absolute_error, mean_squared_error

rng = np.random.default_rng(0)
F = "work/fdc/"
# ------------------------------------------------------------------ 1. food table: kcal and macros per 100 g
food = pd.read_csv(F + "food.csv"); food = food[food.data_type == "foundation_food"]
cat = pd.read_csv(F + "food_category.csv").set_index("id").description
fn = pd.read_csv(F + "food_nutrient.csv", usecols=["fdc_id", "nutrient_id", "amount"], low_memory=False)
fn = fn[fn.fdc_id.isin(food.fdc_id)]
NUT = {1008: "kcal_1008", 2047: "kcal_atw_gen", 2048: "kcal_atw_spec", 1003: "protein", 1004: "fat",
       1005: "carbs", 1079: "fiber"}
wide = fn[fn.nutrient_id.isin(NUT)].pivot_table(index="fdc_id", columns="nutrient_id", values="amount").rename(columns=NUT)
t = food.set_index("fdc_id")[["description", "food_category_id"]].join(wide)
# energy: the measured "Energy" row when present, otherwise the Atwater-factor estimates
t["kcal"] = t.kcal_1008.fillna(t.kcal_atw_gen).fillna(t.kcal_atw_spec)
# oils and butter (>= 80 g fat / 100 g) often have no carbohydrate row: it is genuinely ~0, not unknown
t.loc[t.carbs.isna() & (t.fat >= 80), "carbs"] = 0.0
t = t.dropna(subset=["kcal", "protein", "fat", "carbs"])                    # drop incomplete foods
t = t[(t.kcal > 0) | (t.description.str.contains("Salt"))]
t["fiber"] = t.fiber.fillna(0)
t = t.groupby("description").agg({"food_category_id": "first", "kcal": "mean", "protein": "mean",
                                  "fat": "mean", "carbs": "mean", "fiber": "mean"})   # merge duplicate entries
t["category"] = t.food_category_id.map(cat)
for m in ["protein", "fat", "carbs"]:                                          # the requested ratio features
    t[f"{m}_per_kcal"] = t[m] / t.kcal.replace(0, np.nan)
print(f"{len(t)} distinct foods with complete energy and macros")

def meal_totals(meal):
    """meal = {food description: grams} -> exact kcal and macros (the 'true' energy of the task)."""
    g = pd.Series(meal); rows = t.loc[g.index]
    return (rows[["kcal", "protein", "fat", "carbs", "fiber"]].mul(g / 100, axis=0)).sum()

# ------------------------------------------------------------------ 2. a dataset of random meals
GRAMS = {"Fats and Oils": (5, 40), "Spices and Herbs": (1, 8), "Sweets": (5, 50)}
foods = t.index.to_numpy()
def random_meal():
    k = rng.integers(3, 11); chosen = rng.choice(foods, k, replace=False)
    return {f: float(np.round(rng.uniform(*GRAMS.get(t.category[f], (20, 300))))) for f in chosen}
meals = [random_meal() for _ in range(2000)]
y = np.array([meal_totals(m).kcal for m in meals])

# three representations of the same meal
names = list(foods); idx = {f: i for i, f in enumerate(names)}
X_bag = np.zeros((len(meals), len(names)))                                   # grams of every food
for r, m in enumerate(meals):
    for f, g in m.items(): X_bag[r, idx[f]] = g
cats = sorted(t.category.unique())
X_naive = np.array([[sum(m.values()), len(m)] + [sum(g for f, g in m.items() if t.category[f] == c) for c in cats]
                    for m in meals])                                          # mass per category only
X_macro = np.array([meal_totals(m)[["protein", "fat", "carbs", "fiber"]].values for m in meals])   # grams of macros

models = {"linear regression": lambda: LinearRegression(),
          "random forest": lambda: RandomForestRegressor(300, min_samples_leaf=2, n_jobs=2, random_state=0),
          "neural network (MLP)": lambda: make_pipeline(StandardScaler(), MLPRegressor(hidden_layer_sizes=(64, 64), max_iter=3000,
                                                                                    random_state=0))}
perm = rng.permutation(len(meals)); tr_i, te_i = perm[:1600], perm[1600:]
results = []
for rep, X in [("mass per food category", X_naive), ("grams of each food (bag of foods)", X_bag),
               ("total protein / fat / carbs / fibre", X_macro)]:
    for name, mk in models.items():
        m = mk().fit(X[tr_i], y[tr_i]); p = m.predict(X[te_i])
        results.append({"features": rep, "model": name, "MAE": mean_absolute_error(y[te_i], p),
                        "RMSE": mean_squared_error(y[te_i], p) ** 0.5})
        if name == "linear regression" and X is X_macro: coef = m.coef_; pred_best = p
        if name == "random forest" and X is X_macro: pred_rf = p
res = pd.DataFrame(results); print(res.round(1).to_string(index=False))
print("linear model on macros, kcal per gram of protein, fat, carbs, fibre:", np.round(coef, 2))

# harder test: meals made only of foods NEVER seen in training meals
held = set(rng.choice(foods, len(foods) // 5, replace=False))
seen = [i for i, m in enumerate(meals) if not (set(m) & held)]
unseen_meals = []
while len(unseen_meals) < 300:
    k = rng.integers(3, 8); ch = rng.choice(sorted(held), k, replace=False)
    unseen_meals.append({f: float(rng.uniform(*GRAMS.get(t.category[f], (20, 300)))) for f in ch})
yu = np.array([meal_totals(m).kcal for m in unseen_meals])
Xu_macro = np.array([meal_totals(m)[["protein", "fat", "carbs", "fiber"]].values for m in unseen_meals])
Xu_bag = np.zeros((len(unseen_meals), len(names)))
for r, m in enumerate(unseen_meals):
    for f, g in m.items(): Xu_bag[r, idx[f]] = g
gen = {}
for rep, X, Xu in [("bag of foods", X_bag, Xu_bag), ("macros", X_macro, Xu_macro)]:
    for name in ["linear regression", "random forest"]:
        m = models[name]().fit(X[seen], y[seen]); gen[f"{rep} / {name}"] = mean_absolute_error(yu, m.predict(Xu))
print("MAE on meals of unseen foods:", {k: round(v, 1) for k, v in gen.items()})

# ------------------------------------------------------------------ 3. healthier alternatives by linear programming
# butter and sunflower oil have no energy row in Foundation Foods, so they were cleaned out:
# coconut oil (the only oil with complete data) and sour cream stand in for them
MEALS = {
    "Bryndzove halusky (feta for bryndza)": {"Potatoes, russet, without skin, raw": 300, "Flour, wheat, all-purpose, enriched, bleached": 150,
        "Cheese, feta, whole milk, crumbled": 120, "Pork, cured, bacon, cooked, restaurant": 50, "Cream, sour, full fat": 50},
    "Vyprazany syr s hranolkami": {"Cheese, swiss": 150, "Flour, wheat, all-purpose, enriched, bleached": 30, "Eggs, Grade A, Large, egg whole": 50,
        "Bread, white, commercially prepared": 40, "Oil, coconut": 40, "Potatoes, russet, without skin, raw": 250},
    "Palacinky s jahodami": {"Flour, wheat, all-purpose, enriched, bleached": 120, "Milk, whole, 3.25% milkfat, with added vitamin D": 250,
        "Eggs, Grade A, Large, egg whole": 100, "Sugars, granulated": 30, "Oil, coconut": 15, "Strawberries, raw": 150},
}
SUBS = {  # healthier swaps a cook would accept (same role in the dish)
    "Flour, wheat, all-purpose, enriched, bleached": ["Flour, whole wheat, unenriched", "Flour, oat, whole grain", "Flour, soy, defatted"],
    "Cheese, feta, whole milk, crumbled": ["Cheese, cottage, lowfat, 2% milkfat"],
    "Pork, cured, bacon, cooked, restaurant": ["Ham, sliced, pre-packaged, deli meat (96%fat free, water added)"],
    "Cream, sour, full fat": ["Yogurt, Greek, plain, nonfat"], "Oil, coconut": [],
    "Cheese, swiss": ["Cheese, mozzarella, low moisture, part-skim"],
    "Bread, white, commercially prepared": ["Bread, whole-wheat, commercially prepared"],
    "Potatoes, russet, without skin, raw": ["Sweet potatoes, orange flesh, without skin, raw"],
    "Milk, whole, 3.25% milkfat, with added vitamin D": ["Milk, nonfat, fluid, with added vitamin A and vitamin D (fat free or skim)"],
    "Eggs, Grade A, Large, egg whole": ["Eggs, Grade A, Large, egg white"],
    "Sugars, granulated": [], "Strawberries, raw": ["Raspberries, raw"],
}
ADDONS = ["Yogurt, Greek, plain, nonfat", "Cheese, cottage, lowfat, 2% milkfat", "Broccoli, raw"]
SHRINK = {"Fats and Oils": 0.2, "Sweets": 0.2}            # these slots may shrink a lot, the others keep >= 50 %

def healthier(meal, kcal_cut=0.27):
    """Smallest change to the recipe that meets the three goals, as a LINEAR PROGRAM.
    Variables: grams of every original food, of its allowed substitutes, and of protein add-ons.
    Objective: minimise grams swapped in + grams added + grams removed (stay close to the dish).
    Constraints: kcal <= (1 - kcal_cut) x original, carbs <= 0.95 x original,
    protein share of energy >= 1.1 x original (linear: 4*protein - 1.1*s0*kcal >= 0),
    and, so that it is not just a smaller plate: protein grams >= original, total mass >= 90 % of original;
    each slot (food + its substitutes) keeps >= 50 % of its mass (fats and sugar may drop to 20 %)."""
    o = meal_totals(meal); s0 = 4 * o.protein / o.kcal; var, slot, orig = [], [], []
    for f in meal:
        for g in [f] + [s for s in SUBS.get(f, []) if s in t.index]:
            var.append(g); slot.append(f); orig.append(g == f)
    for a_ in ADDONS: var.append(a_); slot.append(None); orig.append(False)
    N = t.loc[var]; orig = np.array(orig)
    c = np.where(orig, -1.0, 1.0)                                  # -x for kept originals, +x for new grams
    kc, pr, cb = N.kcal.values / 100, N.protein.values / 100, N.carbs.values / 100
    tot = sum(meal.values())
    A = [kc, cb, -(4 * pr - 1.1 * s0 * kc), -pr, -np.ones(len(var))]
    b = [(1 - kcal_cut) * o.kcal, 0.95 * o.carbs, 0.0, -o.protein, -0.9 * tot]
    for f, g0 in meal.items():
        row = np.array([1.0 if s == f else 0 for s in slot]); lo = SHRINK.get(t.category[f], 0.5) * g0
        A += [-row, row]; b += [-lo, g0]
    bounds = [(0, 100) if s is None else (0, meal[s] if v == s else None) for v, s in zip(var, slot)]
    r = linprog(c, A_ub=np.array(A), b_ub=np.array(b), bounds=bounds, method="highs")
    assert r.success, r.message
    alt = {v: round(x) for v, x in zip(var, r.x) if x > 0.5}
    return alt, o, meal_totals(alt)

rows = []
for name, meal in MEALS.items():
    alt, o, a = healthier(meal)
    share = lambda s: 4 * s.protein / s.kcal                          # share of energy coming from protein
    ok = (a.kcal <= 0.75 * o.kcal) and (a.carbs < o.carbs) and (share(a) > share(o))
    print(f"\n{name}: constraints met = {ok}\n  original    {meal}\n  alternative {alt}")
    for k in ["kcal", "carbs", "protein", "fat"]:
        rows.append({"meal": name, "quantity": k, "original": round(o[k], 1), "alternative": round(a[k], 1),
                     "change %": round(100 * (a[k] - o[k]) / o[k], 1)})
    rows.append({"meal": name, "quantity": "protein share of kcal %", "original": round(100 * share(o), 1),
                 "alternative": round(100 * share(a), 1), "change %": None})
cmp = pd.DataFrame(rows); print(cmp.to_string(index=False))
json.dump({"results": results, "coef": coef.tolist(), "gen": gen, "y_test": y[te_i].tolist(),
           "pred_lin_macro": pred_best.tolist(), "pred_rf_macro": pred_rf.tolist()}, open("work/res.json", "w"))
cmp.to_csv("work/comparison.csv", index=False)