For a long time I've been interested in the general idea of scaling recipes. For example, given a recipe, write a program that will scale that recipe up or down. Or convert the units to some other unit system.
Since I've been studying python and linear algebra lately, I thought I would approach the problem from that lense. For this analysis we'll define a recipe as set of ingredients with amounts in a specific unit. For example take this recipe for Sour Cream Twists...
If we wanted to express this recipe as a linear equation, we could say that each line of the recipe is a coefficient and variable in the recipe expression. It would look like this..
And we can represent that as a set of linear equations with a numpy diagonal matrix.
import numpy as np
ingredients = [
"Cups Flour",
"Teaspoons Salt",
"Teaspoons Cardamom",
"Sticks Cold Butter",
"Packs Yeast",
"Teaspoons Almond Extract",
"Tablespoons Sugar",
"Cups Sour Cream",
"Whole Eggs",
"Egg Yokes",
"Cups Sugar"
]
ingredients_no_unit = [
"Flour",
"Salt",
"Cardamom",
"Sticks Cold Butter",
"Packs Yeast",
"Almond Extract",
"Sugar",
"Sour Cream",
"Whole Eggs",
"Egg Yokes",
"Sugar"
]
recipe = np.diag([3.5, 1, .5, 2, 2, .5, 1, .75, 2, 1])
recipe
array([[3.5 , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. ], [0. , 1. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. ], [0. , 0. , 0.5 , 0. , 0. , 0. , 0. , 0. , 0. , 0. ], [0. , 0. , 0. , 2. , 0. , 0. , 0. , 0. , 0. , 0. ], [0. , 0. , 0. , 0. , 2. , 0. , 0. , 0. , 0. , 0. ], [0. , 0. , 0. , 0. , 0. , 0.5 , 0. , 0. , 0. , 0. ], [0. , 0. , 0. , 0. , 0. , 0. , 1. , 0. , 0. , 0. ], [0. , 0. , 0. , 0. , 0. , 0. , 0. , 0.75, 0. , 0. ], [0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 2. , 0. ], [0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 1. ]])
If we think about the recipe abstractly, what we have here is a 10 dimensional space with a single vector pointed along each dimension's axis. The magnitude of the vectors expresses the amount of the ingredient to use.
That's interesting. That also means that the same recipe could also be expressed like this where one of vectors points along two axis (note how I removed the last row and added it to the second row)...
recipe2 = np.array([
[3.5 , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. ],
[0. , 1. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 1. ],
[0. , 0. , 0.5 , 0. , 0. , 0. , 0. , 0. , 0. , 0. ],
[0. , 0. , 0. , 2. , 0. , 0. , 0. , 0. , 0. , 0. ],
[0. , 0. , 0. , 0. , 2. , 0. , 0. , 0. , 0. , 0. ],
[0. , 0. , 0. , 0. , 0. , 0.5 , 0. , 0. , 0. , 0. ],
[0. , 0. , 0. , 0. , 0. , 0. , 1. , 0. , 0. , 0. ],
[0. , 0. , 0. , 0. , 0. , 0. , 0. , 0.75, 0. , 0. ],
[0. , 0. , 0. , 0. , 0. , 0. , 0. , 0. , 2. , 0. ]])
We could just as well express the recipe as a single vector.
np.array([3.5, 1, .5, 2, 2, .5, 1, .75, 2, 1])
print()
...which highlights that a recipe is a vector in our ingredient space.
Let's return to our numpy diagonal matrix - in that form, it will be easiest for us to scale the recipe and then get the final amounts. With the diagonal matrix, we can multiply it by some number to scale the recipe up or down. And we can use numpy.diagonal to extract the amounts from the matrix. This makes recipe scaling trivial...
def show_recipe(ingredients, amounts):
for combo in zip(amounts, ingredients):
print(str(combo[0]) + " " + combo[1])
def scale_recipe(recipe_matrix: "ndarray", amount: "int"):
scaled_recipe = recipe_matrix * amount
amounts = np.diagonal(scaled_recipe)
return amounts
def scale_and_show_recipe(ingredients: "array", recipe_matrix: "ndarray", amount: "int"):
amounts = scale_recipe(recipe_matrix, amount)
show_recipe(ingredients, amounts)
print('Scaled By 2:')
scale_and_show_recipe(ingredients, recipe, 2)
print('----\n')
print('Scaled By 1/2:')
scale_and_show_recipe(ingredients, recipe, .5)
Scaled By 2: 7.0 Cups Flour 2.0 Teaspoons Salt 1.0 Teaspoons Cardamom 4.0 Sticks Cold Butter 4.0 Packs Yeast 1.0 Teaspoons Almond Extract 2.0 Tablespoons Sugar 1.5 Cups Sour Cream 4.0 Whole Eggs 2.0 Egg Yokes ---- Scaled By 1/2: 1.75 Cups Flour 0.5 Teaspoons Salt 0.25 Teaspoons Cardamom 1.0 Sticks Cold Butter 1.0 Packs Yeast 0.25 Teaspoons Almond Extract 0.5 Tablespoons Sugar 0.375 Cups Sour Cream 1.0 Whole Eggs 0.5 Egg Yokes
Cool! But look at the halved version of the recipe. .375 Cups of Sour of Cream? That's not very intuitive. Why? Because most recipes like to use units that correspond to the cook's volumetric measuring tools. In the US, most cooks have 1 Cup, 3/4 Cup, 1/2 Cup, 1/4 Cup, and 1/8 cup measuring cups. Recipe designers usually try and fine a scale that doesn't involve too many different measuring cups, but when we're scaling recipe, it's inevitable that we'll have some unintuitive amounts.
One way we can solve this problem is to display the amount as a fraction divisible by the smallest expected unit, for example 1/8 for Cups. We can find the closest fraction by dividing the amount by 1/8, rounding to the nearest whole, and then multiplying that amount by 1/8. Whatever remains is either the excess or the deficit from the original amount - that amount can either be ignored, or expressed as a smaller unit like a Tablespoon. Here's some code that can do that...
import math
from fractions import Fraction
def amount_to_cups(amount):
parts = math.modf(amount)
whole = parts[1]
fraction = parts[0]
nearest_whole = round(fraction / (1/8)) * 1/8
difference = fraction - nearest_whole
diff_type = ''
if (difference > 0):
diff_type = 'deficit'
elif (difference < 0):
diff_type = 'excess'
else:
diff_type = 'equal'
return {
"whole": whole,
"fraction": Fraction(nearest_whole),
"difference": difference,
"difference_type": diff_type
}
print("1.375 Cups is...\n", amount_to_cups(1.375))
print("\n.375 Cups is...\n", amount_to_cups(.373))
print("\n.375 Cups is...\n", amount_to_cups(.376))
1.375 Cups is... {'whole': 1.0, 'fraction': Fraction(3, 8), 'difference': 0.0, 'difference_type': 'equal'} .375 Cups is... {'whole': 0.0, 'fraction': Fraction(3, 8), 'difference': -0.0020000000000000018, 'difference_type': 'excess'} .375 Cups is... {'whole': 0.0, 'fraction': Fraction(3, 8), 'difference': 0.0010000000000000009, 'difference_type': 'deficit'}
And I suppose that works if we're looking specifically at ingredients that are measured in Cups and we want to do manual algorithmic computation. But what if we go back to that idea of a recipe being a vector in the ingredient space. What if our ingredient space is made up of "some ingredient measured in full cups", and "some ingredient measured in 1/2 of a cup", etc... That relationship can be expressed with this expression...
1a + .75b + .5c + .25d + .125e
Where a, b, c, d, and e are the amounts of that ingredient measured with that unit.
In that case, then our previous example recipe would have this unique vector for the cups of sour cream.
# 1 Cup, 3/4 Cups, 1/2 Cups, 1/4 Cup, 1/8 Cup
[0.375, 0, 0, 0, 0]
[0.375, 0, 0, 0, 0]
But we could just as well write that as the following vectors.
# 1 Cup, 3/4 Cups, 1/2 Cups, 1/4 Cup, 1/8 Cup
[0, 0, 0, 0, 3]
[0, 0, 0, 1, 2]
0.125
But how can we calculate those solutions? We would have to find acceptable solutions to this equation...
1a = .75b + .5c + .25d + .125e
Where b, c, d, and e are all integers. But there are many solutions to that equation that are not appropriate in the context of cooking. In cooking, a general rule is that you should express the amount of something using either the next biggest measuring unit, or use the same unit multiple times if it's a small number of scoops. For example, 2.375 could either be expressed as "2.0 Cup + 3/8 Cup" or "2.0 Cup + 1/4 Cup + 1/8 Cup." How you communicate that is partly a matter of taste.
We can find the closest combination of units by running through a recursive modulo algorithm, where we use the next acceptable measuring unit to express our instruction.
from fractions import Fraction
import math
def friendly_line(amounts: "array", unit: "string", show_unit = True):
parts = []
for amount in amounts:
text = ''
if (amount['modulo'] == 1):
text += str(amount['amount'])
if (show_unit is True):
text += ' ' + unit
elif (amount['modulo'] == 0.0625 and unit == 'cup'):
text += str(math.floor(amount['amount'] / 0.0625))
if (show_unit is True):
text += ' Tablespoon'
elif (amount['modulo'] < .5 and unit == 'tablespoon'):
text += str(math.floor(amount['amount'] / .5))
if (show_unit is True):
text += ' teaspoon'
else:
text += str(Fraction(amount['modulo']))
if (show_unit is True):
text += ' ' + unit
parts.append(text)
return " + ".join(parts)
def to_cooking_units(amount, steps, instructions):
if (len(steps) == 0):
instructions.append({ "modulo": 1, "amount": amount })
return instructions
if (steps[0] == 'any'):
instructions.append({ "modulo": 1, "amount": amount })
return instructions
modulo = steps[0]
remainder = amount % modulo;
whole = amount - remainder
if (remainder == 0):
instructions.append({ "modulo": modulo, "amount": amount })
return instructions;
else:
if (whole != 0):
instructions.append({ "modulo": modulo, "amount": whole })
return to_cooking_units(remainder, steps[1:], instructions)
CUP_UNITS_STANDARD = [1, .75, .5, .25, .125, 0.0625]
CUP_UNITS_OTHER = [1, .75, .5, .375, .25, .125, 0.0625]
print(friendly_line(
to_cooking_units(2.376, CUP_UNITS_STANDARD, []),
"cup"
))
print(friendly_line(
to_cooking_units(2.45, CUP_UNITS_OTHER, []),
"cup",
show_unit = False
))
2.0 cup + 1/4 cup + 1/8 cup + 0.0009999999999998899 cup 2.0 + 3/8 + 1 + 0.012500000000000178
With the code we've written so far, we can now express our scaled recipe in a more intuitive way.
def get_unit_progression_and_unit(name: str):
if (name.lower().find('cup') != -1):
return { 'unit': 'cup', 'progression': [1, .75, .5, .375, .25, .125, 0.0625] }
elif (name.lower().find('tablespoon') != -1):
return { 'unit': "tablespoon", 'progression': [1, .5] }
elif (name.lower().find('teaspoon') != -1):
return { 'unit': "teaspoon", 'progression': [1, .5, .25, .125, .0625] }
else:
return { 'unit': "", 'progression': ['any'] }
def show_nicer_recipe(ingredients, amounts):
recipe_units = map(get_unit_progression_and_unit, ingredients)
for combo in zip(amounts, ingredients_no_unit, recipe_units):
amount = combo[0]
ingredient_name = combo[1]
unit_and_progression = combo[2]
print(friendly_line(
to_cooking_units(amount, unit_and_progression["progression"], []),
unit_and_progression["unit"]
) + " " + ingredient_name)
def scale_and_show_nicer_recipe(ingredients: "array", recipe_matrix: "ndarray", amount: "int"):
amounts = scale_recipe(recipe_matrix, amount)
show_nicer_recipe(ingredients, amounts)
print('\n---Halved With Nice Units\n')
scale_and_show_nicer_recipe(ingredients, recipe, .5)
print('\n---Halved With Decimal Units\n')
scale_and_show_recipe(ingredients, recipe, .5)
print('\n---Original\n')
scale_and_show_recipe(ingredients, recipe, 1)
# print(list(units))
---Halved With Nice Units 1.0 cup + 3/4 cup Flour 1/2 teaspoon Salt 1/4 teaspoon Cardamom 1.0 Sticks Cold Butter 1.0 Packs Yeast 1/4 teaspoon Almond Extract 1/2 tablespoon Sugar 3/8 cup Sour Cream 1.0 Whole Eggs 0.5 Egg Yokes ---Halved With Decimal Units 1.75 Cups Flour 0.5 Teaspoons Salt 0.25 Teaspoons Cardamom 1.0 Sticks Cold Butter 1.0 Packs Yeast 0.25 Teaspoons Almond Extract 0.5 Tablespoons Sugar 0.375 Cups Sour Cream 1.0 Whole Eggs 0.5 Egg Yokes ---Original 3.5 Cups Flour 1.0 Teaspoons Salt 0.5 Teaspoons Cardamom 2.0 Sticks Cold Butter 2.0 Packs Yeast 0.5 Teaspoons Almond Extract 1.0 Tablespoons Sugar 0.75 Cups Sour Cream 2.0 Whole Eggs 1.0 Egg Yokes
For now, we'll leave our analysis at that.