---
title: "Managing Factors with forcats"
author: "IND218"
date: today
format:
html:
toc: true
toc-depth: 3
code-fold: false
code-tools: true
---
## Introduction to Factors
Factors represent categorical data using a set of allowed values called
**levels**. Examples include survey responses (Poor/Fair/Good/Excellent),
education levels (High School/Bachelor's/Master's/PhD), and product categories.
Unlike character vectors, factors can retain categories that do not occur in the
current observations and can record a meaningful display or ranking order. The
`forcats` package provides focused tools for changing those levels.
```{r}
#| label: setup
#| message: false
library(tidyverse)
library(forcats)
# All forcats functions start with fct_ for easy discovery
cat("Key forcats functions we'll explore:\n")
cat("- fct_count(): count factor levels\n")
cat("- fct_reorder(): reorder by another variable\n")
cat("- fct_relevel(): manually reorder levels\n")
cat("- fct_recode(): change level names\n")
cat("- fct_collapse(): combine levels\n")
cat("- fct_lump(): combine rare levels\n")
cat("- fct_drop(): remove unused levels\n")
```
## Understanding Factors vs Characters
### Creating and Examining Factors
```{r}
#| label: factor-basics
# Character vector vs factor
satisfaction_char <- c("Good", "Excellent", "Poor", "Good", "Fair", "Excellent")
satisfaction_factor <- factor(satisfaction_char,
levels = c("Poor", "Fair", "Good", "Excellent"),
ordered = TRUE
)
# Compare the two
cat("Character vector:\n")
print(satisfaction_char)
print(table(satisfaction_char))
cat("\nFactor with ordered levels:\n")
print(satisfaction_factor)
print(table(satisfaction_factor))
# Notice how factors maintain level order even for missing categories
satisfaction_subset <- satisfaction_factor[1:3] # Only has "Good", "Excellent", "Poor"
print(table(satisfaction_subset)) # Still shows all 4 levels
```
The displayed labels are the levels; internally, R stores integer codes that
point to those labels. Because this factor is ordered, comparisons such as
`satisfaction_factor[1] > satisfaction_factor[3]` are meaningful. Do not use
`as.numeric()` to recover labels: it returns the internal codes, not the text or
the numeric values that the labels might resemble.
### Factor Properties
```{r}
#| label: factor-properties
# Examine factor structure
education <- factor(c("High School", "Bachelor's", "Master's", "Bachelor's", "PhD"),
levels = c("High School", "Bachelor's", "Master's", "PhD"))
# Key factor properties
levels(education) # The possible values
nlevels(education) # Number of levels
is.factor(education) # Check if it's a factor
as.character(education) # Convert back to character
as.numeric(education) # See underlying numeric codes
# Factor summary
summary(education)
fct_count(education) # forcats way to count
```
## Basic Factor Operations with forcats
### Counting and Inspecting Factors
```{r}
#| label: factor-counting
# Sample survey data
survey_data <- tibble(
id = 1:20,
satisfaction = sample(c("Very Dissatisfied", "Dissatisfied", "Neutral",
"Satisfied", "Very Satisfied"), 20, replace = TRUE),
department = sample(c("Sales", "Marketing", "IT", "HR", "Finance"), 20, replace = TRUE),
experience = sample(c("0-2 years", "3-5 years", "6-10 years", "10+ years"), 20, replace = TRUE)
)
# Convert to factors with proper ordering
survey_data <- survey_data %>%
mutate(
satisfaction = factor(satisfaction,
levels = c("Very Dissatisfied", "Dissatisfied", "Neutral",
"Satisfied", "Very Satisfied"),
ordered = TRUE),
department = factor(department),
experience = factor(experience,
levels = c("0-2 years", "3-5 years", "6-10 years", "10+ years"),
ordered = TRUE)
)
# Count factor levels
fct_count(survey_data$satisfaction)
fct_count(survey_data$department)
fct_count(survey_data$experience)
# Count with sorting
fct_count(survey_data$department, sort = TRUE)
```
### Reordering Factor Levels
```{r}
#| label: factor-reordering
# Create sample sales data by region
sales_data <- tibble(
region = c("North", "South", "East", "West", "North", "South", "East", "West"),
quarter = rep(c("Q1", "Q2"), 4),
sales = c(120, 95, 110, 88, 135, 102, 118, 92)
)
# Convert region to factor
sales_data$region <- factor(sales_data$region)
# Default factor order (alphabetical)
levels(sales_data$region)
# Reorder by total sales (descending)
sales_by_region <- sales_data %>%
group_by(region) %>%
summarise(total_sales = sum(sales), .groups = "drop") %>%
mutate(region = fct_reorder(region, total_sales, .desc = TRUE))
print(sales_by_region)
levels(sales_by_region$region)
# Reorder by another variable within groups
sales_data_ordered <- sales_data %>%
mutate(region = fct_reorder(region, sales, .fun = mean, .desc = TRUE))
print(sales_data_ordered)
# Manual reordering
sales_data$region <- fct_relevel(sales_data$region, "West", "North", "East", "South")
levels(sales_data$region)
```
### Recoding Factor Levels
```{r}
#| label: factor-recoding
# Sample product categories
products <- tibble(
item = 1:15,
category = sample(c("Electronics", "Clothing", "Home", "Sports", "Books"), 15, replace = TRUE),
subcategory = sample(c("Phone", "Laptop", "Shirt", "Pants", "Furniture",
"Basketball", "Soccer", "Fiction", "Non-fiction"), 15, replace = TRUE)
)
# Convert to factor
products$category <- factor(products$category)
# Recode factor levels
products$category_recoded <- fct_recode(products$category,
"Tech" = "Electronics",
"Apparel" = "Clothing",
"Home & Garden" = "Home",
"Sporting Goods" = "Sports"
# "Books" stays the same (not mentioned)
)
# Compare original and recoded
comparison <- products %>%
count(category, category_recoded)
print(comparison)
# Recode with error checking (forcats will warn about typos)
# This would give a warning:
# products$category_bad <- fct_recode(products$category, "Tech" = "Electronic") # Note the typo
```
## Advanced Factor Manipulation
### Collapsing Factor Levels
```{r}
#| label: factor-collapsing
# Create detailed job title data
job_data <- tibble(
employee_id = 1:25,
job_title = sample(c("Software Engineer I", "Software Engineer II", "Senior Software Engineer",
"Marketing Specialist", "Marketing Manager", "Senior Marketing Manager",
"Sales Rep", "Senior Sales Rep", "Sales Manager",
"HR Specialist", "HR Manager", "Data Analyst", "Senior Data Analyst"),
25, replace = TRUE),
salary = runif(25, 50000, 120000)
)
job_data$job_title <- factor(job_data$job_title)
# Original levels
cat("Original job titles:\n")
fct_count(job_data$job_title, sort = TRUE)
# Collapse into broader categories
job_data$department <- fct_collapse(job_data$job_title,
"Engineering" = c("Software Engineer I", "Software Engineer II", "Senior Software Engineer"),
"Marketing" = c("Marketing Specialist", "Marketing Manager", "Senior Marketing Manager"),
"Sales" = c("Sales Rep", "Senior Sales Rep", "Sales Manager"),
"HR" = c("HR Specialist", "HR Manager"),
"Analytics" = c("Data Analyst", "Senior Data Analyst")
)
cat("\nCollapsed into departments:\n")
fct_count(job_data$department, sort = TRUE)
# More complex collapsing with grouping
job_data$seniority <- fct_collapse(job_data$job_title,
"Senior" = c("Senior Software Engineer", "Senior Marketing Manager",
"Senior Sales Rep", "Senior Data Analyst"),
"Manager" = c("Marketing Manager", "Sales Manager", "HR Manager"),
"Individual Contributor" = c("Software Engineer I", "Software Engineer II",
"Marketing Specialist", "Sales Rep", "HR Specialist", "Data Analyst")
)
cat("\nCollapsed by seniority:\n")
fct_count(job_data$seniority, sort = TRUE)
```
### Lumping Rare Categories
```{r}
#| label: factor-lumping
# Create data with many categories, some rare
customer_data <- tibble(
customer_id = 1:100,
state = sample(c("CA", "TX", "NY", "FL", "IL", "PA", "OH", "MI", "GA", "NC",
"NJ", "VA", "WA", "AZ", "MA", "TN", "IN", "MO", "MD", "WI",
"MN", "CO", "AL", "SC", "LA", "OR", "OK", "CT", "AR", "MS"),
100, replace = TRUE,
prob = c(rep(0.08, 5), rep(0.04, 5), rep(0.02, 10), rep(0.01, 10))),
purchase_amount = runif(100, 10, 500)
)
customer_data$state <- factor(customer_data$state)
# Original state distribution
cat("Original state distribution:\n")
state_counts <- fct_count(customer_data$state, sort = TRUE)
print(state_counts)
# Lump least frequent states into "Other"
customer_data$state_lumped <- fct_lump_n(customer_data$state, n = 10) # Keep top 10
cat("\nAfter lumping (keep top 10):\n")
fct_count(customer_data$state_lumped, sort = TRUE)
# Lump states with fewer than 3 customers
customer_data$state_min <- fct_lump_min(customer_data$state, min = 3)
cat("\nAfter lumping (minimum 3 customers):\n")
fct_count(customer_data$state_min, sort = TRUE)
# Lump by proportion (keep states with at least 5% of customers)
customer_data$state_prop <- fct_lump_prop(customer_data$state, prop = 0.05)
cat("\nAfter lumping (minimum 5% proportion):\n")
fct_count(customer_data$state_prop, sort = TRUE)
```
### Handling Missing Levels and NA Values
```{r}
#| label: factor-missing
# Create data with unused levels and NA values
rating_data <- tibble(
product_id = 1:20,
rating = sample(c("1 Star", "2 Stars", "3 Stars", "4 Stars", "5 Stars", NA),
20, replace = TRUE)
)
# Create factor with extra levels
rating_data$rating <- factor(rating_data$rating,
levels = c("1 Star", "2 Stars", "3 Stars", "4 Stars", "5 Stars", "6 Stars"))
cat("Original factor with unused level:\n")
print(levels(rating_data$rating))
print(summary(rating_data$rating))
# Drop unused levels
rating_data$rating_clean <- fct_drop(rating_data$rating)
cat("\nAfter dropping unused levels:\n")
print(levels(rating_data$rating_clean))
print(summary(rating_data$rating_clean))
# Handle NA values explicitly with the current forcats helper
rating_data$rating_with_na <- fct_na_value_to_level(
rating_data$rating_clean,
level = "No Rating"
)
cat("\nAfter making NA explicit:\n")
print(summary(rating_data$rating_with_na))
# Demonstrate NA handling workflow
# First, convert NA values to explicit level
rating_data$rating_with_unknown <- fct_na_value_to_level(rating_data$rating_clean, level = "Unknown")
cat("\nAfter converting NA to 'Unknown' level:\n")
print(summary(rating_data$rating_with_unknown))
# Convert explicit NA level back to actual NA values
# Note: fct_na_level_to_value() converts the default "(Missing)" level back to NA
rating_data$rating_with_missing <- fct_na_value_to_level(rating_data$rating_clean)
rating_data$rating_back_to_na <- fct_na_level_to_value(rating_data$rating_with_missing)
cat("\nAfter converting '(Missing)' level back to NA:\n")
print(summary(rating_data$rating_back_to_na))
```
## Real-World Factor Applications
### Example 1: Survey Data Analysis
```{r}
#| label: survey-analysis
# Create realistic survey data
set.seed(123)
survey_responses <- tibble(
respondent_id = 1:200,
age_group = sample(c("18-25", "26-35", "36-45", "46-55", "56-65", "65+"), 200, replace = TRUE),
education = sample(c("High School", "Some College", "Bachelor's", "Master's", "PhD"),
200, replace = TRUE, prob = c(0.3, 0.2, 0.3, 0.15, 0.05)),
income = sample(c("Under $30k", "$30k-$50k", "$50k-$75k", "$75k-$100k", "Over $100k"),
200, replace = TRUE),
satisfaction = sample(c("Very Dissatisfied", "Dissatisfied", "Neutral", "Satisfied", "Very Satisfied"),
200, replace = TRUE, prob = c(0.05, 0.15, 0.2, 0.4, 0.2))
)
# Convert to properly ordered factors
survey_responses <- survey_responses %>%
mutate(
age_group = factor(age_group, levels = c("18-25", "26-35", "36-45", "46-55", "56-65", "65+")),
education = factor(education, levels = c("High School", "Some College", "Bachelor's", "Master's", "PhD"),
ordered = TRUE),
income = factor(income, levels = c("Under $30k", "$30k-$50k", "$50k-$75k", "$75k-$100k", "Over $100k"),
ordered = TRUE),
satisfaction = factor(satisfaction, levels = c("Very Dissatisfied", "Dissatisfied", "Neutral",
"Satisfied", "Very Satisfied"), ordered = TRUE)
)
# Analyze satisfaction by demographics
cat("Satisfaction by Age Group:\n")
satisfaction_by_age <- survey_responses %>%
count(age_group, satisfaction) %>%
group_by(age_group) %>%
mutate(prop = n / sum(n)) %>%
filter(satisfaction %in% c("Satisfied", "Very Satisfied")) %>%
summarise(satisfaction_rate = sum(prop), .groups = "drop") %>%
mutate(age_group = fct_reorder(age_group, satisfaction_rate))
print(satisfaction_by_age)
# Collapse education levels for analysis
survey_responses$education_simple <- fct_collapse(survey_responses$education,
"High School or Less" = c("High School"),
"Some College" = c("Some College"),
"College Graduate" = c("Bachelor's"),
"Advanced Degree" = c("Master's", "PhD")
)
cat("\nSatisfaction by Education Level:\n")
satisfaction_by_education <- survey_responses %>%
count(education_simple, satisfaction) %>%
group_by(education_simple) %>%
mutate(prop = round(n / sum(n), 3)) %>%
print()
```
### Example 2: Product Category Management
```{r}
#| label: product-categories
# Create e-commerce product data
products_detailed <- tibble(
product_id = 1:150,
category = sample(c("Electronics > Smartphones", "Electronics > Laptops", "Electronics > Tablets",
"Clothing > Men's Shirts", "Clothing > Women's Dresses", "Clothing > Children's",
"Home > Kitchen", "Home > Bedroom", "Home > Living Room",
"Books > Fiction", "Books > Non-Fiction", "Books > Textbooks",
"Sports > Basketball", "Sports > Soccer", "Sports > Tennis"),
150, replace = TRUE, prob = c(rep(0.1, 3), rep(0.08, 3), rep(0.07, 3),
rep(0.05, 3), rep(0.03, 3))),
price = round(runif(150, 10, 1000), 2),
sales_volume = sample(1:100, 150, replace = TRUE)
)
products_detailed$category <- factor(products_detailed$category)
cat("Original detailed categories:\n")
fct_count(products_detailed$category, sort = TRUE)
# Extract main categories
products_detailed$main_category <- products_detailed$category %>%
str_extract("^[^>]+") %>%
str_trim() %>%
factor()
cat("\nMain categories:\n")
fct_count(products_detailed$main_category, sort = TRUE)
# Reorder categories by average price
products_detailed$main_category_by_price <- fct_reorder(products_detailed$main_category,
products_detailed$price,
.fun = mean, .desc = TRUE)
cat("\nCategories ordered by average price:\n")
products_detailed %>%
group_by(main_category_by_price) %>%
summarise(avg_price = round(mean(price), 2), .groups = "drop") %>%
print()
# Lump low-volume categories
products_detailed$category_lumped <- fct_lump_n(products_detailed$category, n = 8,
w = products_detailed$sales_volume)
cat("\nAfter lumping low-volume categories:\n")
fct_count(products_detailed$category_lumped, sort = TRUE)
# Create performance categories
products_detailed <- products_detailed %>%
group_by(main_category) %>%
mutate(
revenue = price * sales_volume,
category_performance = case_when(
revenue > quantile(revenue, 0.75) ~ "High Performer",
revenue > quantile(revenue, 0.25) ~ "Medium Performer",
TRUE ~ "Low Performer"
)
) %>%
ungroup()
products_detailed$category_performance <- factor(products_detailed$category_performance,
levels = c("Low Performer", "Medium Performer", "High Performer"),
ordered = TRUE)
cat("\nPerformance distribution:\n")
fct_count(products_detailed$category_performance)
```
### Example 3: Geographic Data Processing
```{r}
#| label: geographic-factors
# Create customer location data
customer_locations <- tibble(
customer_id = 1:300,
country = sample(c("United States", "Canada", "United Kingdom", "Germany", "France",
"Australia", "Japan", "Brazil", "Mexico", "India", "China", "Other"),
300, replace = TRUE,
prob = c(0.4, 0.15, 0.1, 0.08, 0.06, 0.05, 0.04, 0.03, 0.03, 0.02, 0.02, 0.02)),
region = case_when(
country %in% c("United States", "Canada", "Mexico") ~ "North America",
country %in% c("United Kingdom", "Germany", "France") ~ "Europe",
country %in% c("Australia", "Japan") ~ "Asia-Pacific",
country %in% c("Brazil") ~ "South America",
country %in% c("India", "China") ~ "Asia",
TRUE ~ "Other"
),
order_value = round(runif(300, 20, 500), 2)
)
# Convert to factors with logical ordering
customer_locations$country <- factor(customer_locations$country)
customer_locations$region <- factor(customer_locations$region)
# Reorder countries by total order value
country_summary <- customer_locations %>%
group_by(country) %>%
summarise(
total_orders = n(),
total_value = sum(order_value),
avg_value = mean(order_value),
.groups = "drop"
) %>%
mutate(country = fct_reorder(country, total_value, .desc = TRUE))
cat("Countries by total order value:\n")
print(country_summary)
# Lump smaller countries by order volume
customer_locations$country_lumped <- fct_lump_n(customer_locations$country, n = 6,
w = customer_locations$order_value)
cat("\nAfter lumping smaller countries:\n")
fct_count(customer_locations$country_lumped, sort = TRUE)
# Create market size categories
market_sizes <- customer_locations %>%
group_by(country) %>%
summarise(market_size = sum(order_value), .groups = "drop") %>%
mutate(
size_category = case_when(
market_size > 5000 ~ "Large Market",
market_size > 2000 ~ "Medium Market",
TRUE ~ "Small Market"
)
)
customer_locations <- customer_locations %>%
left_join(market_sizes %>% select(country, size_category), by = "country")
customer_locations$size_category <- factor(customer_locations$size_category,
levels = c("Small Market", "Medium Market", "Large Market"),
ordered = TRUE)
cat("\nMarket size distribution:\n")
fct_count(customer_locations$size_category)
```
## Factor Visualization and Analysis
### Preparing Factors for Visualization
```{r}
#| label: factor-visualization
# Create sample data for visualization
employee_data <- tibble(
department = sample(c("Engineering", "Sales", "Marketing", "HR", "Finance", "Operations"),
100, replace = TRUE),
performance_rating = sample(c("Needs Improvement", "Meets Expectations", "Exceeds Expectations", "Outstanding"),
100, replace = TRUE, prob = c(0.1, 0.4, 0.4, 0.1)),
salary = round(rnorm(100, 75000, 15000)),
years_experience = sample(1:20, 100, replace = TRUE)
)
# Convert to factors with proper ordering
employee_data <- employee_data %>%
mutate(
department = factor(department),
performance_rating = factor(performance_rating,
levels = c("Needs Improvement", "Meets Expectations",
"Exceeds Expectations", "Outstanding"),
ordered = TRUE)
)
# Reorder departments by median salary for better visualization
employee_data$department_ordered <- fct_reorder(employee_data$department,
employee_data$salary,
.fun = median)
# Create summary for visualization
dept_summary <- employee_data %>%
group_by(department_ordered, performance_rating) %>%
summarise(
count = n(),
avg_salary = round(mean(salary)),
.groups = "drop"
)
cat("Department performance summary (ordered by median salary):\n")
print(dept_summary)
# Show factor levels in order
cat("\nDepartments ordered by median salary:\n")
print(levels(employee_data$department_ordered))
```
## Best Practices and Common Pitfalls
### Factor Best Practices
```{r}
#| label: factor-best-practices
# Best Practice 1: Always specify levels explicitly for ordered data
# Good
satisfaction_good <- factor(c("Poor", "Good", "Excellent"),
levels = c("Poor", "Fair", "Good", "Excellent"))
# Avoid: Letting R determine order alphabetically
satisfaction_bad <- factor(c("Poor", "Good", "Excellent"))
cat("Good approach - explicit levels:\n")
print(levels(satisfaction_good))
cat("\nBad approach - alphabetical:\n")
print(levels(satisfaction_bad))
# Best Practice 2: Use ordered factors for ordinal data
education_ordered <- factor(c("High School", "Bachelor's", "Master's"),
levels = c("High School", "Bachelor's", "Master's", "PhD"),
ordered = TRUE)
# Best Practice 3: Handle missing data explicitly
survey_with_na <- c("Yes", "No", "Yes", NA, "No")
survey_factor <- factor(survey_with_na)
survey_explicit <- fct_na_value_to_level(survey_factor, level = "No Response")
cat("\nHandling missing data:\n")
print(summary(survey_factor))
print(summary(survey_explicit))
```
### Common Mistakes
```{r}
#| label: factor-mistakes
# Mistake 1: Converting factors to numeric incorrectly
rating_factor <- factor(c("1", "2", "3", "4", "5"))
# Wrong way (gets the underlying codes, not the values)
wrong_numeric <- as.numeric(rating_factor)
cat("Wrong conversion to numeric:\n")
print(wrong_numeric)
# Right way
right_numeric <- as.numeric(as.character(rating_factor))
cat("Correct conversion to numeric:\n")
print(right_numeric)
# Mistake 2: Not dropping unused levels
original_data <- factor(c("A", "B", "C", "A", "B"))
subset_data <- original_data[1:2] # Only A and B
cat("\nSubset still has unused level C:\n")
print(levels(subset_data))
print(table(subset_data))
# Fix by dropping unused levels
subset_clean <- fct_drop(subset_data)
cat("\nAfter dropping unused levels:\n")
print(levels(subset_clean))
print(table(subset_clean))
# Mistake 3: Inconsistent level names
messy_categories <- c("Category A", "category_a", "CATEGORY A", "Category A")
factor(messy_categories) # Creates 3 different levels!
# Better: Clean first, then convert to factor
clean_categories <- str_to_title(str_replace_all(messy_categories, "[_\\s]+", " "))
factor(clean_categories)
```
## Exercises
### Exercise 1: Survey Data Processing
Given survey responses with inconsistent category names:
1. Clean and standardize the response categories
2. Create proper ordered factors for Likert scales
3. Collapse detailed categories into broader groups
4. Handle missing responses appropriately
### Exercise 2: Sales Data Analysis
You have sales data with product categories:
1. Reorder categories by sales performance
2. Lump low-performing categories into "Other"
3. Create high/medium/low performance tiers
4. Prepare the data for visualization
### Exercise 3: Geographic Analysis
Working with customer location data:
1. Standardize country and region names
2. Group countries into major markets
3. Order regions by customer value
4. Create market size categories
### Exercise 4: Factor Validation Pipeline
Create a data validation system for categorical data:
1. Detect and fix inconsistent category names
2. Identify and handle unexpected categories
3. Ensure proper factor ordering
4. Generate validation reports
## Summary
The `forcats` package makes factor manipulation intuitive and powerful:
### Key Functions:
- **Inspection**: `fct_count()`, `levels()`, `nlevels()`
- **Reordering**: `fct_reorder()`, `fct_relevel()`, `fct_rev()`
- **Changing levels**: `fct_recode()`, `fct_collapse()`, `fct_lump()`
- **Missing data**: `fct_na_value_to_level()`, `fct_na_level_to_value()`, `fct_drop()`
### Best Practices:
- **Specify levels explicitly** for ordered data
- **Use ordered factors** for ordinal variables
- **Handle missing data** consciously
- **Drop unused levels** after subsetting
- **Clean data before** converting to factors
### Common Applications:
- **Survey analysis**: Proper ordering of response scales
- **Data visualization**: Reordering for better plots
- **Reporting**: Grouping categories for summaries
- **Machine learning**: Preparing categorical variables
### Remember:
- Factors preserve level order even when subsetting
- Proper factor handling improves visualization and analysis
- forcats integrates seamlessly with dplyr and ggplot2
- Always validate factor levels in real-world data
Factors are essential for working with categorical data effectively. With forcats, you can handle even complex categorical data scenarios with confidence!
Next: **[Working with Dates and Times using lubridate](dates-lubridate.qmd)**