The most important step in data science
In real data science, 70% of time is spent on data cleaning. This week teaches you how to clean data professionally. REALITY OF DATA SCIENCE: Most datasets are "messy":
CLEANING WORKFLOW:
KEY GOALS THIS WEEK: ✓ Identify data quality issues ✓ Remove/handle missing values appropriately ✓ Detect and handle duplicates ✓ Fix inconsistent data types ✓ Standardize categorical values ✓ Handle dates and times ✓ Detect and handle outliers ✓ Validate cleaned data ✓ Document cleaning process ✓ Create reproducible cleaning pipeline
MISSING DATA - FUNDAMENTAL PROBLEM:
Why data is missing:
TYPES OF MISSING:
IDENTIFYING MISSING DATA:
df <- tibble( name = c("Alice", "Bob", NA, "David"), age = c(25, NA, 28, 30), score = c(85, 90, 78, NA) ) # Check for missing is.na(df$age) # [1] FALSE TRUE FALSE FALSE sum(is.na(df)) # Total missing values = 3 colSums(is.na(df)) # Missing per column rowSums(is.na(df)) # Missing per row # Percentage missing colMeans(is.na(df)) * 100 # 33% age, 33% score # Visualize missing pattern library(VIM) aggr(df, prop = TRUE) # Shows missing pattern
HANDLING MISSING VALUES:
OPTION 1: REMOVE (Safest, but lose data)
# Remove rows with ANY missing value df_clean <- df |> drop_na() # Remove rows missing in specific column df_clean <- df |> drop_na(age) # Remove rows missing in any of these columns df_clean <- df |> drop_na(age, score) # When to use: Missing is MCAR, small amount missing (<5%)
OPTION 2: IMPUTATION (Keep data, estimate values)
# Mean imputation (for numeric) df_imputed <- df |> mutate(age = replace_na(age, mean(age, na.rm=TRUE))) # Forward/backward fill (for time series) df_imputed <- df |> fill(age, .direction = "down") # Forward fill # Multiple imputation (advanced, better) library(mice) df_imputed <- complete(mice(df, m=5))
OPTION 3: KEEP AS SPECIAL CATEGORY (for categorical)
# Create new category for missing df <- df |> mutate(age_category = case_when( age < 30 ~ "Young", age >= 30 ~ "Adult", is.na(age) ~ "Unknown" ))
BEST PRACTICES:
✗ Don't silently remove data ✗ Don't always use mean imputation
DUPLICATES - ANOTHER COMMON PROBLEM:
df <- tibble( id = c(1, 1, 2, 2, 3), name = c("Alice", "Alice", "Bob", "Bob", "Charlie") )
# Detect duplicates duplicated(df) # [1] F T F T F which(duplicated(df)) # [1] 2 4 (row numbers)
# Count duplicates per ID df |> group_by(id) |> filter(n() > 1) # Shows duplicated records
# Remove exact duplicates (keep first) df_unique <- df |> distinct()
# Remove duplicates by specific column df_unique <- df |> distinct(id, .keep_all = TRUE)
# Find and investigate duplicates BEFORE removing df_dup <- df |> filter(duplicated(id)) |> arrange(id) # INVESTIGATE - Why duplicated? Bug? Real records?
# Then decide how to handle
HANDLING DUPLICATES:
# Option 1: Keep only first occurrence df_clean <- df |> distinct(id, .keep_all = TRUE)
# Option 2: Keep record with most complete data df_clean <- df |> mutate(missing_count = rowSums(is.na(across(everything())))) |> group_by(id) |> slice_min(missing_count) |> ungroup() |> select(-missing_count)
# Option 3: Aggregate duplicates df_summary <- df |> group_by(id) |> summarize( name = first(name), # Keep first count = n() # Count occurrences )
WRONG DATA TYPES - SILENT KILLER:
# Load data - everything becomes character! df <- read_csv("data.csv") # Check types str(df) # May see: age = "25" instead of numeric # FIX: Specify types when reading df <- read_csv("data.csv", col_types = cols( age = col_integer(), salary = col_double(), hire_date = col_date(), department = col_factor() ) ) # Fix after loading df <- df |> mutate( age = as.numeric(age), score = as.integer(score), employed = as.logical(employed), hire_date = as.Date(hire_date) ) # Problems with conversion as.numeric("25") # [1] 25 (works!) as.numeric("25 years") # [1] NA (fails!) as.numeric("$1,000") # [1] NA (fails!) # Solution: Clean first, then convert df <- df |> mutate( age_clean = str_remove(age, " years"), age = as.numeric(age_clean) ) |> select(-age_clean)
STANDARDIZING TEXT/CATEGORIES:
# Inconsistent categories - common problem df <- tibble( city = c("New York", "new york", "NYC", "ny", "NEW YORK"), status = c("Active", "active", "ACTIVE", "inactive", "Active") ) # Standardize: lowercase df <- df |> mutate(city = tolower(city)) # Result: "new york", "new york", "nyc", "ny", "new york" # Standardize: Create mapping city_mapping <- c( "new york" = "New York", "nyc" = "New York", "ny" = "New York", "new york" = "New York" ) df <- df |> mutate(city = tolower(city)) |> mutate(city = recode(city, !!!city_mapping)) # Better: Use factor with levels df <- df |> mutate(status = factor(tolower(status), levels = c("active", "inactive"))) # Trim whitespace (very common issue!) df <- df |> mutate(across(where(is.character), str_trim)) # Remove special characters df <- df |> mutate(name = str_remove_all(name, "[^a-zA-Z ]"))
DATES AND TIMES:
# Common date formats that fail dates <- c("01/15/2023", "2023-01-15", "15-01-2023") # Specify format explicitly dates_fixed <- as.Date(dates, format = "%m/%d/%Y") # OR dates_fixed <- as.Date(dates, format = "%Y-%m-%d") # lubridate package (easier!) library(lubridate) dates_fixed <- mdy(c("01/15/2023", "01-15-2023")) dates_fixed <- ymd("2023-01-15") dates_fixed <- dmy("15-01-2023") # Extract parts of dates today <- Sys.Date() year(today) # [1] 2024 month(today) # [1] 1 day(today) # [1] 15 wday(today) # [1] 2 (Monday) week(today) # [1] 3 (week number) # Calculate differences df <- tibble( hire_date = ymd("2020-01-15"), today_date = Sys.Date() ) df <- df |> mutate(tenure_years = as.numeric( today_date - hire_date) / 365.25)
OUTLIERS - EXTREME VALUES:
df <- tibble( age = c(25, 28, 30, 125, 32, 29), # 125 is outlier salary = c(50000, 55000, 60000, 200000, 58000, 52000) ) # Detect outliers - IQR method detect_outliers_iqr <- function(x) { Q1 <- quantile(x, 0.25, na.rm=TRUE) Q3 <- quantile(x, 0.75, na.rm=TRUE) IQR <- Q3 - Q1 lower <- Q1 - 1.5 * IQR upper <- Q3 + 1.5 * IQR x < lower | x > upper } # Find outliers df |> mutate(is_outlier_age = detect_outliers_iqr(age), is_outlier_sal = detect_outliers_iqr(salary)) # Z-score method df <- df |> mutate(age_zscore = scale(age)[,1], is_outlier = abs(age_zscore) > 3) # Visualize outliers library(ggplot2) ggplot(df, aes(x = age)) + geom_boxplot() + # Shows outliers geom_point(aes(color = is_outlier)) # DECIDE: Keep or remove? # - Keep if real (e.g., CEO salary) # - Remove if data error (e.g., age = 125) # - Document decision!