Statistical Programming: Problem Set 2
Notes
Exercises
This problem set focuses on data handling, manipulation, and visualization. You will work with two real-world datasets, clean and transform them, join them together, and then visualize the results.
General Note
You will very likely find the solution to these exercises online. I, however, strongly encourage you to work on these exercises without doing so. Understanding someone else’s solution is very different from coming up with your own. Use the lecture notes and try to solve the exercises independently.
Datasets
We will use two datasets:
- Times Higher Education (THE) World University Rankings (
timesData.csv): Ranking data and performance scores for universities worldwide across multiple years. - Continents (
continents.csv): Maps country names to continents.
Section 1: Loading Data
Exercise 1: Load the Datasets
Load both datasets from the URLs below using pd.read_csv().
timesData.csv:"https://raw.githubusercontent.com/JanTeichertKluge/data-statprog/refs/heads/main/data/timesData.csv"continents.csv:"https://raw.githubusercontent.com/JanTeichertKluge/data-statprog/refs/heads/main/data/continents.csv"
- Load both datasets into DataFrames called
df_timesanddf_continents. Print the shape of each DataFrame.
Solution:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
url_times = "https://raw.githubusercontent.com/JanTeichertKluge/data-statprog/refs/heads/main/data/timesData.csv"
url_continents = "https://raw.githubusercontent.com/JanTeichertKluge/data-statprog/refs/heads/main/data/continents.csv"
df_times = pd.read_csv(url_times)
df_continents = pd.read_csv(url_continents)
print("Times Data shape:", df_times.shape)
print("Continents Data shape:", df_continents.shape)Section 2: Data Exploration
Exercise 2: Inspect the Data
Before cleaning, inspect the structure of the data to identify which columns need fixing.
Display the first 5 rows of
df_times.Show the data types and missing value counts using
.info().Show descriptive statistics using
.describe().How many unique years are in the dataset? How many unique countries?
- Hint: Use
.nunique()on a column to count unique values.
Solution:
n_years = df_times['year'].nunique()
n_countries = df_times['country'].nunique()
print(f"Unique years: {n_years}")
print(f"Unique countries: {n_countries}")
print(f"\nYear values: {sorted(df_times['year'].unique())}")Section 3: Data Cleaning & Transformation
Exercise 3: Replace Missing Value Placeholders
In df_times, missing values are sometimes stored as the string '-' instead of NaN. This causes pandas to treat those columns as strings (object dtype) instead of numbers.
Replace all occurrences of the string
'-'in the entiredf_timesDataFrame withnp.nan.After replacing, verify that the string
'-'no longer appears in thetotal_scorecolumn.
- Hint: Use
df.replace().
Solution:
# Replace the '-' string with NaN across the whole DataFrame
df_times = df_times.replace('-', np.nan)
# Verify
remaining = (df_times['total_score'] == '-').sum()
print(f"Remaining '-' in total_score: {remaining}")
print(f"Sample values: {df_times['total_score'].dropna().head().tolist()}")Exercise 4: Convert String Columns to Numeric
After replacing '-' with NaN, numeric columns like total_score and income are still stored as strings. Convert them to numeric (float) dtype.
Convert
total_scoreandincometo numeric usingpd.to_numeric().Print the dtypes of both columns after conversion to verify.
Solution:
df_times['total_score'] = pd.to_numeric(df_times['total_score'])
df_times['income'] = pd.to_numeric(df_times['income'])
print(df_times[['total_score', 'income']].dtypes)
print(df_times[['total_score', 'income']].describe())Exercise 5: Clean Comma-Formatted Numbers
The num_students column contains numbers as comma-formatted strings (e.g., "20,152"). Remove the commas and convert to a numeric type.
Use
.str.replace(',', '')to remove commas fromnum_students, then convert to numeric.Print the 5 universities with the most students.
- Hint: You can chain:
pd.to_numeric(df_times['num_students'].str.replace(',', '')).
Solution:
df_times['num_students'] = pd.to_numeric(df_times['num_students'].str.replace(',', ''))
top_students = (df_times[df_times['year'] == 2016]
.nlargest(5, 'num_students')
[['university_name', 'country', 'num_students']])
print(top_students)Exercise 6: Clean Percentage Strings
The international_students column contains percentage values as strings (e.g., "25%"). Convert these to numeric decimals (e.g., 0.25).
Remove the
%character using.str.replace('%', ''), convert to numeric, and divide by100.What is the mean percentage of international students across all universities in 2016?
Solution:
df_times['international_students'] = (
pd.to_numeric(df_times['international_students'].str.replace('%', '')) / 100
)
mean_intl = df_times[df_times['year'] == 2016]['international_students'].mean()
print(f"Mean international student share (2016): {mean_intl:.1%}")Exercise 7: Split a Column into Two
The female_male_ratio column contains strings like "45 : 55". Split this into two separate numeric columns: female_ratio and male_ratio.
Use
.str.split(' : ', expand=True)to split the column into a two-column DataFrame. Assign the two columns todf_times['female_ratio']anddf_times['male_ratio']and convert both to numeric.Check: For all rows where
female_ratioandmale_ratioare not NaN, they should sum to approximately 100. Verify this.
Solution:
# Split on ' : '
split_ratios = df_times['female_male_ratio'].str.split(' : ', expand=True)
df_times['female_ratio'] = pd.to_numeric(split_ratios[0])
df_times['male_ratio'] = pd.to_numeric(split_ratios[1])
# Verify
ratio_sum = (df_times['female_ratio'] + df_times['male_ratio']).dropna()
print(f"All ratios sum to ~100: {(ratio_sum.round(0) == 100).all()}")
print(df_times[['female_male_ratio', 'female_ratio', 'male_ratio']].dropna().head())Section 4: Joining Datasets
Exercise 8: Merge with the Continents Dataset
We want to add the continent for each university. The df_continents DataFrame has columns Entity (country name) and Countries Continents (continent name).
First inspect
df_continentswith.head()to understand its structure.Rename
df_continentscolumns to'country'and'continent'to make the merge cleaner.Merge
df_timeswith the cleaneddf_continentsusing a left join on the'country'column. Save the result asdf_merged.
- Hint:
pd.merge(df_times, df_continents, on='country', how='left')
Solution:
# a) Inspect
print(df_continents.head())
# b) Rename columns
df_continents = df_continents.rename(columns={
'Entity': 'country',
'Countries Continents': 'continent'
})
# c) Left merge
df_merged = pd.merge(df_times, df_continents, on='country', how='left')
print(f"\nMerged shape: {df_merged.shape}")
print(df_merged[['university_name', 'country', 'continent']].head())Exercise 9: Handle Mismatched Country Names
A left join can produce missing continents if the country names differ between the two datasets (e.g., "United States of America" vs. "United States").
Find all country names in
df_mergedthat have a missingcontinent, and print them.Fix the mismatched names in
df_timesusing.replace()and re-run the merge. Use the mapping below:
'United States of America' -> 'United States'
'Russian Federation' -> 'Russia'
'Republic of Ireland' -> 'Ireland'
- How many universities still have a missing continent after the fix? Print any remaining unmatched countries.
Solution:
# a) Countries with missing continents
missing = df_merged[df_merged['continent'].isna()]['country'].unique()
print("Countries with missing continent:", missing)
# b) Fix country names and re-merge
df_times['country'] = df_times['country'].replace({
'United States of America': 'United States',
'Russian Federation': 'Russia',
'Republic of Ireland': 'Ireland',
})
df_merged = pd.merge(df_times, df_continents, on='country', how='left')
# c) Check remaining
still_missing = df_merged[df_merged['continent'].isna()]['country'].unique()
print(f"\nCountries still missing a continent: {still_missing}")
print(f"Rows without continent: {df_merged['continent'].isna().sum()}")Section 5: Filtering & Selection
Exercise 10: Filter the Data
Now that the data is clean and joined, we can filter it for specific analyses.
Create a DataFrame
top_10_2016containing only universities from the year 2016 with aworld_rankof1through10. Note thatworld_rankcan contain strings like"=10", so clean this first.- Hint: Use
pd.to_numeric(df_merged['world_rank'].str.replace('=', ''), errors='coerce')to create a numeric rank columnworld_rank_num.
- Hint: Use
Print the result showing
world_rank,university_name,country, andcontinent.How many of the top 10 are from Europe?
Solution:
# a) Clean world_rank and filter
df_merged['world_rank_num'] = pd.to_numeric(
df_merged['world_rank'].str.replace('=', '', regex=False), errors='coerce'
)
top_10_2016 = df_merged[
(df_merged['year'] == 2016) &
(df_merged['world_rank_num'] >= 1) &
(df_merged['world_rank_num'] <= 10)
]
# b) Display
print(top_10_2016[['world_rank', 'university_name', 'country', 'continent']].to_string(index=False))
# c) Europe count
n_europe = (top_10_2016['continent'] == 'Europe').sum()
print(f"\nTop-10 universities from Europe: {n_europe}")Section 6: Grouping & Aggregation
Exercise 11: Aggregate by Continent and Year
Use grouping to calculate summary statistics for different categories.
Group
df_mergedbycontinentandyear, and calculate:- the mean of
teaching,research, andtotal_score - the total (sum) of
num_students
Save the result as
continent_year_stats. Reset the index so continent and year become regular columns.- the mean of
Filter
continent_year_statsfor the year 2016 and sort by meantotal_scoredescending. Which continent has the highest average total score?
Solution:
# a) Group and aggregate
continent_year_stats = (
df_merged
.groupby(['continent', 'year'])
.agg(
teaching=('teaching', 'mean'),
research=('research', 'mean'),
total_score=('total_score', 'mean'),
num_students=('num_students', 'sum')
)
.reset_index()
)
# b) Filter for 2016 and sort
stats_2016 = (continent_year_stats[continent_year_stats['year'] == 2016]
.sort_values('total_score', ascending=False))
print(stats_2016.to_string(index=False))Section 7: Visualization
Exercise 12: Scatter Plot - Teaching vs. Research
Use a scatter plot to visualize the relationship between two numerical variables.
Create a scatter plot for the year 2016 showing
teaching(x-axis) vs.research(y-axis), with one point per university.Color the points by
continent. Add a legend, axis labels, and a title.- Hint: Loop over continents and plot each group separately with a different color, or use
seaborn.scatterplotwithhue='continent'.
- Hint: Loop over continents and plot each group separately with a different color, or use
Solution:
df_2016 = df_merged[(df_merged['year'] == 2016)].dropna(subset=['teaching', 'research', 'continent'])
plt.figure(figsize=(10, 7))
sns.scatterplot(data=df_2016, x='teaching', y='research',
hue='continent', alpha=0.7, s=60)
plt.xlabel('Teaching Score', fontsize=12)
plt.ylabel('Research Score', fontsize=12)
plt.title('Teaching vs. Research Scores by Continent (2016)', fontsize=14, fontweight='bold')
plt.legend(title='Continent', bbox_to_anchor=(1.05, 1), loc='upper left')
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()Exercise 13: Box Plot - Score Distribution by Continent
Box plots are useful for comparing the distribution of a variable across groups.
Create a box plot of
total_scoregrouped bycontinentfor the year 2016. Drop NaN values intotal_scorebefore plotting.Rotate the x-axis labels by 45 degrees so they don’t overlap.
Which continent shows the highest median total score? Which has the most spread (widest box)?
Solution:
df_box = (df_merged[(df_merged['year'] == 2016)]
.dropna(subset=['total_score', 'continent']))
plt.figure(figsize=(11, 6))
sns.boxplot(data=df_box, x='continent', y='total_score',
palette='Set2', order=sorted(df_box['continent'].unique()))
plt.title('University Total Score Distribution by Continent (2016)', fontsize=14, fontweight='bold')
plt.ylabel('Total Score', fontsize=12)
plt.xlabel('Continent', fontsize=12)
plt.xticks(rotation=45, ha='right')
plt.grid(axis='y', alpha=0.3)
plt.tight_layout()
plt.show()Exercise 14: Bar Chart - Top 10 Universities by Number of Students
Using
top_10_2016from Exercise 10, create a horizontal bar chart (plt.barh) showing the number of students per university. Sort so the university with the most students appears at the top.Annotate each bar with the exact student count.
Add a color to the bars that distinguishes universities by continent.
- Hint: Sort by
num_studentsascending for a horizontal bar chart (so the largest bar is at the top).
Solution:
top_sorted = top_10_2016.dropna(subset=['num_students']).sort_values('num_students', ascending=True)
# Assign colours by continent
continent_colors = {'Europe': '#4C72B0', 'North America': '#DD8452', 'Asia': '#55A868'}
colors = [continent_colors.get(c, '#999999') for c in top_sorted['continent']]
plt.figure(figsize=(10, 7))
bars = plt.barh(top_sorted['university_name'], top_sorted['num_students'], color=colors)
# Annotate with exact counts
for bar in bars:
width = bar.get_width()
plt.text(width + 200, bar.get_y() + bar.get_height() / 2,
f'{int(width):,}', va='center', fontsize=9)
plt.xlabel('Number of Students', fontsize=12)
plt.title('Number of Students - Top 10 Universities 2016', fontsize=14, fontweight='bold')
plt.grid(axis='x', alpha=0.3)
plt.tight_layout()
plt.show()