Skip to content

Instantly share code, notes, and snippets.

@tommyod
Last active August 8, 2022 22:58
Show Gist options
  • Save tommyod/d1440bdca7e1e954aea9c9d7708c0f63 to your computer and use it in GitHub Desktop.
Save tommyod/d1440bdca7e1e954aea9c9d7708c0f63 to your computer and use it in GitHub Desktop.
Automatic planning of cheap, varied, nutritional meals
# -*- coding: utf-8 -*-
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MEAL PLANNER - Automatic planning of cheap, varied, nutritional meals
---------------------------------------------------------------------
Summary
-------
This Python module uses optimization (Linear Programming) and stochastic methods to
generate a meal with lots of variety while still obeying user-defined nutritional
constraints. Solving the Stigler Diet problem directly yields sparse, boring solutions.
This program samples some foods, solves a small Stigler Diet problem, and then evalutes
the many solutions to come up with a meal plan which has good variety.
Created by GitHub users @tommyod and @JensWahl on a slow weekend, July 2019.
Installation
------------
This program was tested with Python 3.7.3 and ortools 7.2.6977. To install and run:
1. Install Python 3.7
2. Install ortools (`pip install ortools`)
3. Run `python <this file>`
If you have markdown2 (`pip install markdown2`), you can render the output of this
program as a HTML file and print it for your fridge. Tested with markdown2 2.3.8.
1. $ python <this file> > meal.md
2. $ markdown2 meal.md > meal.html
"""
import operator
import dataclasses
import collections
import warnings
import statistics
import pprint
import typing
import random
import copy
from ortools.linear_solver import pywraplp
# =============================================================================
# CLASSES - Used to store data and make some computations more easily
# =============================================================================
@dataclasses.dataclass(frozen=True)
class Food:
"""
A food consists of a name and nutritional data given in units of 100 grams.
Examples
--------
>>> eggs = Food(name='eggs', protein=13.0, fat=10.6, carbs=0.3, kcal=149,
... price_per_product=32.9, grams_per_product=690)
>>> eggs.price
4.768115942028985
>>> eggs.name
'eggs'
"""
name: str
# Values are per 100 grams of food
protein: float
fat: float
carbs: float
kcal: float
# Used to infer the price per 100 grams
price_per_product: float
grams_per_product: float
@property
def price(self):
return self.price_per_product / self.grams_per_product * 100
def __post_init__(self):
"""Verify the relationship between macros and kcal."""
computed_kcal = 4 * self.protein + 4 * self.carbs + 9 * self.fat
relative_error = abs((self.kcal - computed_kcal) / computed_kcal)
if relative_error > 0.1:
warnings.warn(f"Got a {relative_error:.2f} error on kcal: '{self.name}'.")
@dataclasses.dataclass(frozen=True)
class Meal:
"""
A meal consists of several foods given at some "base" unit of grams. In the example
below 'eggs' is the general food, an a meal consists of discrete units of eggs,
weighing approximately 65 grams each.
Examples
--------
>>> eggs = Food(name='eggs', protein=13.0, fat=10.6, carbs=0.3, kcal=149,
... price_per_product=32.9, grams_per_product=690)
>>> egg = Meal(name='egg', foods={eggs:65}, discrete=True)
>>> egg.price # price of the meal
3.0992753623188407
"""
# Foods are added as: foods={all_foods["lettmelk"]:100, all_foods["musli"]:100}
# This means that a baseline
name: str
foods: typing.Dict[Food, float] = dataclasses.field(default_factory=dict)
discrete: bool = True
type: str = None
def __getattr__(self, key):
"""Allow accessing attributes of foods, summing over them."""
return sum(
getattr(food, key) * quantity / 100
for (food, quantity) in self.foods.items()
)
def __hash__(self):
return hash(self.name) + hash(frozenset(self.foods.keys()))
def __iter__(self):
return iter(self.foods.keys())
def __copy__(self):
return type(self)(
name=self.name, foods=self.foods.copy(), discrete=self.discrete
)
def __str__(self):
name = type(self).__name__
foods_names = (
"{"
+ ", ".join(
f"{quantity}g {food.name}" for food, quantity in self.foods.items()
)
+ "}"
)
return name + f"(name='{self.name}', foods={foods_names})"
class Day(collections.UserList):
def __getattr__(self, key):
"""Allow accessing attributes of meals"""
return sum(getattr(meal, key) for meal in self.data)
# =============================================================================
# FUNCTIONS - Used to generate the meal plan
# =============================================================================
def sample_single_day(meals, *, num_meals=3, type_constraints=None):
"""Sample a day consisting of several meals, meeting all meal type constraints."""
if type_constraints is None:
type_constraints = dict()
else:
type_constraints = copy.deepcopy(type_constraints)
type_constraints[None] = num_meals - sum(type_constraints.values())
chosen_meals = Day()
for meal_type, num in type_constraints.items():
# Filters the meal of a given meal type
valid_meals = [meal for meal in meals if meal.type == meal_type]
# Choose the desired number of meals of the type and add to chosen list
to_add = random.choices(valid_meals, k=num)
chosen_meals += to_add
assert len(chosen_meals) == num_meals
return chosen_meals
def optimize_day(day, *, dietary_constraints, price_epsilon=0.001, low_range=(1, 4)):
"""Optimize the quantitiy of each meal in a day, given constraints."""
# Create a solver and an objective function
solver = pywraplp.Solver("food", pywraplp.Solver.CBC_MIXED_INTEGER_PROGRAMMING)
objective_function = 0
INF = solver.infinity()
# Create variables representing quantities of food
solution = []
for i, food in enumerate(day):
# A lower limit for the quanitity, forces variation in the solutions
low_limit = random.choice(range(*low_range))
# Create a variable - use str(i) to avoid duplicate names if same foods are
# present in the solution
if food.discrete:
x = solver.IntVar(low_limit, INF, food.name + str(i))
else:
x = solver.NumVar(low_limit, INF, food.name + str(i))
# Gently perturb the solution towards cheaper combinations
objective_function += price_epsilon * food.price * x
solution.append(x)
# Set up the contraints. Here goal programming is used to create "soft" constraints
# instead of hard contraints, since a feasible solution might not exist in general
# with hard constraints: https://en.wikipedia.org/wiki/Goal_programming
for macro, (low, high) in dietary_constraints.items():
food_macros = [getattr(meal, macro) for meal in day]
# Create the sum: sum_i food_i * macro_i
total_macro = sum(c * x for x, c in zip(solution, food_macros))
# Slack variables related to the lower limit. Only "undershooting" is penalized.
low_positive = solver.NumVar(0, INF, "over_lower_limit_" + macro)
low_negative = solver.NumVar(0, INF, "under_lower_limit_" + macro)
solver.Add(total_macro + low_positive - low_negative == low)
# Slack variables related to the upper limit. Only "overshooting" is penalized.
high_positive = solver.NumVar(0, INF, "over_upper_limit_" + macro)
high_negative = solver.NumVar(0, INF, "under_upper_limit_" + macro)
solver.Add(total_macro + high_positive - high_negative == high)
# Scale the influence of calorier compared to the macros
scale = 0.2 if macro == "kcal" else 1
objective_function += scale * (low_positive + high_negative)
# Minimize the deviation from the goal (with a preference for low price too)
solver.Minimize(objective_function)
result_status = solver.Solve()
assert result_status == pywraplp.Solver.OPTIMAL
assert solver.VerifySolution(1e-7, True)
answer = [(food, round(x.solution_value(), 1)) for food, x in zip(day, solution)]
return answer, round(solver.Objective().Value(), 2)
def sort_by_penality(*, days, obj_func_values):
"""Choose the best days based on heuristics."""
def get_price(result):
"""Preference towards cheaper days."""
return sum(food.price * num for (food, num) in result)
def calorie_spread(result):
"""Preference for lower standard deviation of calories."""
calories = [food.kcal * num for food, num in result]
return statistics.pstdev(calories)
def get_duplicates(result):
"""Preference for many distinct meals in a day."""
return len(result) - len(set([f for f, num in result]))
def standarize(values):
"""Standardize the penality scores."""
mu = statistics.mean(values)
sigma = statistics.pstdev(values)
return [0 if sigma == 0 else (x - mu) / sigma for x in values]
prices = standarize([get_price(result) for result in days])
calorie_stds = standarize([calorie_spread(result) for result in days])
duplicates = standarize([get_duplicates(result) for result in days])
obj_func_values = standarize(obj_func_values)
# A user can change these weights to penalize different properties
generator = zip(prices, calorie_stds, duplicates, obj_func_values)
total_penalty = [p * 1 + c * 1 + d * 1 + o * 5 for (p, c, d, o) in generator]
# Join the solutions with the scores in preparation of argsort
joined = sorted(zip(days, total_penalty), key=operator.itemgetter(1))
return list(map(operator.itemgetter(0), joined))
def print_results(results, *, verbose=True):
"""Print the results in markdown."""
print(f"# Meal plan")
for day_num, result in enumerate(results, 1):
# Heuristics to get more carbohydrates earlier in the day
result = sorted(result, key=lambda r: r[0].carbs, reverse=True)
price = int(sum(meal.price * qnty for (meal, qnty) in result))
print(f"\n## Day {day_num} (price: {price} NOK)")
print(f"\n### Meals\n")
for meal, qnty in result:
print(f"- {qnty} x {str(meal)}")
if not verbose:
continue
print(f"\n### Statistics\n")
for macro in ["kcal", "protein", "fat", "carbs"]:
macro_distribution = [
int(getattr(meal, macro) * qnty) for (meal, qnty) in result
]
print(f"- Total {macro}: {sum(macro_distribution)} {macro_distribution}")
# =============================================================================
# DATA - Stored in dataclasses for convenience
# =============================================================================
def get_data():
"""Stored in a function so as not to pollute global namespace."""
all_foods = [Food(name='bacon', protein=14.0, fat=32.0, carbs=1.0, kcal=350, price_per_product=50.9, grams_per_product=400),
Food(name='burger', protein=15.0, fat=18.0, carbs=2.0, kcal=230, price_per_product=87.3, grams_per_product=800),
Food(name='coop bratwurst', protein=12.0, fat=20.0, carbs=5.2, kcal=253, price_per_product=25.9, grams_per_product=240),
Food(name='cottage cheese', protein=13.0, fat=2.0, carbs=2.1, kcal=79, price_per_product=24.4, grams_per_product=400),
Food(name='egg', protein=13.0, fat=10.6, carbs=0.3, kcal=149, price_per_product=32.9, grams_per_product=690),
Food(name='frossen kyllingfilet', protein=19.0, fat=1.8, carbs=0.3, kcal=94, price_per_product=260.0, grams_per_product=2500),
Food(name='grovt brød', protein=11.0, fat=4.8, carbs=36.0, kcal=245, price_per_product=39.5, grams_per_product=750),
Food(name='gulost', protein=27.0, fat=27.0, carbs=0.0, kcal=351, price_per_product=110.0, grams_per_product=1000),
Food(name='jasmin ris', protein=2.7, fat=0.1, carbs=31.1, kcal=136, price_per_product=45.8, grams_per_product=1000),
Food(name='kjøttdeig', protein=19.0, fat=9.0, carbs=0.0, kcal=157, price_per_product=32.5, grams_per_product=400),
Food(name='lettmelk', protein=3.5, fat=0.5, carbs=4.5, kcal=37, price_per_product=16.4, grams_per_product=1000),
Food(name='melkesjokolade', protein=8.1, fat=33.0, carbs=55.0, kcal=550, price_per_product=38.6, grams_per_product=200),
Food(name='musli', protein=9.0, fat=4.8, carbs=63.0, kcal=351, price_per_product=23.1, grams_per_product=750),
Food(name='PF whey', protein=71.8, fat=8.1, carbs=7.9, kcal=377, price_per_product=599.0, grams_per_product=3000),
Food(name='svinekotelett dypfryst', protein=20.0, fat=18.0, carbs=0.0, kcal=243, price_per_product=98.6, grams_per_product=2000),
Food(name='sweet and sour sauce', protein=0.4, fat=0.2, carbs=16.4, kcal=71, price_per_product=35.0, grams_per_product=675),
Food(name='tomatbønner', protein=3.8, fat=0.6, carbs=14.0, kcal=83, price_per_product=11.8, grams_per_product=420),
Food(name='yoghurt', protein=3.7, fat=3.1, carbs=10.5, kcal=84, price_per_product=17.0, grams_per_product=600)]
foods = {food.name: food for food in all_foods}
#pprint.pprint(sorted(foods.values(), key=lambda f:f.name.lower()))
all_meals = [
Meal(
name="pølse i skive",
foods={foods["grovt brød"]: 40, foods["coop bratwurst"]: 80},
),
Meal(
name="pølse, skive og 2 egg",
foods={
foods["grovt brød"]: 40,
foods["egg"]: 120,
foods["coop bratwurst"]: 80,
},
),
Meal(
name="burger, skive og egg",
foods={foods["grovt brød"]: 40, foods["egg"]: 60, foods["burger"]: 80},
),
Meal(
name="1 yoghurt med ct.cheese",
foods={foods["yoghurt"]: 150, foods["cottage cheese"]: 100},
),
Meal(
name="2 yoghurt med musli",
foods={foods["yoghurt"]: 300, foods["musli"]: 100},
),
Meal(
name="2 scoops whey", foods={foods["PF whey"]: 45, foods["lettmelk"]: 300}
),
Meal(
name="melk og musli",
foods={foods["lettmelk"]: 150, foods["musli"]: 100},
discrete=False,
),
]
meals = {meal.name: meal for meal in all_meals}
return foods, meals
def main():
"""Main function: the user defines parameters and gets a high level overview."""
random.seed(123)
foods, meals = get_data()
# =============================================================================
# USER DEFINED HYPERPARAMETERS ARE PRIMARILY SET HERE
# =============================================================================
# User defined constraints for types of meals, e.g. {"breakfast":1, "dinner":1}
# Some Meal-instances must have Meal(..., type="dinner"). This can be used to
# enforce one dinner every day, or one liquid meal every day, etc.
type_constraints = dict()
num_days = 7 # Total number of days for the meal program
num_meals = 4 # Number of meals per day
dietary_constraints = {"kcal": (1800, 2000), "protein": (120, 200)}
# Algorithm hyperparameters
num_samples = 1000 # Number of completely random samples to draw
top_samples_to_keep = 100 # Top samples to retain
# =============================================================================
# SOLVE THE PROBLEM USING OPTIMIZATION AND HEURISTICS
# =============================================================================
# Sample different possible days
meal_objects = list(meals.values())
optimized_results, obj_func_values = list(), list()
for _ in range(num_samples):
# Sample a possible day (i.e. a collection of meals)
day = sample_single_day(
meal_objects, num_meals=num_meals, type_constraints=type_constraints
)
# Optimize how much of each meal should go into the day to meet dietary contraints
result, obj_func_value = optimize_day(
day,
dietary_constraints=dietary_constraints,
price_epsilon=0.001,
low_range=(1, 4),
)
# Store values
optimized_results.append(result)
obj_func_values.append(obj_func_value)
# Sort the days by their "penalty", a weighted sum of terms
sorted_results = sort_by_penality(
days=optimized_results, obj_func_values=obj_func_values
)
sorted_results = sorted_results[:top_samples_to_keep]
weights = list(reversed(range(1, len(sorted_results) + 1)))
sum_weights = sum(weights)
weights = [w / sum_weights for w in weights]
# Choose among the best days, weighted by how good they are
results_solved = random.choices(sorted_results, weights=weights, k=num_days)
print_results(results_solved, verbose=True)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment