Joseph Falcone
  • Home
  • Projects
  • Resume
  • Contact
  • Resources

Private-Label Purchasing at Kroger

R
Statistical Inference
Retail Analytics
A team project examining household income and private-label spending, with comparisons across Grocery categories.

Selected work / Team project

Private-Label Purchasing
at Kroger

An analysis of household income and private-label spending, examining overall purchasing patterns and differences within Grocery categories.

Download R script

Business Question

Do lower-income Kroger households allocate a larger share of their spending to private-label products than higher-income households, and is that difference concentrated in Grocery categories?

Unit of Analysis

Our unit of analysis is the household.

We use households because income is measured at the household level. Each household therefore contributes one summarized private-label purchasing measure rather than households with more transactions receiving more weight.

Defining Private-Label Purchasing

We define private-label purchasing as private-label dollar share:

\[ \text{Private Label Share} = \frac{\text{Private Label Spending}} {\text{Total Spending}} \]

This measures the share of a household’s spending that goes to private-label products.

Basics

Read in tidyverse and data
View R code
library(tidyverse)

transactions <- read_csv("transaction_data.csv")
products <- read_csv("product.csv")
demographics <- read_csv("hh_demographic.csv")
Join data
View R code
data <- transactions |>
    left_join(products, by = 'PRODUCT_ID') |>
    left_join(demographics, by = 'household_key')

glimpse(data)
Initial EDA
View R code
## Count of private and national transactions
data |>
    count(BRAND)

## Count of transactions per department
data |>
    count(DEPARTMENT, sort = TRUE)

## Count of each income groups
data |>
    count(INCOME_DESC, sort = TRUE)

## Count of transactions with demographic info
data |>
    summarise(
        total_rows = n(),
        rows_with_income = sum(!is.na(INCOME_DESC)),
        percent_with_income = mean(!is.na(INCOME_DESC)) * 100
    )
Create new variable to track only private label product sales
View R code
data <- data |>
    mutate(
        private_sales = if_else(
            BRAND == 'Private',
            SALES_VALUE, 0
            )
    )

Analysis

Rearrange data to household granularity
View R code
household_data <- data |>
    filter(!is.na(INCOME_DESC)) |>
    group_by(household_key, INCOME_DESC) |>
    summarise(
        total_sales = sum(SALES_VALUE, na.rm = TRUE),
        private_sales = sum(private_sales, na.rm = TRUE),
        .groups = 'drop'
    ) |>
        mutate(
            private_share = private_sales / total_sales
        )

glimpse(household_data)
summary(household_data$private_share)
Private label purchases by income groups
View R code
household_data |>
    group_by(INCOME_DESC) |>
    summarise(
        n = n(),
        mean_private_share = mean(private_share, na.rm = TRUE),
        median_private_share = median(private_share, na.rm = TRUE),
        sd_private_share = sd(private_share, na.rm = TRUE)
    ) |>
        arrange(desc(mean_private_share))
Distribution of private label dollar share
View R code
ggplot(household_data, aes(x = private_share)) +
    geom_histogram(bins = 30) +
    labs(
        title = 'Distribution of Household Private Label Dollar Share',
        x = 'Private Label Dollar Share',
        y = 'Number of Households'
    )
Private-label dollar share by income group
View R code
ggplot(household_data, aes(x = INCOME_DESC, y = private_share)) +
    geom_boxplot() +
    coord_flip() +
    labs(
        title = 'Private Label Dollar Share by Household Income',
        x = 'Income Group',
        y = 'Private Label Dollar Share '
    )
Income group summary
View R code
income_summary <- household_data |>
    group_by(INCOME_DESC) |>
    summarise(
        n = n(),
        mean_private_share = mean(private_share, na.rm = TRUE),
        median_private_share = median(private_share, na.rm = TRUE),
        sd_private_share = sd(private_share, na.rm = TRUE)
    ) |>
        arrange(desc(mean_private_share))

income_summary

Now you have an actual finding to work with — and it’s more interesting than a simple “lower income buys more private label” story.

Your descriptive results show:

Overall mean private label dollar share: 27.7% Median: 26.9% 100–124K is highest observed group at 30.2% Several lower income groups are around 28–29%

The highest-income groups are much lower:

  • 150–174K: 22.7%
  • 175–199K: 22.0%
  • 250K+: 16.6%
  • 200–249K: 16.3%

Last few groups have very small sample sizes, (especially 200–249K with only 5 households). Collapse in broader groups.

Establish broader groups
View R code
household_data <- household_data |>
    mutate(
        income_group = case_when(
            INCOME_DESC %in% c(
                "Under 15K",
                "15-24K",
                "25-34K",
                "35-49K"
            ) ~ "Below $50K",

            INCOME_DESC %in% c(
                "50-74K",
                "75-99K"
            ) ~ "$50K-$99K",

            INCOME_DESC %in% c(
                "100-124K",
                "125-149K",
                "150-174K",
                "175-199K",
                "200-249K",
                "250K+"
            ) ~ "$100K+"
        )
    )
Summarize new groups
View R code
income_group_summary <- household_data |>
    group_by(income_group) |>
    summarise(
        n = n(),
        mean_private_share = mean(private_share),
        median_private_share = median(private_share),
        sd_private_share = sd(private_share)
    )

income_group_summary
New plot
View R code
ggplot(
    household_data,
    aes(x = income_group, y = private_share)
) +
    geom_boxplot() +
    labs(
        title = "Private-Label Dollar Share by Household Income",
        x = "Household Income",
        y = "Private-Label Dollar Share"
    )

ANOVA

Welch’s one way ANOVA best because of unequal sizes and different standard deviations
View R code
oneway.test(
    private_share ~ income_group,
    data = household_data,
    var.equal = FALSE
)

Welch ANOVA Result:

  • F = 5.7469
  • p = 0.003509

Average private-label dollar share is not the same across all three income groups.

Your grouped means are:

  • Below $50K: 28.9%
  • $50K–$99K: 27.2%
  • $100K+: 25.0%

So broad pattern is: Private-label dollar share decreases as household income rises.

ANOVA only tells shows at least one group differs, not which pairs differ.

Next step is a pairwise follow-up with Welch pairwise t-tests with a multiple-comparison adjustment
View R code
pairwise.t.test(
    household_data$private_share,
    household_data$income_group,
    p.adjust.method = "bonferroni",
    pool.sd = FALSE
)

Bonferroni-adjusted pairwise p-values:

  • $50K–$99K vs $100K+: p = 0.256
  • Below $50K vs $100K+: p = 0.004
  • Below $50K vs $50K–$99K: p = 0.173

Only statistically significant pair Below $50K vs $100K+

And the observed mean difference is 0.039 (0.289 - 0.250)

Households below $50K spend about 3.9 percentage points more of their total spending on private-label products than households earning $100K+. The middle-income group is not significantly different from either side after the Bonferroni adjustment.

Private-label dollar share differs across income groups overall, but the clearest difference is between households below $50K and households earning $100K or more.

Quantify key difference with confidence interval
View R code
t.test(
    private_share ~ income_group,
    data = household_data |>
        filter(income_group %in% c("Below $50K", "$100K+"))
)
  • $100K+ mean PL share: 25.05%
  • Below $50K mean PL share: 28.88%
  • Difference: about 3.83 percentage points
  • 95% CI for $100K+ − Below $50K: -6.15 to -1.51 percentage points
  • p = 0.00133

Households earning below $50K spend about 3.8 percentage points more of their total dollars on private label products than households earning $100K or more. The 95% confidence interval suggests the true gap is roughly 1.5 to 6.2 percentage points.

Is income gap concentrated within certain departments
View R code
department_data <- data |>
    filter(!is.na(INCOME_DESC)) |>
    mutate(
        income_group = case_when(
            INCOME_DESC %in% c(
                "Under 15K",
                "15-24K",
                "25-34K",
                "35-49K"
            ) ~ "Below $50K",

            INCOME_DESC %in% c(
                "50-74K",
                "75-99K"
            ) ~ "$50K-$99K",

            INCOME_DESC %in% c(
                "100-124K",
                "125-149K",
                "150-174K",
                "175-199K",
                "200-249K",
                "250K+"
            ) ~ "$100K+"
        )
    ) |>
    filter(income_group %in% c("Below $50K", "$100K+")) |>
    group_by(household_key, income_group, DEPARTMENT) |>
    summarise(
        total_sales = sum(SALES_VALUE, na.rm = TRUE),
        private_sales = sum(private_sales, na.rm = TRUE),
        .groups = "drop"
    ) |>
    mutate(
        private_share = private_sales / total_sales
    )
Summarize by department
View R code
department_summary <- department_data |>
    group_by(DEPARTMENT, income_group) |>
    summarise(
        n = n(),
        mean_private_share = mean(private_share, na.rm = TRUE),
        .groups = "drop"
    )

department_summary

Not a good idea to use all 44 departments. Some departments have tiny samples and mechanically extreme shares, like AUTOMOTIVE = 1.00 with only 7–11 households. Those aren’t very useful for the business story.

GROCERY is by far the largest department in the transaction data, and gives a good “where”

Check grocery
View R code
department_summary |>
    filter(DEPARTMENT == "GROCERY")
Compare Below 50k and 100k+ Does the overall income difference in private-label purchasing persist specifically within Grocery?
View R code
t.test(
    private_share ~ income_group,
    data = department_data |>
        filter(DEPARTMENT == "GROCERY")
)
Find biggest commodity categories within grocery
View R code
top_grocery_commodities <- data |>
    filter(
        DEPARTMENT == "GROCERY",
        !is.na(COMMODITY_DESC)
    ) |>
    group_by(COMMODITY_DESC) |>
    summarise(
        total_sales = sum(SALES_VALUE, na.rm = TRUE),
        .groups = "drop"
    ) |>
    arrange(desc(total_sales))

top_grocery_commodities

Among households with reported income, lower income households devoted about 7 percentage points more of their Grocery spending to private label products than households earning $100K+, with the estimated gap plausibly around 4.7–9.4 percentage points.

Figure out which Grocery categories are driving that difference
View R code
top_categories <- c(
    "SOFT DRINKS",
    "FLUID MILK PRODUCTS",
    "CHEESE",
    "FRZN MEAT/MEAT DINNERS",
    "BAG SNACKS"
)
Household summary per category
View R code
commodity_data <- data |>
    filter(
        DEPARTMENT == "GROCERY",
        COMMODITY_DESC %in% top_categories,
        !is.na(INCOME_DESC)
    ) |>
    mutate(
        income_group = case_when(
            INCOME_DESC %in% c(
                "Under 15K",
                "15-24K",
                "25-34K",
                "35-49K"
            ) ~ "Below $50K",

            INCOME_DESC %in% c(
                "100-124K",
                "125-149K",
                "150-174K",
                "175-199K",
                "200-249K",
                "250K+"
            ) ~ "$100K+"
        )
    ) |>
    filter(!is.na(income_group)) |>
    group_by(
        household_key,
        income_group,
        COMMODITY_DESC
    ) |>
    summarise(
        total_sales = sum(SALES_VALUE, na.rm = TRUE),
        private_sales = sum(private_sales, na.rm = TRUE),
        .groups = "drop"
    ) |>
    mutate(
        private_share = private_sales / total_sales
    )
Average private-label dollar share within that category among households that purchased something in that category.
View R code
commodity_summary <- commodity_data |>
    group_by(COMMODITY_DESC, income_group) |>
    summarise(
        n = n(),
        mean_private_share = mean(private_share),
        median_private_share = median(private_share),
        .groups = "drop"
    )

commodity_summary
Test whether those category gaps are statistically distinguishable.
View R code
commodity_tests <- commodity_data |>
    group_by(COMMODITY_DESC) |>
    group_modify(~ {
        test <- t.test(
            private_share ~ income_group,
            data = .x
        )

        tibble(
            difference = unname(
                test$estimate["mean in group Below $50K"] -
                test$estimate["mean in group $100K+"]
            ),
            conf_low = -test$conf.int[2],
            conf_high = -test$conf.int[1],
            p_value = test$p.value
        )
    }) |>
    ungroup() |>
    mutate(
        p_adjusted = p.adjust(p_value, method = "bonferroni")
    )

commodity_tests

Lower-income households allocate a larger share of Grocery spending to private label, with particularly pronounced differences in soft drinks, cheese, and fluid milk.

Recommendation

Kroger should prioritize private-label positioning and promotion for lower-income households in high-volume Grocery categories where the income gap in private-label purchasing is strongest.

The clearest opportunities in this analysis are:

  • Cheese
  • Soft drinks
  • Fluid milk products

Kroger should also investigate why $100K+ households show lower private-label adoption in these categories.

Limitations

  • Income is only available for a subset of households.
  • This is observational analysis, so income should not be interpreted as causing private-label purchasing.
  • Household composition, shopping frequency, store location, and assortment could also contribute to the observed differences.
  • The commodity analysis examines only five high-sales Grocery categories.

Conclusion

Private-label dollar share differs across income groups. The clearest difference is between households below $50K and households earning $100K or more.

That gap becomes larger within Grocery and is most evident among the selected high-volume categories in cheese, soft drinks, and fluid milk products.


Run the code

The downloadable script requires tidyverse. Place transaction_data.csv, product.csv, and hh_demographic.csv in your R working directory before running it. The datasets are not included here.

← All projects

© Joseph Falcone · Designed by me in NYC.

 
  • Say hello ↗