1 INTRODUCTION

Bellabeat is a high-tech manufacturer of health-focused products for women. The CCO and cofounder of Bellabeat, Urska Srsen, believes the company is well positioned to gain additional market share through thoughtful analysis of wearable smart device data. The marketing strategy will use this analysis as guidance.

As a junior data analyst with just 6 months at the company, I have been tasked with analyzing smart device data and developing high-level recommendations to inform marketing strategy. The scope has been limited to a single product line.

I have found a dataset from Fitbit (a wearable watch) that has features similar to Bellabeat’s Time. The dataset contains no demographic information and has a very limited number of unique participants. We assume the sample population is representative, though additional data might prove this assumption inaccurate. The Fitbit dataset is publicly available as Fitbit Fitness Tracker Data.

What follows is a reproducible examination of the data contained within the Fitbit dataset, and how we addressed oversights in cleaning and processing the data when they arose. We highlight identified limitations in the analyzed data, and any problems that should be considered before a go-forward marketing strategy is deployed.

2 ASK

Business Task or Problem

Analyze competitor data to identify and evaluate user trends, opportunity areas, and insights to inform Bellabeat’s go-forward marketing strategy as it relates to our wearable watch, Time. Because Time connects directly to the Bellabeat app, the analysis performed can also inform system changes.

The stakeholders have been identified as the ownership team and the marketing analytics team. The dataset being evaluated is public, so there are no security concerns. If there were an actual gap-to-target, formalized problem-solving would have to occur.

Key Questions:

  • What are the features being utilized, and at what rates?
  • Is there statistically significant variance that requires additional data to resolve?
  • What impacts are expected with no demographic information available to analyze?

3 PREPARE

Kaggle has made the Fitbit Fitness Tracker Data publicly available. I performed analysis on the April–May dataset and focused on daily activity, daily sleep, hourly steps, hourly intensities, calories, and weight. The number of participants who logged weight data was statistically out-of-phase with the other unique-user data, so weight log trends were not included in our analysis of usage trends.

Evaluating the data by day of the week and, where available and relevant, hour by hour, allowed the most granular assessment possible given our notably limited skillset.

A note on the data files: the raw CSVs in the files folder were originally saved with mismatched names (each file’s contents did not match its filename, and the weight log was missing). This was corrected with a content-based rename utility (fix_fitbit_filenames.R) before running this analysis. See the README for detail.

Let’s get started!

# The tidyverse bundles readr, dplyr, ggplot2, tidyr, lubridate, etc.
# 'hms' handles time-of-day values; 'here' anchors file paths to the
# project root so this knits the same from scripts/ or interactively.
library(tidyverse)
library(lubridate)
library(hms)
library(here)
activity           <- read_csv(here("files", "dailyActivity_merged.csv"))
sleep              <- read_csv(here("files", "sleepDay_merged.csv"))
hourly_intensities <- read_csv(here("files", "hourlyIntensities_merged.csv"))
steps              <- read_csv(here("files", "hourlySteps_merged.csv"))
weight             <- read_csv(here("files", "weightLogInfo_merged.csv"))

head(activity)
## # A tibble: 6 × 15
##           Id ActivityDate TotalSteps TotalDistance TrackerDistance
##        <dbl> <chr>             <dbl>         <dbl>           <dbl>
## 1 1503960366 4/12/2016         13162          8.5             8.5 
## 2 1503960366 4/13/2016         10735          6.97            6.97
## 3 1503960366 4/14/2016         10460          6.74            6.74
## 4 1503960366 4/15/2016          9762          6.28            6.28
## 5 1503960366 4/16/2016         12669          8.16            8.16
## 6 1503960366 4/17/2016          9705          6.48            6.48
## # ℹ 10 more variables: LoggedActivitiesDistance <dbl>,
## #   VeryActiveDistance <dbl>, ModeratelyActiveDistance <dbl>,
## #   LightActiveDistance <dbl>, SedentaryActiveDistance <dbl>,
## #   VeryActiveMinutes <dbl>, FairlyActiveMinutes <dbl>,
## #   LightlyActiveMinutes <dbl>, SedentaryMinutes <dbl>, Calories <dbl>

The datasets have a few easily discernible issues: the date and datetime fields are not in a useful data type for further analysis (the dates/times are character strings), column names vary from dataframe to dataframe, and weekday information cannot be derived without further manipulation.

For activity we need to change ActivityDate from character to date, then add a column identifying the weekday the date is associated with.

4 PROCESS

4.1 Daily Activity

As identified in the preliminary look (head), the column ActivityDate is a character string. There are surely more elegant approaches than the iterative one below, but the intent is that each transformation is easy to follow.

activity <- activity %>% mutate(ActivityDate = mdy(ActivityDate))
# label = TRUE returns the day name (not a number); abbr = FALSE spells it out.
activity <- activity %>%
  mutate(day_of_week = wday(ActivityDate, label = TRUE, abbr = FALSE))
# ActiveHours = all active minutes / 60; SedentaryHours = sedentary minutes / 60.
activity <- activity %>%
  mutate(ActiveHours = (VeryActiveMinutes + FairlyActiveMinutes + LightlyActiveMinutes) / 60,
         SedentaryHours = SedentaryMinutes / 60)

Lastly, rename the date column. This will assist us later in analysis.

activity <- activity %>% rename(Date = ActivityDate)

Now let’s turn our attention to the sleep data. We will clean it in a similar, iterative way.

4.2 Daily Sleep

We first split SleepDay (which holds date and time in a single character column) into two columns we can manipulate as needed.

sleep <- sleep %>% separate(SleepDay, into = c("Date", "Time"), sep = " ")
sleep <- sleep %>% mutate(Date = mdy(Date))

Add a column for day of week.

sleep <- sleep %>%
  mutate(day_of_week = wday(Date, label = TRUE, abbr = FALSE))

For an apples-to-apples comparison later, convert sleep minutes to hours, total time in bed to hours, and define the difference between being in bed and being asleep as the winddown time.

sleep <- sleep %>% mutate(SleepHours = (TotalMinutesAsleep / 60),
                          InBedHours = (TotalTimeInBed / 60),
                          WindDown = (InBedHours - SleepHours))

4.3 Hourly Intensities

# Step 1: separate date and time from the original column.
hourly_intensities <- hourly_intensities %>%
  separate(ActivityHour, into = c("ActivityDate", "ActivityTime"), sep = " ", extra = "merge")
# Change the data type of ActivityDate.
hourly_intensities <- hourly_intensities %>%
  mutate(ActivityDate = mdy(ActivityDate))

Add a weekday column.

hourly_intensities <- hourly_intensities %>%
  mutate(day_of_week = wday(ActivityDate, label = TRUE, abbr = FALSE))

Split out the time so AM and PM are identifiable. Here I use a 24-hour approach.

hourly_intensities <- hourly_intensities %>%
  mutate(ActivityTime = as_hms(parse_date_time(ActivityTime, "I:M:S p")))
hourly_intensities <- hourly_intensities %>% rename(Date = ActivityDate)
# Confirm the date data type has been applied:
str(hourly_intensities$Date)
##  Date[1:22099], format: "2016-04-12" "2016-04-12" "2016-04-12" "2016-04-12" "2016-04-12" ...

4.4 Hourly Steps

Having been through the cleaning process, let’s cut to the quick with hourly steps.

steps <- steps %>%
  separate(ActivityHour, into = c("ActivityDate", "ActivityTime"), sep = " ", extra = "merge") %>%
  mutate(ActivityDate = mdy(ActivityDate)) %>%
  mutate(ActivityTime = as_hms(parse_date_time(ActivityTime, "I:M:S p"))) %>%
  mutate(day_of_week = wday(ActivityDate, label = TRUE, abbr = FALSE)) %>%
  rename(Date = ActivityDate)

4.5 Weight Log

The weight log data is limited, as seen below. We will use the unique user/day data in our data viz in the Analyze section. To do so, we transform the Date column from character to date and add the day-of-week column.

weight <- weight %>% separate(Date, into = c("Date", "Time"), sep = " ") %>%
  mutate(Date = mdy(Date)) %>%
  mutate(day_of_week = wday(Date, label = TRUE, abbr = FALSE))
n_distinct(weight$Id)
## [1] 11

Now let’s put our efforts to use, compiling summaries and looking at how the data interacts.

5 ANALYZE

5.1 Merging and Transforming Summaries

# Average steps and calories by day of week (na.rm removes missing values).
activity_weekly_averages <- activity %>%
  group_by(day_of_week) %>%
  summarise(
    avg_steps = mean(TotalSteps, na.rm = TRUE),
    avg_calories = mean(Calories, na.rm = TRUE))

What can we learn about the sleep habits of Fitbit users that can be applied to our strategic marketing initiatives?

# Summary of sleep hours per weekday.
sleep_averages <- sleep %>%
  group_by(day_of_week) %>%
  summarise(avg_sleep = mean(SleepHours, na.rm = TRUE),
            avg_time_in_bed = mean(InBedHours, na.rm = TRUE),
            avg_winddown = mean(WindDown, na.rm = TRUE))

Late addition: a winddown percent, to see if there is significant variance.

sleep_averages <- sleep_averages %>%
  mutate(winddown_percent = (avg_winddown / avg_time_in_bed) * 100)

To enable a stacked bar graph, transform the sleep data into long format. This lets us visualize the processed data better when we build our graphs.

# Long format is ideal when you want a single column ("metric") to group by.
sleep_averages_long <- sleep_averages %>%
  pivot_longer(
    cols = c(avg_sleep, avg_winddown),
    names_to = "metric",
    values_to = "hours"
  )

Average the hourly intensity data so we can visualize it.

intensities_averages <- hourly_intensities %>%
  group_by(day_of_week, ActivityTime) %>%
  summarise(avg_total_intensity = mean(TotalIntensity, na.rm = TRUE))

Average daily steps.

step_averages <- steps %>%
  group_by(day_of_week, ActivityTime) %>%
  summarise(avg_step_total = mean(StepTotal, na.rm = TRUE))

Our next steps involve full joins to evaluate relationships between datasets. NOTE: it is important to pass a vector to the by argument (the c(...)). Otherwise you get many duplicate rows.

merged_activitySleep <- full_join(activity, sleep, by = c("Id", "Date"))
merged_data_all <- activity %>%
  full_join(sleep, by = c("Id", "Date")) %>%
  full_join(steps, by = c("Id", "Date")) %>%
  full_join(hourly_intensities, by = c("Id", "Date"))

Several users log sleep at a different rate than their activity is recorded. This warrants investigation. It appears the applications requiring the user to do more than wear the watch are used less than the applications that automatically catalog data.

First, let’s look at distinct users per day.

activity_distinct_users_per_day <- activity %>%
  group_by(Date) %>%
  summarise(activity_distinct_users = n_distinct(Id))

sleep_distinct_users_per_day <- sleep %>%
  group_by(Date) %>%
  summarise(sleep_distinct_users_per_day = n_distinct(Id))

intensities_distinct_users_per_day <- hourly_intensities %>%
  group_by(Date) %>%
  summarise(intensities_distinct_users_per_day = n_distinct(Id))

steps_distinct_users_per_day <- steps %>%
  group_by(Date) %>%
  summarise(steps_distinct_users_per_day = n_distinct(Id))

weight_distinct_users_per_day <- weight %>%
  group_by(Date) %>%
  summarise(weight_distinct_users_per_day = n_distinct(Id))
activity_distinct_users_per_day <- activity_distinct_users_per_day %>%
  rename(distinct_users = names(.)[2]) %>%
  mutate(source = "activity")

sleep_distinct_users_per_day <- sleep_distinct_users_per_day %>%
  rename(distinct_users = names(.)[2]) %>%
  mutate(source = "sleep")

intensities_distinct_users_per_day <- intensities_distinct_users_per_day %>%
  rename(distinct_users = names(.)[2]) %>%
  mutate(source = "hourly_intensities")

steps_distinct_users_per_day <- steps_distinct_users_per_day %>%
  rename(distinct_users = names(.)[2]) %>%
  mutate(source = "steps")

weight_distinct_users_per_day <- weight_distinct_users_per_day %>%
  rename(distinct_users = names(.)[2]) %>%
  mutate(source = "weight")

Create a summary of distinct users per application per day.

combined_distinct_user_data <- bind_rows(
  activity_distinct_users_per_day,
  sleep_distinct_users_per_day,
  intensities_distinct_users_per_day,
  steps_distinct_users_per_day,
  weight_distinct_users_per_day)

Create a dataframe with usage rates by application. Distinct Ids by application are 33 for activity, steps, and hourly intensities; 24 for sleep; and 8 for weight. The different totals are built into the code.

combined_distinct_data_with_percent <- combined_distinct_user_data %>%
  mutate(
    percent_of_users = case_when(
      source %in% c("activity", "hourly_intensities", "steps") ~ (distinct_users / 33) * 100,
      source == "sleep" ~ (distinct_users / 24) * 100,
      source == "weight" ~ (distinct_users / 8) * 100
    )
  ) %>%
  mutate(
    percent_of_users = round(percent_of_users, 0)
  )

We need an average use rate to complete our visual.

average_use_rate <- combined_distinct_data_with_percent %>%
  group_by(source) %>%
  summarise(
    average_percent = mean(percent_of_users, na.rm = TRUE)
  )

6 SHARE

6.1 Data Visualizations

# Calories vs. active hours by day of week, to confirm a positive correlation. They correlate.
print(ggplot(data = activity, mapping = aes(x = ActiveHours, y = Calories, color = day_of_week)) +
  geom_point() +
  facet_wrap(~ day_of_week) +
  geom_smooth(color = "red")
)

Active hours and calories burned show a positive correlation, as expected. No new insights gained.

# Average Steps per Weekday
print(ggplot(data = activity_weekly_averages, mapping = aes(x = day_of_week, y = avg_steps, fill = day_of_week)) +
  geom_col() +
  labs(title = "Average Steps per Weekday", x = "Day of Week", y = "Average Steps") +
  geom_text(aes(label = round(avg_steps, 0)), vjust = 3.5)
)

# Average Calories per Weekday
print(ggplot(data = activity_weekly_averages, mapping = aes(x = day_of_week, y = avg_calories, fill = day_of_week)) +
  geom_col() +
  labs(title = "Average Calories per Weekday", x = "Day of Week", y = "Average Calories") +
  geom_text(aes(label = round(avg_calories, 0)), vjust = 3.5)
)

While it doesn’t appear there is a statistical difference between day of the week and average steps/calories, let’s test to be sure.

# ANOVA on total steps by day of week, and on calories by day of week.
anova_steps <- aov(TotalSteps ~ day_of_week, data = activity)
summary(anova_steps)
##              Df    Sum Sq  Mean Sq F value Pr(>F)
## day_of_week   6 1.449e+08 24145583   0.933  0.471
## Residuals   933 2.416e+10 25890251
anova_calories <- aov(Calories ~ day_of_week, data = activity)
summary(anova_calories)
##              Df    Sum Sq Mean Sq F value Pr(>F)
## day_of_week   6   2686242  447707   0.867  0.518
## Residuals   933 481615818  516201

There is no statistically relevant daily variance.

A heat map of hourly intensities and steps is useful for evaluating the times and days where intensities and steps are lowest and highest.

print(ggplot(intensities_averages, aes(x = ActivityTime, y = day_of_week, fill = avg_total_intensity)) +
  geom_tile() +
  scale_fill_gradient(low = "yellow", high = "red") +
  labs(title = "Average Activity Intensity by Time and Day",
       x = "Time of Day",
       y = "Day of Week",
       fill = "Average Intensity") +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))
)

print(ggplot(step_averages, aes(x = ActivityTime, y = day_of_week, fill = avg_step_total)) +
  geom_tile() +
  scale_fill_gradient(low = "yellow", high = "red") +
  labs(title = "Average Steps by Time and Day",
       x = "Time of Day",
       y = "Day of Week",
       fill = "Average") +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))
)

# Average steps by time and day strongly correlate with average intensities by day and hour.
cor.test(step_averages$avg_step_total, intensities_averages$avg_total_intensity)
## 
##  Pearson's product-moment correlation
## 
## data:  step_averages$avg_step_total and intensities_averages$avg_total_intensity
## t = 82.427, df = 166, p-value < 2.2e-16
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  0.9837568 0.9911443
## sample estimates:
##       cor 
## 0.9880031
# Plot distinct users per day across applications.
print(ggplot(combined_distinct_user_data, aes(x = Date, y = distinct_users, color = source, linetype = source)) +
  geom_line(linewidth = 1) +
  labs(
    title = "Distinct Users per Day Across Different Applications",
    x = "Date",
    y = "Number of Distinct Users",
    color = "Data Source"
  ) + theme_minimal() +
  theme(
    plot.title = element_text(hjust = 0.5, face = "bold"),
    legend.position = "bottom"
  )
)

The sleep and weight log functions have far fewer daily participants than the automated applications. Sleep and weight are manually logged. The data suggests fewer than half the users per week log their sleep activity, and less than 25% bother with the weight log. The opportunity areas are to educate (the benefits of using all available applications), automate, or eliminate.

# Average steps per time slot.
print(ggplot(step_averages, aes(x = ActivityTime, y = (avg_step_total) / 7)) +
  geom_bar(stat = "identity", fill = "steelblue") +
  labs(
    title = "Average Steps per Time Slot",
    x = "Time of Day",
    y = "Average Total Steps"
  ) +
  theme_minimal()
)

The overnight hours show low usage. This suggests either (a) biased sampling to the exclusion of 3rd-shift workers, or (b) people working 3rd shift are not buying / not using a Fitbit.

To evaluate the relationship between average calories and average steps, we scale the data. Without scaling, this graph type is not compelling or informative.

# Secondary axis to highlight the relationship between calories and average steps per day.
scale_factor <- max(activity_weekly_averages$avg_steps) / max(activity_weekly_averages$avg_calories)
print(ggplot(activity_weekly_averages, aes(x = day_of_week)) +
  geom_line(aes(y = avg_steps, group = 1), color = "steelblue", linewidth = 1.2) +
  geom_point(aes(y = avg_steps, group = 1), color = "steelblue", size = 3) +
  geom_line(aes(y = avg_calories * scale_factor, group = 1), color = "darkred", linewidth = 1.2) +
  geom_point(aes(y = avg_calories * scale_factor, group = 1), color = "darkred", size = 3) +
  labs(
    title = "Weekly Trends: Steps vs. Calories",
    x = "Day of Week",
    y = "Average Steps"
  ) +
  scale_y_continuous(
    sec.axis = sec_axis(~ . / scale_factor, name = "Average Calories")
  ) +
  theme_minimal() +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1),
    axis.title.y.left = element_text(color = "steelblue"),
    axis.title.y.right = element_text(color = "darkred")
  )
)

# Combo chart for sleep. Generate the scale factors first.
max_hours <- max(sleep_averages$avg_time_in_bed)
max_percent <- max(sleep_averages$winddown_percent)
scale_factor <- max_hours / max_percent
print(ggplot(sleep_averages_long, aes(x = day_of_week)) +
  geom_bar(
    aes(y = hours, fill = metric),
    stat = "identity",
    position = "stack"
  ) +
  geom_line(
    data = sleep_averages,
    aes(y = winddown_percent * scale_factor, group = 1),
    color = "darkred",
    linewidth = 1.2
  ) +
  geom_point(
    data = sleep_averages,
    aes(y = winddown_percent * scale_factor, group = 1),
    color = "darkred",
    size = 3
  ) +
  labs(
    title = "Weekly Sleep Composition and Winddown Percentage",
    x = "Day of Week",
    y = "Average Time in Bed (hours)",
    fill = "metric"
  ) +
  scale_y_continuous(
    name = "Average Time in Bed (hours)",
    sec.axis = sec_axis(~ . / scale_factor, name = "Winddown Percentage (%)")
  ) +
  scale_fill_manual(
    values = c("avg_sleep" = "steelblue", "avg_winddown" = "orange"),
    labels = c("avg_sleep" = "Time Asleep", "avg_winddown" = "Winddown Time")
  ) +
  theme_minimal() +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1),
    axis.title.y.right = element_text(color = "darkred")
  )
)

Even Fitbit users are not eager to let the weekend go. Sunday night shows the largest percentage of time in bed while not asleep, on average. Most users are only getting around the minimum recommended amount of sleep per night (~7 hr/night for adults) according to the Cleveland Clinic.

Our last visual evaluates usage rates across applications per day against the summarized average use rates.

print(ggplot(combined_distinct_data_with_percent, aes(x = Date, y = percent_of_users, color = source, group = source)) +
  geom_line(linewidth = 1) +
  geom_point(size = 2) +
  geom_hline(
    data = average_use_rate,
    aes(yintercept = average_percent, color = source),
    linetype = "dashed",
    linewidth = 1
  ) +
  labs(
    title = "Trends in Daily User Percentage by Source",
    subtitle = "Dashed lines represent the average use rate for each source",
    x = "Date",
    y = "User Percentage (%)",
    color = "Data Source"
  ) +
  theme_minimal() +
  scale_y_continuous(limits = c(0, 100)) +
  theme(
    axis.text.x = element_text(angle = 45, hjust = 1),
    legend.position = "bottom"
  )
)

6.2 Key Takeaways

In evaluating this dataset for user trends, my key takeaways were:

  • The manual applications are underutilized. Slightly more than half of the available users on any given day use the sleep functionality, and slightly over a quarter use the weight application.
  • 3rd-shift workers represent a poorly monetized market segment.
  • There is no statistically meaningful variance between days of the week from a usage perspective. Once purchased, the product is used throughout the week at about the same rate.

6.3 Summary

The Fitbit dataset was limited by time span and by available unique users. We learned most of the automated applications are used at a high rate by Fitbit owners. Of the applications that require additional user input, sleep is used about twice as much as weight. This suggests buyers interested in tracking their weight use a different application altogether, or represent a smaller-than-expected percentage of the target user population. In keeping with the name, Fitbit users seem less inclined to consider weight in the same way they do other stay-healthy metrics (the preponderance of users may already be fit).

Additional demographic data is needed to see who is using the device and to evaluate trends within defined population segments. Steps is the most direct correlation to burning calories we observed.

An opportunity to simplify downstream analysis by changing the schema and storage of Bellabeat user data exists. The database engineering team and data architects have an opportunity to produce a better end-user product simply by storing a date as a date datatype rather than a character string. The same applies to time, and it is likely not a significant time outlay to add weekday columns to the schema.

Most of the problem-solving skill applied here involved data transformation and moving work between environments. Several errors arose from naming conventions and unanticipated downstream effects — the kinds of issues associated with a first project. I leveraged generative AI to help close the gap between my skills at the time and the ability to turn curiosity into meaningful visuals.

7 ACT

7.1 Next Steps

  1. Marketing Team: Develop qualitative and quantitative surveys for demographic data on Fitbit or similar wellness wearables, along with a user feedback survey covering all available applications.
  2. Programming Team: Develop and deploy a voice-command feature for all manual applications. Track and report the resulting change in usage rates to ownership.
  3. Database Architect / Engineering Team: Change the data storage structure for date and time data to store as date and time types. Edit the schema to include a weekday column.
  4. Marketing Team: Create tailored marketing campaigns for underrepresented target market segments.
  5. Marketing Team: Evaluate the efficacy of the approach through a second series of surveys and user feedback.