What’s Really in Our Food?

Exploring USDA Foundation Foods Nutrient Data

Author

Grigory Balasanov

Published

November 12, 2026

What’s Really in Our Food?

Exploring USDA Foundation Foods Nutrient Data

Carrots of many colors, bred by USDA researchers. Photo by Stephen Ausmus, USDA Agricultural Research Service (public domain), via Wikimedia Commons.

Introduction

Every day we make choices about what to eat, often based on labels, habits, or advice from friends and the media. But how do basic foods actually compare to each other? Why are some foods packed with calories while others barely have any? And are animal foods really the only good source of protein?

Why I chose this topic. I cook most of my meals at home, and many of the dishes I grew up with in Armenia and Georgia are built on basic ingredients such as beans, lentils, grains, cheese, vegetables, and meat. I wanted to see, using real laboratory data instead of opinions, how these everyday ingredients compare in calories, protein, and fat. This project also gave me a chance to work with a real government database, which is much messier than a ready-made class dataset.

The data. I used the Foundation Foods dataset from USDA FoodData Central (April 2026 release), published by the U.S. Department of Agriculture. Foundation Foods contains lab-measured nutrient values for common, minimally processed foods such as vegetables, fruits, grains, legumes, dairy, meat, and fish. All nutrient amounts are reported per 100 grams of food, which makes fair comparisons possible.

Unlike most datasets, this one is not a single table. It is a relational database made of several CSV files linked by shared ID columns. I used four of them:

  • food.csv: one row per food, with its ID (fdc_id), name, data type, and food category ID
  • food_nutrient.csv: the nutrient amounts, stored in “long” format with one row per food per nutrient
  • food_category.csv: the names of the food categories
  • market_acquisition.csv: details about where USDA purchased the food samples, including the store’s state

Variables. My final cleaned dataset has 469 foods (observations) and 9 variables: 3 categorical and 6 quantitative.

Categorical variables

  • description: the name of the food (for example, “Broccoli, raw”)
  • food_group: the food’s group (Vegetables, Fruits, Legumes, Beef, etc.)
  • source: whether the food comes from animals or from plants/other sources (a variable I created)

Quantitative variables (all per 100 g)

  • calories: energy (kcal)
  • water: water content (g)
  • protein: protein (g)
  • fat: total fat (g)
  • carbs: carbohydrates (g)
  • sodium: sodium (mg)

For the map, I also used the categorical variable store_state from market_acquisition.csv.

How I cleaned the data. First, I used filter() to keep only rows where data_type is “foundation_food”. This inclusion criterion reduced food.csv from about 88,000 rows, most of them individual lab samples, to 469 foods. Next, I filtered food_nutrient.csv to six nutrients, named them with case_when(), and reshaped them from long to wide format with pivot_wider(), so that each food is one row. Then I combined the tables with two left_join() commands using the shared ID columns, and used mutate() to create the new source and food_group variables. Finally, I checked for missing values. Not every food was tested for every nutrient, so instead of deleting whole rows, I removed missing values only for the specific variables each plot or model needed. For the map, I used filter() to keep only valid U.S. state codes, because the state column also contained regions, other countries, and typos.

Research questions

  1. Which food groups provide the most protein?
  2. How does water content relate to the calories in a food?
  3. Can water content, fat content, and food source (animal vs. plant) predict how much protein a food contains?
  4. Where did USDA buy the food samples used in this dataset?

Load Libraries and Data

# load the libraries
library(tidyverse)    # data cleaning (dplyr, tidyr) and static plots (ggplot2)
library(highcharter)  # interactive plots and the map
library(GGally)       # ggpairs correlation plot

# load the dataset files with readr::read_csv()
food <- read_csv("../datasets/FoodData/food.csv")
food_nutrient <- read_csv("../datasets/FoodData/food_nutrient.csv")
food_category <- read_csv("../datasets/FoodData/food_category.csv")
market <- read_csv("../datasets/FoodData/market_acquisition.csv")

I use tidyverse for cleaning and static plots, highcharter for interactive charts and the map, where readers can hover over the data to see details, and GGally for a correlation matrix plot.

Cleaning and Joining the Data

Step 1: Keep only the Foundation Foods

# inclusion criterion: keep only the main Foundation Foods, not the lab sample rows
foods <- food |>
  filter(data_type == "foundation_food") |>
  select(fdc_id, description, food_category_id)

# check how many foods are left
nrow(foods)
[1] 469

The food.csv file has about 88,000 rows, but most of them are individual lab samples used to calculate the final values. Filtering by data_type keeps only the 469 main Foundation Foods, well under the 800-observation limit.

Step 2: Reshape the nutrients into columns

# keep only the six nutrients needed, give them names, and turn them into columns
nutrients <- food_nutrient |>
  filter(fdc_id %in% foods$fdc_id,
         nutrient_id %in% c(2047, 1051, 1003, 1004, 1005, 1093)) |>
  mutate(nutrient = case_when(nutrient_id == 2047 ~ "calories",
                              nutrient_id == 1051 ~ "water",
                              nutrient_id == 1003 ~ "protein",
                              nutrient_id == 1004 ~ "fat",
                              nutrient_id == 1005 ~ "carbs",
                              nutrient_id == 1093 ~ "sodium")) |>
  select(fdc_id, nutrient, amount) |>
  pivot_wider(names_from = nutrient, values_from = amount)

# preview the reshaped nutrient table
head(nutrients)
# A tibble: 6 × 7
  fdc_id water protein sodium   fat carbs calories
   <dbl> <dbl>   <dbl>  <dbl> <dbl> <dbl>    <dbl>
1 321358 58.7     7.35    438 17.1  14.9       243
2 321359 89.1     3.35     39  1.9   4.91       NA
3 321360 92.5     0.83      6  0.63  5.51       31
4 321505  0.42   NA     38700 NA    NA          NA
5 321611 93.6     1.04    282  0.39  4.11       24
6 321900 90       2.57     36  0.34  6.29       NA

In food_nutrient.csv, every nutrient for every food is its own row, so one food has dozens of rows. I kept only the six nutrients I need, named them using their ID numbers from the USDA nutrient list, and used pivot_wider() to turn them into columns, so each food becomes a single row.

Step 3: Join the tables and create new variables

# rename the category columns so they match the food table
categories <- food_category |>
  select(food_category_id = id, category = description)

# join the tables, then create the source and food_group variables
foods_clean <- foods |>
  left_join(categories, by = "food_category_id") |>
  left_join(nutrients, by = "fdc_id") |>
  mutate(
    source = ifelse(category %in% c("Dairy and Egg Products", "Beef Products",
                                    "Pork Products", "Poultry Products",
                                    "Finfish and Shellfish Products",
                                    "Sausages and Luncheon Meats",
                                    "Lamb, Veal, and Game Products"),
                    "Animal", "Plant/Other"),
    food_group = case_when(category == "Vegetables and Vegetable Products" ~ "Vegetables",
                           category == "Fruits and Fruit Juices" ~ "Fruits",
                           category == "Legumes and Legume Products" ~ "Legumes",
                           category == "Dairy and Egg Products" ~ "Dairy & Eggs",
                           category == "Cereal Grains and Pasta" ~ "Cereals & Pasta",
                           category == "Finfish and Shellfish Products" ~ "Fish & Shellfish",
                           category == "Beef Products" ~ "Beef",
                           category == "Nut and Seed Products" ~ "Nuts & Seeds",
                           category == "Sausages and Luncheon Meats" ~ "Sausages & Deli Meats",
                           category == "Poultry Products" ~ "Poultry",
                           TRUE ~ "Other")
  ) |>
  select(description, food_group, source, calories, water, protein, fat, carbs, sodium)

# preview the final clean dataset
head(foods_clean)
# A tibble: 6 × 9
  description        food_group source calories water protein   fat carbs sodium
  <chr>              <chr>      <chr>     <dbl> <dbl>   <dbl> <dbl> <dbl>  <dbl>
1 Hummus, commercial Legumes    Plant…      243 58.7     7.35 17.1  14.9     438
2 Milk, reduced fat… Dairy & E… Animal       NA 89.1     3.35  1.9   4.91     39
3 Tomatoes, grape, … Vegetables Plant…       31 92.5     0.83  0.63  5.51      6
4 Salt, table, iodi… Other      Plant…       NA  0.42   NA    NA    NA     38700
5 Beans, snap, gree… Vegetables Plant…       24 93.6     1.04  0.39  4.11    282
6 Broccoli, raw      Vegetables Plant…       NA 90       2.57  0.34  6.29     36

I used left_join() twice: first to attach the category names, then to attach the nutrient columns. Next I created two new variables with mutate(). source groups the animal-based categories together, and food_group gives the long USDA category names shorter labels that fit better in charts. Smaller categories, such as pork, fats and oils, baked products, and spices, are combined into “Other.”

Step 4: Check for missing values

# count missing values in each column
colSums(is.na(foods_clean))
description  food_group      source    calories       water     protein 
          0           0           0         122           9          44 
        fat       carbs      sodium 
         56          92          66 

Not every food was tested for every nutrient. For example, calories are missing for 122 foods. Instead of deleting these foods from the whole dataset, I remove missing values only when a specific plot or model needs that variable, so each analysis uses as many foods as possible.

Exploring the Variables

Summary of the quantitative variables

# summary statistics for all variables
summary(foods_clean)
    description      food_group        source       calories     
 Length   :469   Length   :469   Length   :469   Min.   :  0.00  
 N.unique :401   N.unique : 11   N.unique :  2   1st Qu.: 44.09  
 N.blank  :  0   N.blank  :  0   N.blank  :  0   Median :101.87  
 Min.nchar:  9   Min.nchar:  4   Min.nchar:  6   Mean   :184.69  
 Max.nchar:106   Max.nchar: 21   Max.nchar: 11   3rd Qu.:347.61  
                                                 Max.   :895.00  
                                                 NAs    :122     
     water          protein           fat              carbs       
 Min.   : 0.00   Min.   : 0.00   Min.   : 0.0000   Min.   :-0.705  
 1st Qu.:12.29   1st Qu.: 1.06   1st Qu.: 0.2942   1st Qu.: 2.440  
 Median :71.78   Median : 7.35   Median : 1.2660   Median : 7.137  
 Mean   :57.58   Mean   :10.57   Mean   : 7.9207   Mean   :17.710  
 3rd Qu.:88.10   3rd Qu.:19.00   3rd Qu.: 6.3600   3rd Qu.:19.292  
 Max.   :97.40   Max.   :79.90   Max.   :99.1000   Max.   :99.600  
 NAs    :9       NAs    :44      NAs    :56        NAs    :92      
     sodium         
 Min.   :    0.000  
 1st Qu.:    0.871  
 Median :   23.000  
 Mean   :  358.023  
 3rd Qu.:  111.400  
 Max.   :38700.000  
 NAs    :66         

The summary shows that the quantitative variables have very different ranges. Water goes from 0 to about 97 g, while sodium is measured in milligrams and reaches a maximum of 38,700 mg, likely from salt itself. For calories, protein, fat, carbs, and sodium, the mean is larger than the median, which suggests right-skewed distributions. Water is the opposite: its mean (57.6 g) is below its median (71.8 g), because most foods are watery and a smaller group of dry foods pulls the average down. One odd value stands out: the minimum for carbs is slightly negative (-0.7 g). This happens because USDA calculates carbohydrates “by difference” (100 g minus water, protein, fat, and ash), so small measurement errors can push the result below zero.

Exploring the categorical variables

# count the foods by source
table(foods_clean$source)

     Animal Plant/Other 
        134         335 
# simple bar chart: number of foods in each food group
foods_clean |>
  count(food_group) |>
  ggplot(aes(x = reorder(food_group, n), y = n)) +
  geom_col(fill = "#2980b9") +
  coord_flip() +
  labs(title = "Number of Foods in Each Food Group",
       x = "Food Group", y = "Number of Foods",
       caption = "Source: USDA FoodData Central, Foundation Foods") +
  theme_minimal()

Plant and other foods outnumber animal foods by about 2.5 to 1. Vegetables, fruits, and legumes are the largest groups, while some animal groups, like poultry, have only about a dozen foods. This matters when comparing groups, because averages from small groups are less reliable.

Exploring a quantitative variable

# simple histogram of calories to check its shape
foods_clean |>
  filter(!is.na(calories)) |>
  ggplot(aes(x = calories)) +
  geom_histogram(binwidth = 50, fill = "#e67e22", color = "white") +
  labs(title = "Distribution of Calories per 100 g",
       x = "Calories (kcal per 100 g)", y = "Number of Foods",
       caption = "Source: USDA FoodData Central, Foundation Foods") +
  theme_minimal()

Calories are strongly right-skewed: most foods have under 200 calories per 100 g, but a few have over 600. Based on this exploration, I decided to focus on comparing food groups and on what makes some foods so much more calorie-dense than others.

Visualizations

Plot 1: Which food groups have the most protein? (Interactive)

# calculate average protein for each food group
protein_by_group <- foods_clean |>
  filter(food_group != "Other", !is.na(protein)) |>
  group_by(food_group) |>
  summarize(avg_protein = round(mean(protein), 1),
            n_foods = n()) |>
  arrange(desc(avg_protein))

# interactive column chart with highcharter
highchart() |>
  hc_add_series(data = protein_by_group,
                type = "column",
                hcaes(x = food_group, y = avg_protein),
                name = "Average protein (g per 100 g)",
                colorByPoint = TRUE) |>
  hc_colors(c("#8e0152", "#c51b7d", "#de77ae", "#e6550d", "#fd8d3c",
              "#fdae6b", "#31a354", "#74c476", "#3182bd", "#6baed6")) |>
  hc_xAxis(type = "category", title = list(text = "Food Group")) |>
  hc_yAxis(title = list(text = "Average Protein (g per 100 g)")) |>
  hc_title(text = "Average Protein Content by Food Group (per 100 g)") |>
  hc_tooltip(pointFormat = "Average protein: <b>{point.y} g</b><br>Number of foods: {point.n_foods}") |>
  hc_legend(enabled = FALSE) |>
  hc_caption(text = "Source: USDA FoodData Central, Foundation Foods")

Hover over each bar to see the average protein and the number of foods in each group. As expected, meats such as beef and poultry are at the top, with over 20 g of protein per 100 g. The surprise is legumes, which are nearly as high (about 21 g), because many legumes in this dataset are dried beans and lentils. Fruits and vegetables have almost no protein.

Plot 2: How are calories distributed across food groups?

# boxplot of calories by food group, colored by source, with an annotation
foods_clean |>
  filter(food_group != "Other", !is.na(calories)) |>
  ggplot(aes(x = reorder(food_group, calories, FUN = median), y = calories, fill = source)) +
  geom_boxplot(alpha = 0.8) +
  coord_flip() +
  annotate("text", x = "Nuts & Seeds", y = 320,
           label = "Most calorie-dense:\nmedian of about 640 kcal",
           size = 3.2, color = "#7f8c8d") +
  scale_fill_manual(values = c("Animal" = "#c0392b", "Plant/Other" = "#27ae60")) +
  labs(title = "Calories per 100 g by Food Group, Sorted by Median",
       x = "Food Group",
       y = "Calories (kcal per 100 g)",
       fill = "Food Source",
       caption = "Source: USDA FoodData Central, Foundation Foods") +
  theme_minimal(base_size = 12)

The food groups are sorted by their median calories. Nuts & seeds are by far the most calorie-dense, followed by cereals & pasta, while vegetables and fruits have the fewest calories. Some groups, like legumes and dairy, have a very wide spread, because they include both watery foods (like fresh green beans or milk) and dry foods (like dried beans or cheese).

Plot 3: Calorie distributions for animal vs. plant foods

# side-by-side histograms of calories for each food source
foods_clean |>
  filter(!is.na(calories)) |>
  ggplot(aes(x = calories, fill = source)) +
  geom_histogram(binwidth = 50, color = "white") +
  facet_wrap(~ source) +
  scale_fill_manual(values = c("Animal" = "#c0392b", "Plant/Other" = "#27ae60")) +
  labs(title = "Distribution of Calories per 100 g: Animal vs. Plant Foods",
       x = "Calories (kcal per 100 g)",
       y = "Number of Foods",
       fill = "Food Source",
       caption = "Source: USDA FoodData Central, Foundation Foods") +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")

These side-by-side histograms show that both distributions are right-skewed. Most plant foods have under 100 calories per 100 g, with a long tail of high-calorie items like nuts and grains. Animal foods are more concentrated in the middle range, with most between about 85 and 290 calories.

Plot 4: Water vs. calories by food group (Interactive)

# keep foods that have both water and calorie values
water_calories <- foods_clean |>
  filter(!is.na(water), !is.na(calories))

# interactive scatterplot, colored by food group
highchart() |>
  hc_add_series(data = water_calories,
                type = "scatter",
                hcaes(x = water, y = calories, group = food_group)) |>
  hc_colors(c("#c0392b", "#e67e22", "#16a085", "#8e44ad", "#27ae60", "#2c3e50",
              "#f1c40f", "#d35400", "#7f8c8d", "#e84393", "#2980b9")) |>
  hc_xAxis(title = list(text = "Water (g per 100 g)")) |>
  hc_yAxis(title = list(text = "Calories (kcal per 100 g)")) |>
  hc_title(text = "Foods with More Water Have Fewer Calories") |>
  hc_subtitle(text = "Each point is one food. Click a food group in the legend to hide or show it.") |>
  hc_tooltip(pointFormat = "<b>{point.description}</b><br>Water: {point.x} g<br>Calories: {point.y} kcal") |>
  hc_legend(title = list(text = "Food Group"), layout = "vertical",
            align = "right", verticalAlign = "middle") |>
  hc_caption(text = "Source: USDA FoodData Central, Foundation Foods")

Hover over any point to see the food’s name, and click a food group in the legend to hide or show it. This plot shows a very strong negative relationship: the more water a food contains, the fewer calories it has. Dry foods such as nuts, seeds, grains, and dried beans sit in the top left, while watery vegetables and fruits sit in the bottom right.

# simple linear regression: calories explained by water
water_model <- lm(calories ~ water, data = foods_clean)
summary(water_model)

Call:
lm(formula = calories ~ water, data = foods_clean)

Residuals:
    Min      1Q  Median      3Q     Max 
-503.73  -16.89   -4.50    8.76  389.26 

Coefficients:
            Estimate Std. Error t value Pr(>|t|)    
(Intercept) 505.8958     7.7905   64.94   <2e-16 ***
water        -5.1533     0.1108  -46.52   <2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 67.2 on 345 degrees of freedom
  (122 observations deleted due to missingness)
Multiple R-squared:  0.8625,    Adjusted R-squared:  0.8621 
F-statistic:  2164 on 1 and 345 DF,  p-value: < 2.2e-16

A simple regression confirms it: calories = 505.9 - 5.15 × water. Each extra gram of water per 100 g is linked to about 5 fewer calories, and water alone explains about 86% of the variation in calories (adjusted R² = 0.862). This makes sense, because water adds weight but no energy.

Plot 5: Where did USDA buy the food samples? (Interactive map)

# keep only valid U.S. state codes and count the samples bought in each state
samples_by_state <- market |>
  filter(store_state %in% state.abb) |>
  group_by(store_state) |>
  summarize(samples = n()) |>
  arrange(desc(samples))

# preview the states with the most samples
head(samples_by_state)
# A tibble: 6 × 2
  store_state samples
  <chr>         <int>
1 MD             1419
2 VA              878
3 CA              624
4 TX              317
5 IN              296
6 NC              285
# interactive map of samples by state
hcmap("countries/us/us-all",
      data = samples_by_state,
      value = "samples",
      joinBy = c("hc-a2", "store_state"),
      name = "Food samples purchased",
      borderColor = "white",
      borderWidth = 0.5) |>
  hc_colorAxis(minColor = "#fee8c8", maxColor = "#b30000") |>
  hc_title(text = "Number of Food Samples USDA Purchased in Each State") |>
  hc_legend(title = list(text = "Samples"), layout = "vertical",
            align = "right", verticalAlign = "middle") |>
  hc_caption(text = "Source: USDA FoodData Central, Foundation Foods (market_acquisition.csv)")

To measure nutrients, USDA buys real food samples from grocery stores and then analyzes them in the lab. The state column needed cleaning first: it contained regions like “Midwest,” other countries like “Canada,” and typos like “Al,” so I kept only the official two-letter state codes using R’s built-in state.abb list. That left 6,679 samples from 36 states. Hover over a state to see its count. Maryland has by far the most samples (1,419, about 21%), followed by Virginia and California. This makes sense, because USDA’s main research center is in Beltsville, Maryland, and many samples were bought nearby, in towns like Ellicott City, Greenbelt, and Beltsville itself. States in gray had no samples.

Plot 6: Relationships between protein, water, and fat

# correlation matrix plot of the regression variables, colored by source
foods_clean |>
  filter(!is.na(protein), !is.na(water), !is.na(fat)) |>
  ggpairs(columns = c("protein", "water", "fat"),
          mapping = aes(color = source, alpha = 0.5),
          columnLabels = c("Protein (g)", "Water (g)", "Fat (g)")) +
  scale_color_manual(values = c("Animal" = "#c0392b", "Plant/Other" = "#27ae60")) +
  scale_fill_manual(values = c("Animal" = "#c0392b", "Plant/Other" = "#27ae60")) +
  labs(title = "Relationships Between Protein, Water, and Fat by Food Source") +
  theme_minimal(base_size = 11)

This correlation matrix compares the three quantitative variables I use in the regression model. The diagonal shows each variable’s distribution, the lower panels show scatterplots, and the upper panels show correlation values for all foods and for each source. Protein has a moderately strong negative correlation with water (r = -0.60) and a weaker positive correlation with fat (r = 0.35). The two food sources also clearly separate, especially in the protein density plot, which suggests that source will be a useful predictor of protein.

Multiple Linear Regression

Question: Can water content, fat content, and food source predict how much protein a food contains?

Justification for the variables. I chose protein as the response variable because it is measured directly in the lab. Based on the correlation plot, water and fat are both significantly correlated with protein, so I included them as quantitative predictors, along with source. I deliberately left out two variables. Calories are not measured directly: USDA calculates them from protein, fat, and carbohydrates. Carbohydrates are also calculated “by difference,” meaning 100 g minus water, protein, fat, and ash. When I tested a model that included carbohydrates, it had an adjusted R² of almost 1.00. That looks perfect, but it only reflects the formula, so it would not tell us anything real about food.

# multiple linear regression: protein explained by water, fat, and source
protein_model <- lm(protein ~ water + fat + source, data = foods_clean)
summary(protein_model)

Call:
lm(formula = protein ~ water + fat + source, data = foods_clean)

Residuals:
    Min      1Q  Median      3Q     Max 
-19.753  -2.184   0.772   2.765  48.403 

Coefficients:
                    Estimate Std. Error t value Pr(>|t|)    
(Intercept)        33.257175   0.966039  34.426   <2e-16 ***
water              -0.216207   0.009582 -22.565   <2e-16 ***
fat                -0.053119   0.023689  -2.242   0.0255 *  
sourcePlant/Other -13.482658   0.678443 -19.873   <2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 5.952 on 407 degrees of freedom
  (58 observations deleted due to missingness)
Multiple R-squared:  0.6824,    Adjusted R-squared:  0.6801 
F-statistic: 291.5 on 3 and 407 DF,  p-value: < 2.2e-16

Equation:

protein = 33.26 - 0.216 × water - 0.053 × fat - 13.48 × (Plant/Other)

Interpretation:

  • Water: holding the other variables constant, each extra gram of water per 100 g is linked to about 0.22 g less protein. Water takes up space that could otherwise be filled with protein. This effect is highly significant (p < 2e-16).
  • Fat: each extra gram of fat is linked to about 0.05 g less protein. This effect is small but still statistically significant (p = 0.025). Interestingly, fat and protein are positively correlated overall (r = 0.35), because fatty foods tend to be dry. Once water content is held constant, though, more fat leaves less room for protein.
  • Source: plant and other foods have about 13.5 g less protein per 100 g than animal foods with the same water and fat content (p < 2e-16). Animal is the reference group, so it does not appear in the output.
  • Adjusted R²: 0.680, so the model explains about 68% of the variation in protein content. The remaining 32% likely comes from factors not in the model, such as the specific type of food (for example, legumes are much higher in protein than other plant foods).
  • Overall model: F = 291.5 on 3 and 407 degrees of freedom, with a p-value far below 0.05, so the model as a whole is statistically significant. It uses the 411 foods with complete values for all four variables.

Diagnostic plots

# show the four diagnostic plots in a 2x2 grid
par(mfrow = c(2, 2))
plot(protein_model)

  • Residuals vs. Fitted: checks whether a straight-line model fits. Ideally, the points are scattered randomly around the horizontal line at 0.
  • Q-Q Residuals: checks whether the residuals are normally distributed. The residuals of this model have heavy tails: a few foods have much more or much less protein than predicted, so the points at both ends curve away from the line.
  • Scale-Location: checks whether the spread of the residuals is even across the fitted values.
  • Residuals vs. Leverage: identifies unusual foods that could have a strong influence on the model.

Overall, the model is useful and clearly significant, but it does not perfectly meet every regression assumption, mostly because of a few unusual foods.

Conclusion

What the visualizations show

The interactive bar chart showed that meats are the top protein sources, but legumes are nearly as high, which is a good reminder that plants can be excellent sources of protein. This was personally meaningful to me, since beans and lentils are a big part of the food I grew up with. The boxplot and side-by-side histograms showed that calories vary hugely between food groups, with nuts, seeds, and grains at the top and fruits and vegetables at the bottom. The interactive scatterplot revealed the strongest pattern in the data: water content alone explains about 86% of the variation in calories. This supports common nutrition advice that water-rich foods like fruits and vegetables let you eat larger portions for fewer calories. The map showed something I did not expect: about one in five food samples in this national database were bought in Maryland, close to USDA’s research center in Beltsville.

Regression results

The multiple regression showed that water content, fat content, and food source together explain about 68% of the variation in protein. Animal foods contain about 13.5 g more protein per 100 g than plant foods with similar water and fat content, and foods with more water contain less protein. The most surprising result was the fat coefficient: it is negative in the model, even though fat and protein are positively correlated on their own.

Challenges and what I wish I could have included

The biggest challenge was that the data is not one table. I had to understand how the files link together through their ID columns, reshape the nutrient data from long to wide format, and join several tables. Choosing the regression also took care, since some variables, like calories and carbohydrates, are calculated from the others and would produce misleading, almost perfect models.

I wish I could have included sugar and fiber, but they were measured for only about half of the foods or fewer, so they would have left out too much data. I also wanted to connect each food to the state where it was purchased, so I could compare nutrients by region. However, the store locations are recorded for individual lab samples, not for the final foods, and linking them would have required several more tables. Finally, Foundation Foods includes only basic foods, not branded, packaged, or restaurant foods. In the future, I would like to analyze the much larger Branded Foods dataset to compare processed foods with these basic ones.

References

Data and image

Coding help

  • DATA 110 class notes and examples.
  • Kunst, J. (2022). highcharter: A wrapper for the Highcharts library. https://jkunst.com/highcharter/
  • Schloerke, B., et al. (2024). GGally: Extension to ggplot2. https://ggobi.github.io/ggally/
  • Anthropic. (2026). Claude (Opus 5.5) [Large language model]. Used for help with joining the data files, choosing regression variables, and GGally. https://claude.ai