Meal plan algorithm closest to x calories with types

Question:

I have a problem where I need to generate a meal plan with:

  • x number of meals per day (eg. 5)
  • x number of meal types in the plan (eg. 2 breakfasts, 2 snacks and 1 lunch)
  • Number of calories in meal plan (eg. 2000)
  • There should be no duplicate meals

The given data is a list of dictionaries (over 100,000 units) structured this way:

{'title': 'Cannellini Bean and Asparagus Salad with Mushrooms', 'types': ['side dish', 'lunch', 'main course', 'salad', 'main dish', 'dinner'], 'calories': 482}

The output of the algorithm should be a list of x meals closest to the x calories, with their associated meal types.

I have no idea where to start with this problem, any algorithm type or implementation is welcome.

Asked By: Andrey Kiselev

||

Answers:

This problem is NP-hard. That does not mean we cannot solve it, however: we can often use techniques from constraint programming to solve such problems quickly.

Some parameters for your constraints:

NUM_MEALS = 5
MEAL_TYPES = ['breakfast', 'dinner', 'lunch', 'snack']
REQUIRED_MEALS = [1, 1, 1, 2]
TARGET_CALORIES = 2_000
TOLERANCE = 50

I assume there are four meal types, each having a particular amount of times they need to be present in a meal plan (here, each of breakfast, dinner, and lunch are required to be present once, and we need two snacks). We also have a caloric requirement of 2K total calories, and a tolerance on what we consider OK: anything in the range [2K - 50, 2K + 50] will do. We need a bit of a tolerance since it’s not always possible to find a perfect set of meals that reaches 2K calories exactly. Feel free to change the tolerance to whatever you think is reasonable.

To generate some random data of appropriate size:

def make_random_meal_options():
    options = []

    for _ in range(100_000):
        calories = np.random.randint(100, 1_000)
        num_types = np.random.randint(1, len(MEAL_TYPES))
        types = np.random.choice(MEAL_TYPES, num_types, replace=False)

        options.append(dict(calories=calories, types=types))

    # List of dictionaries with keys 'calories' and 'types'
    return options

meal_options = make_random_meal_options()

We generate 100K dictionaries representing meals: each dictionary has a number of calories (uniform in [100, 1000]) and a set of meal types it can fulfil.

It is helpful if we have the calories as a numpy array, along with a boolean matrix matching meals and meal types:

import numpy as np

calories = np.array([option['calories'] for option in meal_options])

# Element (i, j) == True iff meal i has meal type j, and False otherwise.
meal_types = np.empty((len(meal_options), len(MEAL_TYPES)), dtype=bool)

for i, option in enumerate(meal_options):
    for j, meal_type in enumerate(MEAL_TYPES):
        meal_types[i, j] = meal_type in option['types']

To specify the model I will use Google OR-Tools’ CP solver, since that’s freely available software.

from ortools.sat.python import cp_model

model = cp_model.CpModel()

# Decision variables, one for each meal and meal type: meal[i, j] is 1 iff
# meal i is assigned to meal type j, and 0 otherwise.
meal_vars = np.empty((len(meal_options), len(MEAL_TYPES)), dtype=object)

for i in range(len(meal_options)):
    for j in range(len(MEAL_TYPES)):
        meal_vars[i, j] = model.NewBoolVar(f"meal[{i}, {j}]")

# We want the overall caloric value of the meal plan to be within bounds.
lb, ub = [TARGET_CALORIES - TOLERANCE, TARGET_CALORIES + TOLERANCE]
model.AddLinearConstraint(calories @ meal_vars.sum(axis=1), lb, ub)

for j, meal_type in enumerate(MEAL_TYPES):
    # Need the required amount of meals of each type.
    model.Add(meal_types[:, j] @ meal_vars[:, j] == REQUIRED_MEALS[j])

for i in range(len(meal_options)):
    # Each meal can only be selected once across all meal types.
    model.Add(meal_vars[i, :].sum() <= 1)

# Need NUM_MEALS meals in the meal plan
model.Add(meal_vars.sum() == NUM_MEALS)

solver = cp_model.CpSolver()
status = solver.Solve(model)

if status == cp_model.OPTIMAL or status == cp_model.FEASIBLE:
    print(f"Solving took {solver.WallTime():.2f} seconds")

    for i in range(len(meal_options)):
        for j in range(len(MEAL_TYPES)):
            if solver.Value(meal_vars[i, j]) > 0:
                option = meal_options[i]
                cal = option['calories']
                mt = MEAL_TYPES[j]

                print(f"Selected meal {i} with {cal} calories for {mt}.")
else:
    print("No solution found.")

This is a constraint programming model that has a boolean selection variable (yes/no) for the assignment of meals to meal types, and several constraints on these variables: each meal can only be selected once across all meal types, the selected meals need to sum to a caloric value within the tolerance bounds, there need to be sufficient meals of each type, and we need exactly NUM_MEALS meals.

When I run this on my machine after fixing the seed to 42, I get:

Solving took 19.07 seconds
Selected meal 1682 with 100 calories for lunch.
Selected meal 76138 with 999 calories for snack.
Selected meal 86843 with 100 calories for breakfast.
Selected meal 95861 with 100 calories for snack.
Selected meal 99987 with 706 calories for dinner.

The runtime is well under a minute, and the meal plan works. This problem is solvable despite being NP-hard.

Answered By: Nelewout