The Food Triangle Quantified

In a recent cookbook, the Healthspan Solution, the authors propose a new way to categorize food: the food triangle.

First of all, only whole food ingredients are considered. Then, rather than grouping foods into one of the macronutrients (fat, protein and carbohydrates), they are sorted by energy density on the one hand (top / down), and by source (animal on the left, plants on the right) on the other:

Food Triangle

The recommendation is to eat on the "right side", in particular near the top, in order to get sufficient micronutrients while keeping energy intake in check.

On the other hand, many traditional recipes choose foods from both sides near the bottom, yielding the highest energy density while promoting fat storage due to oxidative priority.

I like this scheme, but I wondered whether most foods could really be assigned one of these sides easily. So this post is about visualizing foods by their energy density and relative source of energy (fat vs carbohydrates).

In [1]:
# Activate custom environment ad load packages
using Pkg
Pkg.activate("../../environments/food-triangle/");

using CSV
using DataFrames
using VegaLite
 Activating environment at `~/src/rschwarz.github.io/environments/food-triangle/Project.toml`
In [2]:
# Load nutrition data
foods = DataFrame(CSV.File("../files/foods.csv"))
@show size(foods)
first(foods, 3)
size(foods) = (142, 7)
Out[2]:

3 rows × 7 columns

Source Category Food Energy Protein Fat Carbohydrates
String String String Float64 Float64 Float64 Float64
1 Plant Grain Oats 272.0 2.4 1.6 10.1
2 Plant Grain Barley 388.0 2.9 0.9 18.0
3 Plant Grain Buckwheat 304.0 2.5 0.6 14.1

Energy

Our nutrition data lists 142 different foods from different categories with energy (in kJ per 100g) as well as relative weight of the macronutrients.

Since fat has a much higher energy density, we will replace the weight values by the energy contribution per macronutrient.

In [3]:
foods.Fat = 37.0 .* foods.Fat
foods.Carbohydrates = 16.0 .* foods.Carbohydrates
foods.Protein = 17.0 .* foods.Protein
first(foods, 3)
Out[3]:

3 rows × 7 columns

Source Category Food Energy Protein Fat Carbohydrates
String String String Float64 Float64 Float64 Float64
1 Plant Grain Oats 272.0 40.8 59.2 161.6
2 Plant Grain Barley 388.0 49.3 33.3 288.0
3 Plant Grain Buckwheat 304.0 42.5 22.2 225.6

Food Triangle

Our quantified food triangle will be implemented as a scatter plot. For the vertical axis, we use the total energy density (low energy on the top). For the horizontal axis, we take the difference of energy contribution from carbohydrates and fat. So, foods rich in fat tend to be near the left, while foods rich in carbohydrates are on the right. Foods in the middle could have either both fat and carbohydrates, or neither, being rich in protein instead.

In [4]:
foods.fat_cho = foods.Carbohydrates - foods.Fat
first(foods, 3)
Out[4]:

3 rows × 8 columns

Source Category Food Energy Protein Fat Carbohydrates fat_cho
String String String Float64 Float64 Float64 Float64 Float64
1 Plant Grain Oats 272.0 40.8 59.2 161.6 102.4
2 Plant Grain Barley 388.0 49.3 33.3 288.0 254.7
3 Plant Grain Buckwheat 304.0 42.5 22.2 225.6 203.4
In [5]:
foods |> @vlplot(height=400, width=600, :point,
                 x=:fat_cho, y={:Energy, sort="descending"}, color=:Source)
Out[5]:
-3,000-2,800-2,600-2,400-2,200-2,000-1,800-1,600-1,400-1,200-1,000-800-600-400-20002004006008001,0001,2001,400fat_cho05001,0001,5002,0002,5003,0003,500EnergyAnimalPlantSource

To my surprise, most foods do actually line up on either side of the triangle and few are found in the center.

We can also see that all animal foods are on the left, while most plant foods are on the right.

Finally, it's quite obvious that the left side goes a lot "deeper" than the right. Foods rich in fat have a much higher potential for high energy density!

Plant-based Food

Let's go into more detail, looking only at plant foods now, split into more categories.

In [6]:
filter(row -> row.Source == "Plant", foods) |>
    @vlplot(height=400, width=600, :point,
            x=:fat_cho, y={:Energy, sort="descending"}, color=:Category)
Out[6]:
-3,000-2,800-2,600-2,400-2,200-2,000-1,800-1,600-1,400-1,200-1,000-800-600-400-20002004006008001,0001,2001,400fat_cho02004006008001,0001,2001,4001,6001,8002,0002,2002,4002,6002,8003,000EnergyFruitGrainLegumeNuts_SeedsVegetableCategory

We can see that the different categories form nice clusters in the this view.

Most items on the left side seem to be either nuts or seeds. Let's zoom in on these.

In [7]:
filter(row -> row.Category == ("Nuts_Seeds"), foods) |>
    @vlplot(height=400, width=600, :text, text=:Food,
            x=:fat_cho, y={:Energy, sort="descending", scale={zero=false}}, color=:Category)
Out[7]:
-2,800-2,600-2,400-2,200-2,000-1,800-1,600-1,400-1,200-1,000-800-600-400-2000fat_cho1,4001,6001,8002,0002,2002,4002,6002,8003,000EnergyAlmondCashewHazelnutMacadamiaPeanutPecanPinePistachioWalnutChiaFlaxseedPumpkinSesameSunflowerNuts_SeedsCategory

When we remove nuts and seeds, we can get a better picture of the foods near the top of the triangle.

In [8]:
filter(row -> row.Source == "Plant" && row.Category != "Nuts_Seeds", foods) |>
 @vlplot(height=400, width=600, :point,
         x=:fat_cho, y={:Energy, sort="descending"}, color=:Category)
Out[8]:
-800-700-600-500-400-300-200-10001002003004005006007008009001,0001,1001,200fat_cho01002003004005006007008009001,0001,1001,200EnergyFruitGrainLegumeVegetableCategory

There are still some outliers of higher energy density:

In [9]:
filter(row -> row.Source == "Plant" && row.Category != "Nuts_Seeds"
              && row.Energy > 400.0, foods) |>
 @vlplot(height=400, width=600, :text, text=:Food,
         x=:fat_cho, y={:Energy, sort="descending",scale={zero=false}}, color=:Category)
Out[9]:
-800-700-600-500-400-300-200-10001002003004005006007008009001,0001,1001,200fat_cho4005006007008009001,0001,1001,200EnergyPolentaRyeBrown_RiceDried_DateDried_FigSoy_BeanChickpeaGreen_LentilRed_LentilAvocadoOliveFruitGrainLegumeVegetableCategory

OK, so there are two vegetables high in fat (avocado and olive) and also two types of dried fruits, which might not be considered proper whole foods, I guess.

The other items are either grains or legumes, but not fruits or vegetables.

In [10]:
filter(row -> row.Source == "Plant" && row.Category != "Nuts_Seeds"
              && row.Energy < 120.0, foods) |>
 @vlplot(height=400, width=600, :text, text=:Food,
         x=:fat_cho, y={:Energy, sort="descending",scale={zero=false}}, color=:Category)
Out[10]:
-20-15-10-505101520253035404550556065707580fat_cho30405060708090100110120EnergyGrapefruitLemonWatermelonStrawberryAsparagusBok_ChoyBroccoliBrussels_SproutChinese_CabbageRed_CabbageWhite_CabbageCarrotCauliflowerCeleriacCeleryChicoryCucumberEggplantEndiveFennelKaleLeekLettuceMushroomOkraOnionRocketRed_RadishSpinachTomatoTurnipZucchiniFruitVegetableCategory

Near the top we have mostly vegetables and some fruits. The foods lowest in energy density are either cabbages, leafy greens or watery vegetables.

In [11]:
filter(row -> row.Category == "Vegetable" &&  row.Fat < 200, foods) |>
    @vlplot(height=400, width=600, :text, text=:Food,
            x=:fat_cho, y={:Energy, sort="descending", scale={zero=false}}, color=:Category)
Out[11]:
-20020406080100120140160180200220240fat_cho20406080100120140160180200220240260280EnergyAsparagusBok_ChoyBroccoliBrussels_SproutChinese_CabbageRed_CabbageWhite_CabbageCarrotCauliflowerCeleriacCeleryChicoryCucumberEggplantEndiveFennelKaleKohlrabiLeekLettuceMushroomOkraOnionParsnipPeaPotatoButternut_PumpkinRocketRed_RadishSpinachSweet_PotatoTomatoTurnipZucchiniVegetableCategory

Looking at all vegetables now, we can see that the starchy vegetables are near the bottom. Should peas actually be with the legumes?

In [12]:
filter(row -> row.Category in ("Grain", "Legume"), foods) |>
    @vlplot(height=400, width=600, :text, text=:Food,
            x=:fat_cho, y={:Energy, sort="descending", scale={zero=false}}, color=:Category)
Out[12]:
-300-250-200-150-100-50050100150200250300350400450500fat_cho250300350400450500550600650EnergyOatsBarleyBuckwheatBulgurPolentaQuinoaRyeBrown_RiceCannellini_BeanLima_BeanKidney_BeanSoy_BeanChickpeaGreen_LentilRed_LentilGrainLegumeCategory

All grains and legumes are higher in density than vegetables. It should be noted here that the numbers apply to cooked, not fresh or dried foods. So maybe oats are only at the top here, because they are typically cooked with more water compared to other grains?

Soy beans are quite the outlier here, storing most of their energy as fat.

Animal-based Foods

Let's now turn to animal foods, all located on the left side.

In [13]:
filter(row -> row.Source == "Animal", foods) |>
 @vlplot(height=400, width=600, :point,
         x=:fat_cho, y={:Energy, sort="descending",scale={zero=false}}, color=:Category)
Out[13]:
-3,000-2,800-2,600-2,400-2,200-2,000-1,800-1,600-1,400-1,200-1,000-800-600-400-2000200400fat_cho2004006008001,0001,2001,4001,6001,8002,0002,2002,4002,6002,8003,0003,200EnergyDairyEggFishMeatOrganShellfishCategory

There is one outlier with very high energy content:

In [14]:
first(sort(foods, :Energy, rev=true), 3)
Out[14]:

3 rows × 8 columns

Source Category Food Energy Protein Fat Carbohydrates fat_cho
String String String Float64 Float64 Float64 Float64 Float64
1 Animal Meat Pork_Belly 3073.0 229.5 2841.6 0.0 -2841.6
2 Plant Nuts_Seeds Macadamia 2966.0 156.4 2738.0 72.0 -2666.0
3 Plant Nuts_Seeds Pecan 2906.0 166.6 2660.3 78.4 -2581.9

It's pork belly, with lots of fat. But actually not much more than some nuts.

In [15]:
filter(row -> row.Source == "Animal" && row.Energy < 450.0, foods) |>
 @vlplot(height=400, width=600, :text, text=:Food,
         x=:fat_cho, y={:Energy, sort="descending",scale={zero=false}}, color=:Category)
Out[15]:
-80-75-70-65-60-55-50-45-40-35-30-25-20-15-10-50510fat_cho280300320340360380400420440460EnergyQuarkMilkYoghurtCodLobsterMusselOctopusOysterScallopSquidDairyFishShellfishCategory

Near the top, with lower energy density (and high water content), we can find dairy products and shellfish.

Dairy also has significant carbohydrates, but the scallops and mussels seem to store almost all of their energy as protein.

In [16]:
filter(row -> row.Source == "Animal" && 700.0 < row.Energy < 2000.0, foods) |>
 @vlplot(height=400, width=600, :text, text=:Food,
         x=:fat_cho, y={:Energy, sort="descending",scale={zero=false}}, color=:Category)
Out[16]:
-1,300-1,200-1,100-1,000-900-800-700-600-500-400-300-200-1000fat_cho7008009001,0001,1001,2001,3001,4001,5001,6001,7001,8001,9002,000EnergyCheddarEdamFetaGoudaHaloumiMozzarellaParmesanPecorinoBeef_SteakMinced_BeefLamb_ChopLamb_SteakMinced_PorkChicken_ThighChicken_WingChickenTurkeyBeef_LiverLamb_HeartLamb_LiverVeal_HeartVeal_KidneyVeal_LiverMackerelSalmonTroutDairyFishMeatOrganCategory

Leaving out the pork belly, the highest energy foods are cheeses. The fishes, meats and organ are rich in fat as well as protein.

Still, I would have expected liver to be farther down. Also, I'm surprised by the wide spread of fat content among different types of fish:

In [17]:
filter(row -> row.Category == "Fish", foods) |>
 @vlplot(height=400, width=600, :text, text=:Food,
         x=:fat_cho, y={:Energy, sort="descending",scale={zero=false}}, color=:Category)
Out[17]:
-800-750-700-650-600-550-500-450-400-350-300-250-200-150-100-500fat_cho4005006007008009001,0001,1001,2001,300EnergyCodMackerelSalmonSardineSnapperTilapiaTroutTunaFishCategory

Overall, I was positively surprised by the accuracy of the food triangle schema when looking at the actual numbers.

Energy (Kernel) Density

As a final analysis, let's compare the energy density of plants and animals (by count) in a simpler, one-dimensional chart:

In [18]:
foods |> @vlplot(height=400, width=600, :line,
    transform=[{density="Energy", bandwidth=200, groupby=["Source"]}],
    x={"value:q", title="Energy"}, y="density:q", color=:Source)
Out[18]:
02004006008001,0001,2001,4001,6001,8002,0002,2002,4002,6002,8003,0003,2003,400Energy0.000000.000100.000200.000300.000400.000500.000600.000700.000800.000900.001000.001100.001200.001300.00140densityAnimalPlantSource