Jump to content

Recipe/Solver/Claude

From Open Food Facts wiki

This solver is the result of an extended discussion with Claude. The purpose was to find an alternate solver, so results could be checked. Secondly the idea was to explore the possibilities to the extend the NNLS solver for other unknowns in the data.

Decreasing order NNLS

Here's a summary of the theoretical discussion on decreasing-order NNLS:

The basic reformulation

To enforce a non-increasing solution x₁ ≥ x₂ ≥ ... ≥ xₙ, define a difference vector d where d₁ = x₁ and dⱼ = xⱼ₋₁ − xⱼ for j ≥ 2. The constraint d ≥ 0 then encodes the decreasing order exactly. The transformation matrix L maps x = Ld, giving a standard NNLS problem on à = AL with solution recovered as x = Ld.

Non-negativity interaction

Non-negativity on x is not automatically guaranteed by decreasing order — the sequence can cross zero anywhere. When both constraints are required together, they reduce to a single binding condition: xₙ ≥ 0, equivalent to d₁ ≥ d₂ + ... + dₙ. This is a linear inequality on d that cannot be folded into simple non-negativity, making the joint problem a proper quadratic program rather than plain NNLS.

Post-hoc approach

In practice, solving the decreasing-order NNLS without the non-negativity constraint and checking it afterwards was found to work well. This was attributed to the steepness of the decrease — when x declines steeply, xₙ sits well above zero and the non-negativity constraint is comfortably inactive at the solution, making the post-hoc check a genuine reflection of problem structure rather than luck.

Active set behaviour

Adding the decreasing constraint changes the meaning of the active set fundamentally. In plain NNLS, an active variable means xⱼ = 0. In decreasing-order NNLS, an active variable dⱼ = 0 means xⱼ = xⱼ₋₁ — a plateau where consecutive values are tied. Multiple consecutive active variables produce a flat region in the solution, which the solver interprets as "the data does not support any decrease here." This is a softer and often more physically meaningful outcome than hard zeros, and helps explain why non-negativity violations are rare in practice.

Variance and weighting

Weighting interacts with the constrained problem the same way as in plain NNLS for variables in the passive set, with the optimal choice wᵢ = 1/σᵢ² minimising variance. However the active set behaviour — now producing plateaus rather than zeros — introduces bias-variance tradeoffs near the plateau boundaries that make analytic variance formulas unreliable. Bootstrap methods were recommended for honest uncertainty quantification.

Discussion transcript

Extending NNLS

The base NNLS solver has been extended to support all aspects of a nutritional table.

Problem description

Given a product label (nutrients_product, g per 100 g product) and CIQUAL nutritional data for the candidate ingredients (ingredients, g per 100 g ingredient), find the recipe weights x (g raw ingredient per 100 g final product) such that:

(ingredients / 100) %*% x  ≈  nutrients_product

The solver uses non-negative least squares (NNLS) with a reparametrisation that converts ordering constraints into non-negativity constraints on auxiliary variables d, so that standard NNLS can be applied directly.


Inputs

Argument Format Notes
ingredients named list or matrix CIQUAL values, g per 100 g ingredient. NA = nil (not reported). May contain more nutrients than the label.
nutrients_product named numeric vector Label values, g per 100 g product. NA = not declared on label.
chain list of ingredient() / group() nodes Labelling-law ingredient order (descending weight).

Unit convention: both inputs use g per 100 g (standard packaging convention). The solver divides ingredients by 100 internally; x comes out in grams.


Constraints

Ingredient ordering (mandatory)

Ingredients must appear on the label in descending order of raw weight. Encoded via the chain argument using ingredient() and group() nodes.

chain <- list(
  ingredient("flour"),
  group(ingredient("water"), ingredient("yeast")),  # sub-recipe: water > yeast
  ingredient("salt")
)

A group() means the combined weight of its members is greater than the next chain element. Within a group, members are also ordered descending.

Nutrient ordering (default-on)

Physical truths enforced as hard inequality constraints via slack variables:

nutrient_geq = list(c("fat", "sat_fat"),   # sat fat is a subset of fat
                    c("carbs", "sugar"))    # sugars are a subset of carbohydrates

Pairs where a nutrient is absent from the label are silently skipped. Additional pairs can be added or the default list replaced.

Nutrient upper bounds

For nutrients declared as < X g on the label:

nutrient_bounds = list(salt = 0.5)

Soft fraction priors

When the label declares a percentage for an ingredient (e.g. "contains 45% tomato"):

fractions = list(tomato = 0.45)

Group fraction priors

When the label declares a percentage for a sub-recipe:

group_fractions = list(
  pizza_base = list(members = c("flour", "water", "yeast"), fraction = 0.60)
)

Low-error ratio constraints (near-hard)

For single-nutrient ingredients (salt, sugar, oil, water) where CIQUAL error is small and the label fraction is reliable. The ratio x_i / x_j = f_i / f_j cancels cooking yield uncertainty.

low_error_fractions = list(salt = 0.02, sugar = 0.10)

Nil and missing data

Situation How to encode Effect
CIQUAL does not report a nutrient for an ingredient NA in ingredients Cell excluded from fit
Nutrient genuinely absent (e.g. oil has 0 carbs) 0 in ingredients Kept as a real constraint
Nutrient not on the product label NA in nutrients_product Excluded from fit; predicted afterwards
Nutrient in CIQUAL but not on the label extra row in ingredients Used for prediction only, not fitting

Outputs

Recipe weights

sol$x — named vector, g raw ingredient per 100 g final product.

Cooking yield

sol$yield_pct100 / sum(x) * 100 (%). Values well below 100 % indicate concentration or evaporation (e.g. apple syrup ≈ 18 %).

Nutrient fit report

sol$fit_report — observed vs. fitted values and relative errors for all label nutrients. Flags large residuals (> 20 %) which may indicate a poor CIQUAL proxy.

Predicted nutrients

sol$predicted_nutrients — full table of all CIQUAL nutrients, with columns:

  • observed — label value (if declared)
  • predicted — value computed from solved recipe
  • source — "declared on label" / "on label as NA" / "not on label — predicted from recipe"

Identifiability diagnostics

sol$id_report — column norms and pairwise correlations for each ingredient's CIQUAL profile. Flags ingredients that are nutritionally invisible or collinear with another ingredient (the solver cannot distinguish them).

NNLS plateau report

sol$plateau_report — for zero-valued ingredients, reports whether the zero is a "true zero" (the fit would worsen if the ingredient were added) or "on plateau" (the fit is indifferent — the ingredient is nutritionally interchangeable with another).

Bootstrap uncertainty

sol$bootstrap_report — 95 % confidence intervals on each ingredient weight, obtained by residual resampling over 500 replicates (configurable). Activated with bootstrap = TRUE.

Producer comparison

When the producer's recipe is known (fully or partially), pass it as recipe_product:

recipe_product = c(flour = 78, oil = 20)   # salt unknown → shown as "?"

The recipe table in the output gains a "true" column and a signed error column.


Typical call

sol <- solve_recipe(
  ingredients         = list(
    flour = c(fat=1.4,  sat_fat=0.2, carbs=73.3, sugar=0.3,  fibre=2.7, protein=10.3, salt=0.01),
    oil   = c(fat=100,  sat_fat=11,  carbs=0,    sugar=0,    fibre=0,   protein=0,    salt=0),
    salt  = c(fat=NA,   sat_fat=NA,  carbs=NA,   sugar=NA,   fibre=NA,  protein=NA,   salt=97.5)
  ),
  nutrients_product   = c(fat=21.2, sat_fat=2.4, carbs=57.4,
                           sugar=0.2, fibre=2.1, protein=8.1, salt=2.0),
  chain               = list(ingredient("flour"), ingredient("oil"), ingredient("salt")),
  low_error_fractions = list(salt = 0.02),
  recipe_product      = c(flour = 78, oil = 20, salt = 2),   # optional
  bootstrap           = TRUE
)

Experiment yourself

You can try this all for yourself (very interesting), no coding required. Download Claude, setup a free account and initialise a chat a set of files:

  • CLAUDE.md - is the problem description that Claude bases it's reasoning on;
  • recipe_solver_v.1.2.py - is the python code that Claude will use;
  • Ciqual.csv - the nutritional data for the ingredients;
  • IFCT2017.pdf - the Indian nutritional data information as second source;

Ask @aleene on Slack for the files, as the wiki does not support the corresponding file formats.