Lecture 2: Data Handling, Manipulation, and Visualization

Statistical Programming
Prof. Dr. Martin Spindler

\[ \newcommand{\E}{{\mathbb{E}}} \newcommand{\R}{{\mathbb{R}}} \]

The Data Science Pipeline

Data science projects typically follow these steps:

  1. Data Collection: Gathering data from various sources
  2. Data Cleaning: Handling missing values, duplicates, errors
  3. Data Manipulation: Filtering, selecting, transforming
  4. Data Analysis: Computing statistics, testing hypotheses
  5. Data Visualization: Creating plots to understand patterns
  6. Modeling: Building predictive or explanatory models
  7. Communication: Presenting results and insights

Introduction to Data Science Workflow

Why is Data Handling Important?

  • Real-world data is messy: missing values, inconsistencies, outliers
  • Data often needs to be reshaped before analysis
  • Good data handling leads to reliable results
  • 80% of data science is data preparation, only 20% is modeling

The 80/20 Rule (The Painful Truth)

“Data cleaning and preparation are not glamorous, but they are absolutely critical to the success of any data analysis project.”

In reality: Professional Data Janitor 🧹

XKCD #1838

Loading and Exploring Data

The Star Wars Dataset

About the Dataset

We’ll use the Star Wars characters dataset from the tidyverse package:

  • Contains information about 87 characters from the Star Wars universe
  • Variables: name, height, mass, hair color, species, homeworld, etc.
  • Great for learning data manipulation techniques
  • Includes missing values (realistic!)

Loading Data from URL

Code
import pandas as pd
import numpy as np

# Load data directly from GitHub
url = 'https://raw.githubusercontent.com/tidyverse/dplyr/refs/heads/main/data-raw/starwars.csv'
starwars = pd.read_csv(url)

print("Data loaded successfully!")
print(f"Shape: {starwars.shape[0]} rows × {starwars.shape[1]} columns")
Data loaded successfully!
Shape: 87 rows × 14 columns

Initial Data Exploration

First Look at the Data

Always start by viewing your data:

Code
# First 5 rows
starwars.head().style.set_properties(**{'white-space': 'nowrap'})
  name height mass hair_color skin_color eye_color birth_year sex gender homeworld species films vehicles starships
0 Luke Skywalker 172.000000 77.000000 blond fair blue 19.000000 male masculine Tatooine Human A New Hope, The Empire Strikes Back, Return of the Jedi, Revenge of the Sith, The Force Awakens Snowspeeder, Imperial Speeder Bike X-wing, Imperial shuttle
1 C-3PO 167.000000 75.000000 nan gold yellow 112.000000 none masculine Tatooine Droid A New Hope, The Empire Strikes Back, Return of the Jedi, The Phantom Menace, Attack of the Clones, Revenge of the Sith nan nan
2 R2-D2 96.000000 32.000000 nan white, blue red 33.000000 none masculine Naboo Droid A New Hope, The Empire Strikes Back, Return of the Jedi, The Phantom Menace, Attack of the Clones, Revenge of the Sith, The Force Awakens nan nan
3 Darth Vader 202.000000 136.000000 none white yellow 41.900000 male masculine Tatooine Human A New Hope, The Empire Strikes Back, Return of the Jedi, Revenge of the Sith nan TIE Advanced x1
4 Leia Organa 150.000000 49.000000 brown light brown 19.000000 female feminine Alderaan Human A New Hope, The Empire Strikes Back, Return of the Jedi, Revenge of the Sith, The Force Awakens Imperial Speeder Bike nan

Initial Data Exploration

Understanding Data Structure

Use .info() to see data types and missing values:

Code
# Data structure overview
starwars.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 87 entries, 0 to 86
Data columns (total 14 columns):
 #   Column      Non-Null Count  Dtype  
---  ------      --------------  -----  
 0   name        87 non-null     object 
 1   height      81 non-null     float64
 2   mass        59 non-null     float64
 3   hair_color  82 non-null     object 
 4   skin_color  87 non-null     object 
 5   eye_color   87 non-null     object 
 6   birth_year  43 non-null     float64
 7   sex         83 non-null     object 
 8   gender      83 non-null     object 
 9   homeworld   77 non-null     object 
 10  species     83 non-null     object 
 11  films       87 non-null     object 
 12  vehicles    11 non-null     object 
 13  starships   20 non-null     object 
dtypes: float64(3), object(11)
memory usage: 9.6+ KB

Initial Data Exploration

Key Observations

From the .info() output, we can see:

  • 87 entries (characters)
  • 14 columns with different data types
  • Missing values in several columns:
    • hair_color: 5 missing
    • height: 6 missing
    • mass: 28 missing
    • birth_year: 44 missing
    • sex: 4 missing

Warning

Missing data is common in real datasets! We need to handle it appropriately.

Summary Statistics

Numerical Summaries

Use .describe() for numerical columns:

Code
# Summary statistics for numerical columns
starwars.describe()
height mass birth_year
count 81.000000 59.000000 43.000000
mean 174.604938 97.311864 87.565116
std 34.774157 169.457163 154.691439
min 66.000000 15.000000 8.000000
25% 167.000000 55.600000 35.000000
50% 180.000000 79.000000 52.000000
75% 191.000000 84.500000 72.000000
max 264.000000 1358.000000 896.000000

Summary Statistics

Interpreting the Summary

Key insights from the numerical summary:

  • Height: ranges from 66 cm to 264 cm (wide variation!)
  • Mass: ranges from 15 kg to 1,358 kg (Jabba the Hutt?)
  • Birth year: characters born across ~900 years
  • Missing values: note the count is different for each variable

Column Names and Types

Code
# List all column names
print("Column names:")
print(starwars.columns.tolist())
print("\nData types:")
print(starwars.dtypes)
Column names:
['name', 'height', 'mass', 'hair_color', 'skin_color', 'eye_color', 'birth_year', 'sex', 'gender', 'homeworld', 'species', 'films', 'vehicles', 'starships']

Data types:
name           object
height        float64
mass          float64
hair_color     object
skin_color     object
eye_color      object
birth_year    float64
sex            object
gender         object
homeworld      object
species        object
films          object
vehicles       object
starships      object
dtype: object

Data Cleaning

Handling Missing Values

Checking for Missing Data

Identify missing values systematically:

Code
# Count missing values per column
missing_counts = starwars.isnull().sum()
print("Missing values per column:")
print(missing_counts[missing_counts > 0])

# Percentage of missing values
missing_pct = (starwars.isnull().sum() / len(starwars) * 100).round(2)
print("\nPercentage missing:")
print(missing_pct[missing_pct > 0])
Missing values per column:
height         6
mass          28
hair_color     5
birth_year    44
sex            4
gender         4
homeworld     10
species        4
vehicles      76
starships     67
dtype: int64

Percentage missing:
height         6.90
mass          32.18
hair_color     5.75
birth_year    50.57
sex            4.60
gender         4.60
homeworld     11.49
species        4.60
vehicles      87.36
starships     77.01
dtype: float64

Handling Missing Values

Strategies for Missing Data

Three main approaches:

  1. Remove rows with missing values
  2. Remove columns with too many missing values
  3. Impute (fill) missing values with reasonable estimates

The choice depends on:

  • How much data is missing
  • Whether missing values are random
  • The importance of the variable

Reality Check: Most “clean” datasets look like someone played Jenga with your rows. Missing values everywhere!

XKCD #627

Handling Missing Values

Removing Rows with Missing Values

# Original shape
print(f"Original: {starwars.shape}")

# Remove any row with missing values
starwars_complete = starwars.dropna()
print(f"After dropna(): {starwars_complete.shape}")

# Too aggressive! We lost most data (87 → 29 rows)
Original: (87, 14)
After dropna(): (6, 14)

Warning

Dropping all rows with any missing value is often too aggressive!

Handling Missing Values

Selective Removal

Drop rows only if specific columns are missing:

# Only drop if height or mass is missing
starwars_height_mass = starwars.dropna(subset=['height', 'mass'])
print(f"Dropping rows with missing height or mass: {starwars_height_mass.shape}")

# Much better! (87 → 59 rows)
Dropping rows with missing height or mass: (59, 14)

Handling Missing Values

Imputation: Filling Missing Values

Replace missing values with estimates:

# Create a copy for imputation
starwars_imputed = starwars.copy()

# Fill missing height with median
median_height = starwars_imputed['height'].median()
starwars_imputed['height'] = starwars_imputed['height'].fillna(median_height)

# Fill missing mass with median
median_mass = starwars_imputed['mass'].median()
starwars_imputed['mass'] = starwars_imputed['mass'].fillna(median_mass)

# Check results
print(f"Missing height after imputation: {starwars_imputed['height'].isnull().sum()}")
print(f"Missing mass after imputation: {starwars_imputed['mass'].isnull().sum()}")
Missing height after imputation: 0
Missing mass after imputation: 0

Checking for Duplicates

Identifying Duplicate Rows

# Check for duplicate rows
n_duplicates = starwars.duplicated().sum()
print(f"Number of duplicate rows: {n_duplicates}")

# Check for duplicate names (should each be unique)
duplicate_names = starwars[starwars.duplicated(subset=['name'], keep=False)]
print(f"\nDuplicate names: {len(duplicate_names)}")
Number of duplicate rows: 0

Duplicate names: 0

No duplicates found - this is a clean dataset!

Data Manipulation: Selection and Filtering

Selecting Columns

Choose Specific Columns

# Select single column (returns Series)
names = starwars['name']
print(f"Type: {type(names)}")
print(names.head())
Type: <class 'pandas.core.series.Series'>
0    Luke Skywalker
1             C-3PO
2             R2-D2
3       Darth Vader
4       Leia Organa
Name: name, dtype: object

Selecting Columns

Select Multiple Columns

# Select multiple columns (returns DataFrame)
basic_info = starwars[['name', 'height', 'mass', 'species']]
basic_info.head()
name height mass species
0 Luke Skywalker 172.0 77.0 Human
1 C-3PO 167.0 75.0 Droid
2 R2-D2 96.0 32.0 Droid
3 Darth Vader 202.0 136.0 Human
4 Leia Organa 150.0 49.0 Human

Selecting Columns

Excluding Columns

# Drop specific columns
starwars_simple = starwars.drop(columns=['films', 'vehicles', 'starships'])
print(f"Original columns: {len(starwars.columns)}")
print(f"After dropping: {len(starwars_simple.columns)}")
starwars_simple.head(3)
Original columns: 14
After dropping: 11
name height mass hair_color skin_color eye_color birth_year sex gender homeworld species
0 Luke Skywalker 172.0 77.0 blond fair blue 19.0 male masculine Tatooine Human
1 C-3PO 167.0 75.0 NaN gold yellow 112.0 none masculine Tatooine Droid
2 R2-D2 96.0 32.0 NaN white, blue red 33.0 none masculine Naboo Droid

Filtering Rows: Boolean Indexing

Single Condition

# Filter for humans only
humans = starwars[starwars['species'] == 'Human']
print(f"Number of humans: {len(humans)}")
humans[['name', 'species', 'homeworld']].head()
Number of humans: 35
name species homeworld
0 Luke Skywalker Human Tatooine
3 Darth Vader Human Tatooine
4 Leia Organa Human Alderaan
5 Owen Lars Human Tatooine
6 Beru Whitesun Lars Human Tatooine

Filtering Rows: Boolean Indexing

Multiple Conditions with AND

Code
# Tall humans (height > 180)
tall_humans = starwars[(starwars['species'] == 'Human') &
                       (starwars['height'] > 180)]
print(f"Tall humans: {len(tall_humans)}")
tall_humans[['name', 'height', 'species']].head()
Tall humans: 15
name height species
3 Darth Vader 202.0 Human
8 Biggs Darklighter 183.0 Human
9 Obi-Wan Kenobi 182.0 Human
10 Anakin Skywalker 188.0 Human
20 Boba Fett 183.0 Human

Filtering Rows: Boolean Indexing

Multiple Conditions with OR

# Humans or Droids
humans_or_droids = starwars[(starwars['species'] == 'Human') |
                            (starwars['species'] == 'Droid')]
print(f"Humans or Droids: {len(humans_or_droids)}")
humans_or_droids[['name', 'species']].head()
Humans or Droids: 41
name species
0 Luke Skywalker Human
1 C-3PO Droid
2 R2-D2 Droid
3 Darth Vader Human
4 Leia Organa Human

Filtering Rows: Using Query

Alternative Syntax

The .query() method provides cleaner syntax:

# Same filter using query
tall_humans_query = starwars.query('species == "Human" and height > 180')
print(f"Tall humans (using query): {len(tall_humans_query)}")
tall_humans_query[['name', 'height', 'species']].head()
Tall humans (using query): 15
name height species
3 Darth Vader 202.0 Human
8 Biggs Darklighter 183.0 Human
9 Obi-Wan Kenobi 182.0 Human
10 Anakin Skywalker 188.0 Human
20 Boba Fett 183.0 Human

Filtering with String Methods

String Contains

Code
# Find characters with "Skywalker" in their name
skywalkers = starwars[starwars['name'].str.contains('Skywalker', na=False)]
skywalkers[['name', 'species', 'homeworld']]
name species homeworld
0 Luke Skywalker Human Tatooine
10 Anakin Skywalker Human Tatooine
41 Shmi Skywalker Human Tatooine

Filtering with String Methods

String Starts With

# Names starting with 'C'
c_names = starwars[starwars['name'].str.startswith('C', na=False)]
print(f"Characters whose name starts with 'C': {len(c_names)}")
c_names[['name', 'species']].head()
Characters whose name starts with 'C': 5
name species
1 C-3PO Droid
12 Chewbacca Wookiee
59 Cordé NaN
60 Cliegg Lars Human
86 Captain Phasma Human

Data Manipulation: Sorting and Ranking

Sorting Data

Sort by Single Column

# Sort by height (ascending)
sorted_by_height = starwars.sort_values('height')
sorted_by_height[['name', 'height', 'species']].head()
name height species
18 Yoda 66.0 Yoda's species
45 Ratts Tyerel 79.0 Aleena
28 Wicket Systri Warrick 88.0 Ewok
46 Dud Bolt 94.0 Vulptereen
2 R2-D2 96.0 Droid

Sorting Data

Sort by Height (Descending)

# Tallest characters
tallest = starwars.sort_values('height', ascending=False)
tallest[['name', 'height', 'species']].head()
name height species
55 Yarael Poof 264.0 Quermian
78 Tarfful 234.0 Wookiee
70 Lama Su 229.0 Kaminoan
12 Chewbacca 228.0 Wookiee
35 Roos Tarpals 224.0 Gungan

Sorting Data

Sort by Multiple Columns

Code
# Sort by species (ascending) then height (descending)
sorted_multi = starwars.sort_values(['species', 'height'],
                                    ascending=[True, False])
sorted_multi[['name', 'species', 'height']].head(10)
name species height
45 Ratts Tyerel Aleena 79.0
69 Dexter Jettster Besalisk 198.0
50 Ki-Adi-Mundi Cerean 198.0
57 Mas Amedda Chagrian 196.0
68 Zam Wesell Clawdite 168.0
21 IG-88 Droid 200.0
1 C-3PO Droid 167.0
7 R5-D4 Droid 97.0
2 R2-D2 Droid 96.0
73 R4-P17 Droid 96.0

Data Manipulation: Creating New Variables

Adding New Columns

Simple Calculations

# Create a copy to work with
sw = starwars.copy()

# Convert height from cm to meters
sw['height_m'] = sw['height'] / 100
sw[['name', 'height', 'height_m']].head()
name height height_m
0 Luke Skywalker 172.0 1.72
1 C-3PO 167.0 1.67
2 R2-D2 96.0 0.96
3 Darth Vader 202.0 2.02
4 Leia Organa 150.0 1.50

Adding New Columns

Body Mass Index (BMI)

# Calculate BMI = mass (kg) / height^2 (m)
sw['bmi'] = sw['mass'] / (sw['height_m'] ** 2)
# View results (excluding NaN)
sw[['name', 'height_m', 'mass', 'bmi']].dropna().head()
name height_m mass bmi
0 Luke Skywalker 1.72 77.0 26.027582
1 C-3PO 1.67 75.0 26.892323
2 R2-D2 0.96 32.0 34.722222
3 Darth Vader 2.02 136.0 33.330066
4 Leia Organa 1.50 49.0 21.777778

Creating Categorical Variables

Using np.select()

Create categories based on conditions:

# Categorize by height
conditions = [
    sw['height'] < 150,
    (sw['height'] >= 150) & (sw['height'] < 180),
    sw['height'] >= 180
]
choices = ['Short', 'Average', 'Tall']

sw['height_category'] = np.select(conditions, choices, default='Unknown')

# View distribution
sw['height_category'].value_counts()
height_category
Tall       44
Average    27
Short      10
Unknown     6
Name: count, dtype: int64

Creating Categorical Variables

View Height Categories

# Show examples from each category
sw[['name', 'height', 'height_category']].dropna().groupby('height_category').head(3)
name height height_category
0 Luke Skywalker 172.0 Average
1 C-3PO 167.0 Average
2 R2-D2 96.0 Short
3 Darth Vader 202.0 Tall
4 Leia Organa 150.0 Average
7 R5-D4 97.0 Short
8 Biggs Darklighter 183.0 Tall
9 Obi-Wan Kenobi 182.0 Tall
18 Yoda 66.0 Short

Using assign() Method

Chaining Operations

The .assign() method allows method chaining:

# Create multiple columns at once
sw_enhanced = (starwars
               .assign(height_m=lambda df: df['height'] / 100)
               .assign(bmi=lambda df: df['mass'] / (df['height_m'] ** 2))
               .assign(bmi_category=lambda df: pd.cut(df['bmi'],
                                                       bins=[0, 18.5, 25, 30, 100],
                                                       labels=['Underweight', 'Normal',
                                                              'Overweight', 'Obese'])))

sw_enhanced[['name', 'height_m', 'mass', 'bmi', 'bmi_category']].dropna().head()
name height_m mass bmi bmi_category
0 Luke Skywalker 1.72 77.0 26.027582 Overweight
1 C-3PO 1.67 75.0 26.892323 Overweight
2 R2-D2 0.96 32.0 34.722222 Obese
3 Darth Vader 2.02 136.0 33.330066 Obese
4 Leia Organa 1.50 49.0 21.777778 Normal

Grouping and Aggregation

Group By Operations

Grouping by Species

# Group by species and count
species_counts = starwars.groupby('species').size().sort_values(ascending=False)
print("Top 10 species by count:")
species_counts.head(5)
Top 10 species by count:
species
Human       35
Droid        6
Gungan       3
Kaminoan     2
Mirialan     2
dtype: int64

Aggregating Data

Summary Statistics by Group

# Average height and mass by species
species_stats = starwars.groupby('species')[['height', 'mass']].mean()
species_stats.sort_values('height', ascending=False).head(5)
height mass
species
Quermian 264.000000 NaN
Wookiee 231.000000 124.0
Kaminoan 221.000000 88.0
Kaleesh 216.000000 159.0
Gungan 208.666667 74.0

Multiple Aggregations

Using agg()

# Multiple statistics by species
species_summary = (starwars
                   .groupby('species')
                   .agg(
                       count=('name', 'count'),
                       avg_height=('height', 'mean'),
                       avg_mass=('mass', 'mean'),
                       max_height=('height', 'max')
                   )
                   .round(2)
                   .sort_values('count', ascending=False))

species_summary.head(5)
count avg_height avg_mass max_height
species
Human 35 178.00 81.31 202.0
Droid 6 131.20 69.75 200.0
Gungan 3 208.67 74.00 224.0
Kaminoan 2 221.00 88.00 229.0
Mirialan 2 168.00 53.10 170.0

Grouping by Multiple Variables

Gender and Species

Code
# Count by species and gender
species_gender = (starwars
                  .groupby(['species', 'sex'])
                  .size()
                  .reset_index(name='count')
                  .sort_values('count', ascending=False))

species_gender.head(5)
species sex count
11 Human male 26
10 Human female 9
5 Droid none 6
9 Gungan male 3
18 Mirialan female 2

Filtering After Grouping

Species with Multiple Characters

# Only species with at least 2 characters
species_multi = (starwars
                 .groupby('species')
                 .filter(lambda x: len(x) >= 2)
                 ['species']
                 .value_counts())

print("Species with 2+ characters:")
species_multi.head(5)
Species with 2+ characters:
species
Human      35
Droid       6
Gungan      3
Wookiee     2
Zabrak      2
Name: count, dtype: int64

Data Visualization

Introduction to Visualization

Why Visualize?

  • Patterns are easier to see than in tables
  • Outliers stand out visually
  • Distributions reveal data characteristics
  • Relationships between variables become clear
  • Essential for communication of results

Visualization Libraries in Python

  • Matplotlib: The foundational plotting library
  • Seaborn: Built on Matplotlib, easier for statistical plots

Distribution Plots: Histogram

Height Distribution

Code
# Histogram of heights
import matplotlib.pyplot as plt
import seaborn as sns
plt.figure(figsize=(10, 6))
plt.hist(starwars['height'].dropna(), bins=20, color='skyblue', edgecolor='black')
plt.xlabel('Height (cm)', fontsize=12)
plt.ylabel('Frequency', fontsize=12)
plt.title('Distribution of Character Heights', fontsize=14, fontweight='bold')
plt.grid(axis='y', alpha=0.3)
plt.show()

Distribution Plots: Density Plot

Kernel Density Estimation

Code
# Density plot of mass
plt.figure(figsize=(10, 6))
starwars['mass'].dropna().plot(kind='density', color='coral', linewidth=2)
plt.xlabel('Mass (kg)', fontsize=12)
plt.ylabel('Density', fontsize=12)
plt.title('Density Plot of Character Mass', fontsize=14, fontweight='bold')
plt.grid(alpha=0.3)
plt.show()

Box Plots: Comparing Distributions

Height by Gender

Code
# Box plot: height by gender
plt.figure(figsize=(10, 6))
data_for_box = starwars[starwars['sex'].isin(['male', 'female'])].dropna(subset=['height', 'sex'])
sns.boxplot(data=data_for_box, x='sex', y='height', palette='Set2')
plt.xlabel('Gender', fontsize=12)
plt.ylabel('Height (cm)', fontsize=12)
plt.title('Height Distribution by Gender', fontsize=14, fontweight='bold')
plt.show()

Box Plots: Identifying Outliers

Mass by Gender

Code
# Mass by gender - notice the outlier!
plt.figure(figsize=(10, 6))
data_mass = starwars[starwars['sex'].isin(['male', 'female'])].dropna(subset=['mass', 'sex'])
sns.boxplot(data=data_mass, x='sex', y='mass', palette='Set3')
plt.xlabel('Gender', fontsize=12)
plt.ylabel('Mass (kg)', fontsize=12)
plt.title('Mass Distribution by Gender (Note: Outlier!)', fontsize=14, fontweight='bold')
plt.show()

Violin Plots: Density + Box Plot

Combining Information

Code
# Violin plot combines box plot and density
plt.figure(figsize=(10, 6))
sns.violinplot(data=data_for_box, x='sex', y='height', palette='muted')
plt.xlabel('Gender', fontsize=12)
plt.ylabel('Height (cm)', fontsize=12)
plt.title('Height Distribution by Gender (Violin Plot)', fontsize=14, fontweight='bold')
plt.show()

Bar Plots: Categorical Counts

Species Distribution

Code
# Top 10 species by count
plt.figure(figsize=(12, 6))
top_species = starwars['species'].value_counts().head(10)
top_species.plot(kind='bar', color='steelblue')
plt.xlabel('Species', fontsize=12)
plt.ylabel('Count', fontsize=12)
plt.title('Top 10 Most Common Species', fontsize=14, fontweight='bold')
plt.xticks(rotation=45, ha='right')
plt.grid(axis='y', alpha=0.3)
plt.tight_layout()
plt.show()

Bar Plots: Grouped Bars

Average Height by Species

Code
# Average height for top species
plt.figure(figsize=(12, 6))
top_species_names = starwars['species'].value_counts().head(8).index
species_height = (starwars[starwars['species'].isin(top_species_names)]
                  .groupby('species')['height']
                  .mean()
                  .sort_values(ascending=False))

species_height.plot(kind='bar', color='coral')
plt.xlabel('Species', fontsize=12)
plt.ylabel('Average Height (cm)', fontsize=12)
plt.title('Average Height by Species (Top 8)', fontsize=14, fontweight='bold')
plt.xticks(rotation=45, ha='right')
plt.grid(axis='y', alpha=0.3)
plt.tight_layout()
plt.show()

Scatter Plots: Relationships

Height vs. Mass

Code
# Scatter plot: height vs mass
plt.figure(figsize=(10, 6))
plt.scatter(starwars['height'], starwars['mass'], alpha=0.6, s=50, color='darkblue')
plt.xlabel('Height (cm)', fontsize=12)
plt.ylabel('Mass (kg)', fontsize=12)
plt.title('Relationship: Height vs. Mass', fontsize=14, fontweight='bold')
plt.grid(alpha=0.3)
plt.show()

Scatter Plots: With Categories

Colored by Gender

Code
# Scatter plot colored by gender
plt.figure(figsize=(10, 6))
data_scatter = starwars.dropna(subset=['height', 'mass', 'sex'])
data_scatter = data_scatter[data_scatter['sex'].isin(['male', 'female'])]

for gender in ['male', 'female']:
    subset = data_scatter[data_scatter['sex'] == gender]
    plt.scatter(subset['height'], subset['mass'],
                label=gender, alpha=0.6, s=60)

plt.xlabel('Height (cm)', fontsize=12)
plt.ylabel('Mass (kg)', fontsize=12)
plt.title('Height vs. Mass by Gender', fontsize=14, fontweight='bold')
plt.legend()
plt.grid(alpha=0.3)
plt.show()

Scatter Plots: With Regression Line

Adding Trend Line

Code
# Scatter with regression line
plt.figure(figsize=(10, 6))
data_clean = starwars.dropna(subset=['height', 'mass'])
# Remove extreme outlier
data_clean = data_clean[data_clean['mass'] < 500]

sns.regplot(data=data_clean, x='height', y='mass',
            scatter_kws={'alpha': 0.5}, line_kws={'color': 'red'})
plt.xlabel('Height (cm)', fontsize=12)
plt.ylabel('Mass (kg)', fontsize=12)
plt.title('Height vs. Mass with Regression Line', fontsize=14, fontweight='bold')
plt.show()

Correlation Heatmap

Correlation Matrix

Code
# Correlation heatmap for numerical variables
plt.figure(figsize=(8, 6))
numerical_cols = starwars[['height', 'mass', 'birth_year']].dropna()
correlation = numerical_cols.corr()

sns.heatmap(correlation, annot=True, cmap='coolwarm', center=0,
            square=True, linewidths=1, cbar_kws={"shrink": 0.8})
plt.title('Correlation Matrix', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()

Remember, Fellow Data Scientists!

Correlation ≠ Causation (but it does waggle its eyebrows suggestively)

Just because height and mass correlate doesn’t mean being tall causes you to be heavy. Could be: - The other way around - Both caused by genetics? - Diet? The Force? 🤔

XKCD #552

Interactive Visualizations

Introduction to Plotly

Why Interactive Plots?

Interactive visualizations offer several advantages:

  • Zoom and pan to explore details
  • Hover to see exact values
  • Click to filter categories
  • Export high-quality images
  • Share via HTML files

We’ll use Plotly for interactive plots:

Code
import plotly.express as px
import plotly.graph_objects as go

Making Data Actually Cool

Static plots: “Here’s some information”

Interactive plots: “Here’s some information AND YOU CAN PLAY WITH IT!”

Your stakeholders will literally forget to ask difficult questions because they’re too busy zooming and hovering.

Pro move: Make the executive dashboard so interactive they never notice you didn’t answer the original question

Interactive Scatter Plot

Height vs. Mass (Interactive)

Code
# Interactive scatter plot with hover information
fig = px.scatter(starwars.dropna(subset=['height', 'mass']),
                 x='height',
                 y='mass',
                 color='sex',
                 hover_data=['name', 'species'],
                 title='Interactive: Height vs. Mass by Gender',
                 labels={'height': 'Height (cm)', 'mass': 'Mass (kg)'},
                 template='plotly_white',
                 width=900,
                 height=500)

fig.update_traces(marker=dict(size=10, opacity=0.7))
fig.show()

Interactive Bar Chart

Species Distribution (Interactive)

Code
# Interactive bar chart
species_counts = starwars['species'].value_counts().head(10).reset_index()
species_counts.columns = ['species', 'count']

fig = px.bar(species_counts,
             x='species',
             y='count',
             title='Top 10 Species (Interactive)',
             labels={'species': 'Species', 'count': 'Number of Characters'},
             template='plotly_white',
             color='count',
             color_continuous_scale='viridis')

fig.update_layout(xaxis_tickangle=-45, width=900, height=500)
fig.show()

Interactive Box Plot

Height Distribution by Species

Code
# Interactive box plot
species_with_multi = starwars['species'].value_counts()
major_species = species_with_multi[species_with_multi >= 2].index
data_box = starwars[starwars['species'].isin(major_species)].dropna(subset=['height'])

# sort by avg height
avg_height = data_box.groupby('species')['height'].mean().sort_values()
data_box['species'] = pd.Categorical(data_box['species'],
                                     categories=avg_height.index,
                                     ordered=True)
fig = px.box(data_box,
             x='species',
             y='height',
             color='species',
             title='Height Distribution by Species (Interactive)',
             labels={'species': 'Species', 'height': 'Height (cm)'},
             template='plotly_white',
             hover_data=['name'])

fig.update_layout(showlegend=False, xaxis_tickangle=-45, width=1000, height=500)
fig.show()

Interactive 3D Scatter

Three-Dimensional View

Code
# 3D scatter plot
data_3d = starwars.dropna(subset=['height', 'mass', 'birth_year'])

fig = px.scatter_3d(data_3d,
                    x='height',
                    y='mass',
                    z='birth_year',
                    color='sex',
                    hover_data=['name', 'species'],
                    title='3D View: Height, Mass, and Birth Year',
                    labels={'height': 'Height (cm)',
                           'mass': 'Mass (kg)',
                           'birth_year': 'Birth Year (BBY)'},
                    template='plotly_white',
                    width=700,
                    height=400)

fig.update_traces(marker=dict(size=5))
fig.show()

Interactive Sunburst Chart

Hierarchical View: Homeworld and Species

Code
# Sunburst chart showing hierarchical relationships
data_sunburst = starwars.dropna(subset=['homeworld', 'species'])
# Count characters by homeworld and species
sunburst_data = (data_sunburst
                .groupby(['homeworld', 'species'])
                .size()
                .reset_index(name='count'))

fig = px.sunburst(sunburst_data,
                  path=['homeworld', 'species'],
                  values='count',
                  title='Character Distribution: Homeworld → Species',
                  template='plotly_white',
                  width=700,
                  height=500)

fig.show()

Advanced Topics

Data Reshaping: Wide to Long

Melting Data

Sometimes we need to reshape data from wide to long format:

Code
# Create a simple wide dataset for demonstration
wide_data = pd.DataFrame({
    'character': ['Luke', 'Leia', 'Han'],
    'height': [172, 150, 180],
    'mass': [77, 49, 80]
})

print("Wide format:")
print(wide_data)

# Melt to long format
long_data = wide_data.melt(id_vars='character',
                           var_name='measurement',
                           value_name='value')
print("\nLong format:")
print(long_data)
Wide format:
  character  height  mass
0      Luke     172    77
1      Leia     150    49
2       Han     180    80

Long format:
  character measurement  value
0      Luke      height    172
1      Leia      height    150
2       Han      height    180
3      Luke        mass     77
4      Leia        mass     49
5       Han        mass     80

Data Reshaping: Long to Wide

Pivoting Data

Code
# Pivot back to wide format
wide_again = long_data.pivot(index='character',
                             columns='measurement',
                             values='value')
print("Back to wide format:")
print(wide_again.reset_index())
Back to wide format:
measurement character  height  mass
0                 Han     180    80
1                Leia     150    49
2                Luke     172    77

Combining Datasets: Concatenation

Stacking DataFrames

Code
# Split and recombine
humans_df = starwars[starwars['species'] == 'Human'].head(3)
droids_df = starwars[starwars['species'] == 'Droid'].head(3)

# Concatenate vertically
combined = pd.concat([humans_df, droids_df], ignore_index=True)
combined[['name', 'species', 'height']]
name species height
0 Luke Skywalker Human 172.0
1 Darth Vader Human 202.0
2 Leia Organa Human 150.0
3 C-3PO Droid 167.0
4 R2-D2 Droid 96.0
5 R5-D4 Droid 97.0

String Operations

Basic String Manipulations

Code
# String manipulations
sw_strings = starwars.copy()

# Uppercase names
sw_strings['name_upper'] = sw_strings['name'].str.upper()

# Extract first name (before space)
sw_strings['first_name'] = sw_strings['name'].str.split(' ').str[0]

# Name length
sw_strings['name_length'] = sw_strings['name'].str.len()

sw_strings[['name', 'name_upper', 'first_name', 'name_length']].head()
name name_upper first_name name_length
0 Luke Skywalker LUKE SKYWALKER Luke 14
1 C-3PO C-3PO C-3PO 5
2 R2-D2 R2-D2 R2-D2 5
3 Darth Vader DARTH VADER Darth 11
4 Leia Organa LEIA ORGANA Leia 11

Basic String Manipulations

String Operations: Not as scary as regex!

Good news: We’re just using .str.split() and .str.contains()

Bad news: Eventually you’ll need regex…

(But seriously, pandas string methods are your friend - they save you from regex 90% of the time)

XKCD #208

“Now you have two problems”

Outlook: Text Analysis

Text Vectorization and Analysis

Let’s analyze character names using text mining techniques:

Code
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from collections import Counter

# Analyze character names - extract all words
all_names = ' '.join(starwars['name'].dropna().values)
words = all_names.lower().split()

# Most common words in character names
word_counts = Counter(words)
print("Most common words in Star Wars character names:")
for word, count in word_counts.most_common(10):
    print(f"  {word}: {count}")
Most common words in Star Wars character names:
  skywalker: 3
  lars: 3
  darth: 2
  organa: 2
  antilles: 2
  fett: 2
  jar: 2
  luke: 1
  c-3po: 1
  r2-d2: 1

Text Feature Engineering

Creating Text-Based Features

Code
# Extract features from text data
sw_text = starwars.copy()

# Has title (Master, Darth, etc.)
sw_text['has_title'] = sw_text['name'].str.contains('Master|Darth|Captain|Admiral|General',
                                                     case=False, na=False)

# Number of words in name
sw_text['name_words'] = sw_text['name'].str.split().str.len()

# Starts with vowel
sw_text['starts_vowel'] = sw_text['name'].str[0].str.lower().isin(['a', 'e', 'i', 'o', 'u'])

# Show examples
sw_text[['name', 'has_title', 'name_words', 'starts_vowel']].head(10)
name has_title name_words starts_vowel
0 Luke Skywalker False 2 False
1 C-3PO False 1 False
2 R2-D2 False 1 False
3 Darth Vader True 2 False
4 Leia Organa False 2 False
5 Owen Lars False 2 True
6 Beru Whitesun Lars False 3 False
7 R5-D4 False 1 False
8 Biggs Darklighter False 2 False
9 Obi-Wan Kenobi False 2 True

N-gram Analysis

Character-Level Patterns

Code
# Analyze character patterns in names
from collections import defaultdict

def get_bigrams(text):
    """Extract character bigrams from text"""
    return [text[i:i+2] for i in range(len(text)-1)]

# Get all character bigrams from names
all_bigrams = []
for name in starwars['name'].dropna():
    all_bigrams.extend(get_bigrams(name.lower().replace(' ', '')))

bigram_counts = Counter(all_bigrams)
print("Most common character bigrams in names:")
for bigram, count in bigram_counts.most_common(15):
    print(f"  '{bigram}': {count}")
Most common character bigrams in names:
  'ar': 20
  'an': 14
  'in': 13
  'er': 11
  'or': 11
  'es': 9
  'al': 9
  'na': 8
  'wa': 7
  'da': 7
  'la': 7
  'un': 7
  'ta': 7
  'ba': 7
  're': 7

Text Similarity Analysis

Finding Similar Character Names

Code
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

# Create TF-IDF vectors from character names
names_clean = starwars['name'].dropna()
vectorizer = TfidfVectorizer(analyzer='char', ngram_range=(2,3))
name_vectors = vectorizer.fit_transform(names_clean)

# Calculate similarity matrix
similarity_matrix = cosine_similarity(name_vectors)

# Find most similar name pairs
def find_similar_names(names, sim_matrix, top_n=5):
    results = []
    for i in range(len(names)):
        for j in range(i+1, len(names)):
            sim = sim_matrix[i, j]
            if sim > 0.3:  # Threshold for similarity
                results.append((names.iloc[i], names.iloc[j], sim))
    results.sort(key=lambda x: x[2], reverse=True)
    return results[:top_n]

similar = find_similar_names(names_clean, similarity_matrix)
print("Most similar character name pairs:")
for name1, name2, score in similar:
    print(f"  {name1} <-> {name2}: {score:.3f}")
Most similar character name pairs:
  Luke Skywalker <-> Shmi Skywalker: 0.627
  Luke Skywalker <-> Anakin Skywalker: 0.615
  Anakin Skywalker <-> Shmi Skywalker: 0.601
  Wedge Antilles <-> Raymus Antilles: 0.521
  Darth Vader <-> Darth Maul: 0.442

Working with Dates

Birth Year Analysis

Code
# Find oldest and youngest characters
birth_data = starwars[['name', 'birth_year']].dropna()

oldest = birth_data.nsmallest(5, 'birth_year')
youngest = birth_data.nlargest(5, 'birth_year')

print("5 Oldest characters (by birth year):")
print(oldest)
print("\n5 Youngest characters:")
print(youngest)
5 Oldest characters (by birth year):
                     name  birth_year
28  Wicket Systri Warrick         8.0
21                  IG-88        15.0
0          Luke Skywalker        19.0
4             Leia Organa        19.0
16         Wedge Antilles        21.0

5 Youngest characters:
                     name  birth_year
18                   Yoda       896.0
15  Jabba Desilijic Tiure       600.0
12              Chewbacca       200.0
1                   C-3PO       112.0
65                  Dooku       102.0

Case Study: Atlantic Hurricane Analysis

Introduction to the Storms Dataset

About the Data

We’ll analyze Atlantic tropical storms from 1975-2020:

  • Data from NOAA Hurricane Database
  • Contains storm tracks, wind speeds, pressure
  • Geographic coordinates for mapping
  • Multiple observations per storm over time

About the Data

Let’s load this new dataset:

Code
# Load storms dataset
storms_url = 'https://raw.githubusercontent.com/tidyverse/dplyr/refs/heads/main/data-raw/storms.csv'
storms = pd.read_csv(storms_url)

print(f"Dataset shape: {storms.shape}")
print(f"\nFirst few rows:")
storms.head()
Dataset shape: (20778, 13)

First few rows:
name year month day hour lat long status category wind pressure tropicalstorm_force_diameter hurricane_force_diameter
0 Amy 1975 6 27 0 27.5 -79.0 tropical depression NaN 25 1013 NaN NaN
1 Amy 1975 6 27 6 28.5 -79.0 tropical depression NaN 25 1013 NaN NaN
2 Amy 1975 6 27 12 29.5 -79.0 tropical depression NaN 25 1013 NaN NaN
3 Amy 1975 6 27 18 30.5 -79.0 tropical depression NaN 25 1013 NaN NaN
4 Amy 1975 6 28 0 31.5 -78.8 tropical depression NaN 25 1012 NaN NaN

Initial Exploration

Understanding the Data

Code
# Data structure
print("Column information:")
storms.info()
Column information:
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 20778 entries, 0 to 20777
Data columns (total 13 columns):
 #   Column                        Non-Null Count  Dtype  
---  ------                        --------------  -----  
 0   name                          20778 non-null  object 
 1   year                          20778 non-null  int64  
 2   month                         20778 non-null  int64  
 3   day                           20778 non-null  int64  
 4   hour                          20778 non-null  int64  
 5   lat                           20778 non-null  float64
 6   long                          20778 non-null  float64
 7   status                        20778 non-null  object 
 8   category                      5100 non-null   float64
 9   wind                          20778 non-null  int64  
 10  pressure                      20778 non-null  int64  
 11  tropicalstorm_force_diameter  11266 non-null  float64
 12  hurricane_force_diameter      11266 non-null  float64
dtypes: float64(5), int64(6), object(2)
memory usage: 2.1+ MB

Understanding the Data

Code
print("\n\nSummary statistics:")
storms.describe()


Summary statistics:
year month day hour lat long category wind pressure tropicalstorm_force_diameter hurricane_force_diameter
count 20778.000000 20778.000000 20778.000000 20778.000000 20778.000000 20778.000000 5100.000000 20778.000000 20778.000000 11266.000000 11266.000000
mean 2003.986813 8.707672 15.713351 9.102416 26.938724 -61.322918 1.898824 50.065454 993.408076 148.130659 14.823806
std 13.311891 1.344361 8.914931 6.735674 10.474673 21.131973 1.154117 25.464176 18.784057 156.692478 33.856667
min 1975.000000 1.000000 1.000000 0.000000 7.000000 -136.900000 1.000000 10.000000 882.000000 0.000000 0.000000
25% 1995.000000 8.000000 8.000000 5.000000 18.300000 -78.600000 1.000000 30.000000 986.000000 0.000000 0.000000
50% 2005.000000 9.000000 16.000000 12.000000 26.400000 -61.900000 1.000000 45.000000 1000.000000 120.000000 0.000000
75% 2016.000000 9.000000 24.000000 18.000000 33.800000 -45.300000 3.000000 65.000000 1007.000000 220.000000 0.000000
max 2024.000000 12.000000 31.000000 23.000000 70.700000 13.500000 5.000000 165.000000 1024.000000 1440.000000 300.000000

Data Preparation

Feature Engineering

Code
# Create datetime column
storms['datetime'] = pd.to_datetime(storms[['year', 'month', 'day', 'hour']])

# Extract year and decade
storms['decade'] = (storms['year'] // 10) * 10

# Categorize storm status
storms['status_category'] = storms['status'].map({
    'tropical depression': 'Weak',
    'tropical storm': 'Moderate',
    'hurricane': 'Strong',
    'extratropical': 'Transitioning'
})

# Calculate storm duration (hours per storm)
storm_duration = storms.groupby('name').size()
storms['observations'] = storms['name'].map(storm_duration)

print("Enhanced dataset ready!")
storms[['name', 'datetime', 'status', 'status_category', 'wind', 'pressure']].head()
Enhanced dataset ready!
name datetime status status_category wind pressure
0 Amy 1975-06-27 00:00:00 tropical depression Weak 25 1013
1 Amy 1975-06-27 06:00:00 tropical depression Weak 25 1013
2 Amy 1975-06-27 12:00:00 tropical depression Weak 25 1013
3 Amy 1975-06-27 18:00:00 tropical depression Weak 25 1013
4 Amy 1975-06-28 00:00:00 tropical depression Weak 25 1012

Exploratory Analysis: Storm Intensity

Wind Speed Distribution

Code
# Distribution of maximum wind speeds
plt.figure(figsize=(12, 4))

# Histogram
plt.subplot(1, 2, 1)
plt.hist(storms['wind'], bins=40, color='steelblue', edgecolor='black', alpha=0.7)
plt.xlabel('Wind Speed (knots)', fontsize=11)
plt.ylabel('Frequency', fontsize=11)
plt.title('Distribution of Storm Wind Speeds', fontsize=13, fontweight='bold')
plt.grid(axis='y', alpha=0.3)

# By status
plt.subplot(1, 2, 2)
for status in storms['status'].unique():
    data = storms[storms['status'] == status]['wind']
    plt.hist(data, bins=20, alpha=0.5, label=status)

plt.xlabel('Wind Speed (knots)', fontsize=11)
plt.ylabel('Frequency', fontsize=11)
plt.title('Wind Speed by Storm Status', fontsize=13, fontweight='bold')
plt.legend()
plt.tight_layout()
plt.show()

Interactive Geographic Visualization

Storm Tracks on Map

Code
# Select a few major storms for clear visualization
major_storms = ['Katrina', 'Sandy', 'Maria', 'Harvey', 'Irma']
storms_major = storms[storms['name'].isin(major_storms)]

# Create interactive map with storm tracks
fig = px.scatter_geo(storms_major,
                     lat='lat',
                     lon='long',
                     color='name',
                     size='wind',
                     hover_data=['status', 'wind', 'pressure', 'datetime'],
                     title='Major Hurricane Tracks (Interactive Map)',
                     projection='natural earth',
                     template='plotly_white',
                     width=1000,
                     height=600)

fig.update_geos(
    showcountries=True,
    showcoastlines=True,
    showland=True,
    fitbounds="locations",
    projection_scale=1.5
)

fig.show()

Animation: Storm Evolution

Time-Lapse of 2017 Hurricane Season

Code
# Animated map showing storm progression
storms_2017 = storms[storms['year'] == 2017].copy()
storms_2017 = storms_2017.sort_values('datetime')

fig = px.scatter_geo(storms_2017,
                     lat='lat',
                     lon='long',
                     color='name',
                     size='wind',
                     hover_name='name',
                     hover_data=['status', 'wind', 'pressure'],
                     animation_frame='datetime',
                     title='2017 Atlantic Hurricane Season Animation',
                     projection='natural earth',
                     template='plotly_white',
                     width=1000,
                     height=600)

fig.update_geos(
    showcountries=True,
    showcoastlines=True,
    showland=True,
    landcolor='lightgray',
    coastlinecolor='darkgray',
    projection_scale=2,
    center=dict(lat=25, lon=-70)
)

# Speed up animation - 200ms per frame
fig.update_layout(
    updatemenus=[dict(
        type='buttons',
        showactive=False,
        buttons=[
            dict(label='▶', method='animate',
                 args=[None, {'frame': {'duration': 200, 'redraw': True},
                              'fromcurrent': True}]),
            dict(label='⏸', method='animate',
                 args=[[None], {'frame': {'duration': 0, 'redraw': True},
                                'mode': 'immediate'}])
        ]
    )]
)

fig.show()

Storm Intensity Analysis

Pressure vs. Wind Speed Relationship

Code
# Interactive scatter: pressure vs wind
fig = px.scatter(storms,
                 x='pressure',
                 y='wind',
                 color='status',
                 hover_data=['name', 'year'],
                 title='Storm Pressure vs. Wind Speed',
                 labels={'pressure': 'Atmospheric Pressure (mb)',
                        'wind': 'Wind Speed (knots)'},
                 template='plotly_white',
                 width=1000,
                 height=500,
                 opacity=0.6)

fig.update_traces(marker=dict(size=5))
fig.show()

Statistical Analysis

Hurricane Categories by Decade

Code
# Analyze hurricane trends by decade
hurricanes_only = storms[storms['status'] == 'hurricane'].copy()

# Get maximum wind speed per storm
max_winds = (hurricanes_only
            .groupby(['name', 'year', 'decade'])
            ['wind']
            .max()
            .reset_index())

# Count by decade
decade_counts = max_winds.groupby('decade').size().reset_index(name='count')

# Average maximum wind speed by decade
decade_intensity = max_winds.groupby('decade')['wind'].mean().reset_index()
decade_intensity.columns = ['decade', 'avg_max_wind']

print("Hurricanes per decade:")
print(decade_counts)
print("\n\nAverage maximum wind speed by decade:")
print(decade_intensity)
Hurricanes per decade:
   decade  count
0    1970     27
1    1980     52
2    1990     64
3    2000     74
4    2010     72
5    2020     48


Average maximum wind speed by decade:
   decade  avg_max_wind
0    1970     92.777778
1    1980     90.000000
2    1990     93.984375
3    2000     98.445946
4    2010     96.458333
5    2020     99.062500

Heatmap: Monthly Patterns

When Do Storms Occur?

Code
# Storm activity by month and year
monthly_activity = (storms
                   .groupby(['year', 'month', 'name'])
                   .first()
                   .groupby(['year', 'month'])
                   .size()
                   .reset_index(name='count'))

# Pivot for heatmap
heatmap_data = monthly_activity.pivot(index='month', columns='year', values='count')
heatmap_data = heatmap_data.fillna(0)

# Interactive heatmap
fig = px.imshow(heatmap_data,
                labels=dict(x='Year', y='Month', color='Number of Storms'),
                title='Atlantic Storm Activity Heatmap (1975-2020)',
                aspect='auto',
                color_continuous_scale='YlOrRd',
                template='plotly_white',
                width=1000,
                height=500)

fig.update_yaxes(tickvals=list(range(1, 13)),
                ticktext=['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
                         'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'])
fig.show()

Key Findings

Hurricane Analysis Summary

Based on our comprehensive analysis:

  1. Peak Season: August-October shows highest activity (visible in heatmap)
  2. Intensity-Pressure Relationship: Strong negative correlation (-0.96)
  3. Recent Trends: 2020s show increased storm frequency
  4. Geographic Patterns: Most storms originate in tropical Atlantic, moving northwest
  5. Major Storms: Katrina (2005), Sandy (2012), Maria (2017) were devastating

How Does This Relate to Climate Change?

Climate Impact Note

Data suggests increasing frequency of named storms in recent decades, consistent with climate change predictions.

Important: We can responsibly discuss trends within our data range (1975-2020), but extrapolating far into the future requires climate models, not just historical data!

XKCD #605

Don’t extrapolate to 2100!

Advanced Geospatial Analysis

Storm Density Map

Code
# Create hexbin density map
fig = px.density_mapbox(storms,
                        lat='lat',
                        lon='long',
                        z='wind',       # Weight by wind speed (intensity)
                        radius=20,      # Smooths out the points
                        zoom=2.5,
                        mapbox_style='carto-positron',  # Cleaner background than open-street-map
                        title='Storm Track & Intensity Density Heatmap',
                        hover_name='name',  # Show name on hover
                        hover_data=['datetime'], # Show date on hover
                        width=900,
                        height=600,
                        center=dict(lat=25, lon=-65),
                        color_continuous_scale='Viridis',
                        opacity=0.5)

fig.show()

Tip

Interactive maps allow exploration of spatial patterns that static plots miss!

Best Practices and Tips

Data Handling Best Practices

Key Recommendations

  1. Always explore data first: Use .head(), .info(), .describe()
  2. Check for missing values: Decide on strategy (drop, impute, or keep)
  3. Look for duplicates: Use .duplicated() to check
  4. Understand data types: Ensure correct types for each column
  5. Document your workflow: Comment your code, explain decisions

Data Handling Best Practices

More Recommendations

  1. Create copies when modifying: Use .copy() to avoid unintended changes
  2. Chain operations: Use method chaining for readable pipelines
  3. Save intermediate results: Don’t lose work in long analyses
  4. Validate results: Sanity check numbers and visualizations
  5. Export clean data: Save processed data for reproducibility

Hofstadter’s Law - Data Science Edition

Mistakes to Watch For

  • SettingWithCopyWarning: Always use .copy() when creating subsets
  • Chained indexing: Use .loc[] instead of chained [][]
  • Ignoring missing values: Many operations silently exclude NaN
  • Wrong data types: Strings vs. numbers, dates as strings
  • Outliers affecting means: Use median for skewed distributions
  • Over-cleaning: Don’t remove too much data unnecessarily

“Data cleaning always takes longer than you expect, even when you take into account Hofstadter’s Law.”

  • Estimated: 2 hours
  • Actual: 2 days + existential crisis

XKCD #917

Efficient Pandas Code

Performance Tips

# GOOD: Vectorized operations (fast)
starwars['height_m'] = starwars['height'] / 100

# AVOID: Loops (slow)
# for i in range(len(starwars)):
#     starwars.loc[i, 'height_m'] = starwars.loc[i, 'height'] / 100

# GOOD: Built-in methods
result = starwars['species'].value_counts()

# AVOID: Manual counting
# counts = {}
# for species in starwars['species']:
#     counts[species] = counts.get(species, 0) + 1

Data Visualization Guidelines

Creating Effective Plots

  • Choose appropriate chart type for your data
  • Label axes clearly with units
  • Add informative titles
  • Use color meaningfully, not decoratively
  • Keep it simple: Don’t overcomplicate
  • Consider your audience: What do they need to know?

Data Visualization Guidelines

Chart Type Selection

Data Type Chart Type Use Case
Single numerical Histogram, density Distribution
Categorical Bar chart Counts or proportions
Numerical + categorical Box plot, violin Compare distributions
Two numerical Scatter plot Relationship
Time series Line plot Trends over time
Correlation Heatmap Multiple relationships

Summary and Next Steps

What We Covered

Key Topics

  1. Loading data: From URLs, files, APIs
  2. Exploring data: .head(), .info(), .describe()
  3. Cleaning data: Missing values, duplicates
  4. Selecting data: Columns, rows, filtering
  5. Transforming data: New columns, calculations
  6. Grouping and aggregating: Summary statistics by groups
  7. Visualizing data: Histograms, box plots, scatter plots
  8. Reshaping data: Wide to long, long to wide

Essential Pandas Operations

Quick Reference

Operation Method Example
Load pd.read_csv() df = pd.read_csv('file.csv')
View .head(), .tail() df.head(10)
Info .info(), .describe() df.info()
Select [], .loc[], .iloc[] df['col'], df.loc[0:5, 'col']
Filter Boolean indexing df[df['col'] > 5]
Sort .sort_values() df.sort_values('col')
Group .groupby() df.groupby('col').mean()
Create Direct assignment, .assign() df['new'] = df['a'] + df['b']

Resources for Further Learning

  • Pandas Documentation: https://pandas.pydata.org/docs/
  • Python Data Science Handbook: Jake VanderPlas
  • Pandas Cookbook: Many practical examples
  • Stack Overflow: Great for specific questions

Practice Datasets

  • Kaggle: https://www.kaggle.com/datasets
  • UCI Machine Learning Repository
  • Data.gov (government data)
  • Built-in pandas datasets

Next Lecture Preview

Moving to Statistical Analysis

In the next lectures, we’ll build on these data handling skills to:

  • Lecture 3: Regression analysis and prediction
  • Lecture 4: Classification problems

Note

Data handling is the foundation - master these skills and everything else becomes easier!

Thank You!

Questions?

Key Takeaways

  1. Data cleaning is crucial for reliable analysis
  2. Pandas provides powerful tools for data manipulation
  3. Visualization reveals patterns that tables hide
  4. Practice with real datasets to build skills

Next steps: Try these techniques on your own datasets!