Exploratory Data Analysis using Python - A Case Study
Analyzing responses from the Stack Overflow Annual Developer Survey 2020
This tutorial is an executable Jupyter notebook. Click the Open in Colab button at the top of this page to execute the code.
Jupyter Notebooks: This notebook is made of cells. Each cell can contain code written in Python or explanations in plain English. You can execute code cells and view the results instantly within the notebook. Jupyter is a powerful platform for experimentation and analysis. Don't be afraid to mess around with the code & break things - you'll learn a lot by encountering and fixing errors. You can use the "Kernel > Restart & Clear Output" menu option to clear all outputs and start again from the top.
Introduction
We'll use the StackOverflow developer survey dataset for our analysis. This is an annual survey conducted by SO, and you find the raw data & results here: https://insights.stackoverflow.com/survey.
Let's retrive the files using urlretrieve.
url1 = "https://raw.githubusercontent.com/JovianHQ/notebooks/refs/heads/main/data-analysis-with-python-zero-to-pandas/lesson-6-exploratory-data-analysis-a-case-study/survey_results_public.csv"
url2 = "https://raw.githubusercontent.com/JovianHQ/notebooks/refs/heads/main/data-analysis-with-python-zero-to-pandas/lesson-6-exploratory-data-analysis-a-case-study/survey_results_schema.csv"
url3 = "https://raw.githubusercontent.com/JovianHQ/notebooks/refs/heads/main/data-analysis-with-python-zero-to-pandas/lesson-6-exploratory-data-analysis-a-case-study/README.txt"from urllib.request import urlretrieve
import os# Let's create the directory to save the files in.
os.makedirs("stackoverflow-developer-survey-2020", exist_ok = True)
# Let's retrive the files
urlretrieve(url1, './stackoverflow-developer-survey-2020/survey_results_public.csv')
urlretrieve(url2, './stackoverflow-developer-survey-2020/survey_results_schema.csv')
urlretrieve(url3, './stackoverflow-developer-survey-2020/README.txt')('./stackoverflow-developer-survey-2020/README.txt',
<http.client.HTTPMessage at 0x7f6f92c8f290>)Let's verify that the dataset was downloaded into the directory stackoverflow-developer-survey-2020, and retrieve the list of files in the dataset.
os.listdir('stackoverflow-developer-survey-2020')['survey_results_schema.csv', 'survey_results_public.csv', 'README.txt']You can through the downloaded files using File > Open menu option in Jupyter. It seems like the dataset contains 3 files:
README.txt- containing information about the datasetsurvey_results_schema.csv- containing the list of questions and the short codes for each questionsurvey_results_public.csv- the full list of responses to the questions
Let's load the CSV files using the Pandas library.
import pandas as pdsurvey_raw_df = pd.read_csv('stackoverflow-developer-survey-2020/survey_results_public.csv')survey_raw_dfThe dataset contains over 64,000 responses to 60 questions (although many questions are optional). The responses have been anonymized and there's no personally identifiable information about respondents - just a Randomized respondent ID.
survey_raw_df.columnsIndex(['Respondent', 'MainBranch', 'Hobbyist', 'Age', 'Age1stCode', 'CompFreq',
'CompTotal', 'ConvertedComp', 'Country', 'CurrencyDesc',
'CurrencySymbol', 'DatabaseDesireNextYear', 'DatabaseWorkedWith',
'DevType', 'EdLevel', 'Employment', 'Ethnicity', 'Gender', 'JobFactors',
'JobSat', 'JobSeek', 'LanguageDesireNextYear', 'LanguageWorkedWith',
'MiscTechDesireNextYear', 'MiscTechWorkedWith',
'NEWCollabToolsDesireNextYear', 'NEWCollabToolsWorkedWith', 'NEWDevOps',
'NEWDevOpsImpt', 'NEWEdImpt', 'NEWJobHunt', 'NEWJobHuntResearch',
'NEWLearn', 'NEWOffTopic', 'NEWOnboardGood', 'NEWOtherComms',
'NEWOvertime', 'NEWPurchaseResearch', 'NEWPurpleLink', 'NEWSOSites',
'NEWStuck', 'OpSys', 'OrgSize', 'PlatformDesireNextYear',
'PlatformWorkedWith', 'PurchaseWhat', 'Sexuality', 'SOAccount',
'SOComm', 'SOPartFreq', 'SOVisitFreq', 'SurveyEase', 'SurveyLength',
'Trans', 'UndergradMajor', 'WebframeDesireNextYear',
'WebframeWorkedWith', 'WelcomeChange', 'WorkWeekHrs', 'YearsCode',
'YearsCodePro'],
dtype='object')Short codes are used as column names. We can refer to the schema file to see the full text of each question.
schema_fname = 'stackoverflow-developer-survey-2020/survey_results_schema.csv'
schema_raw = pd.read_csv(schema_fname, index_col='Column').QuestionTextschema_rawColumn
Respondent Randomized respondent ID number (not in order ...
MainBranch Which of the following options best describes ...
Hobbyist Do you code as a hobby?
Age What is your age (in years)? If you prefer not...
Age1stCode At what age did you write your first line of c...
...
WebframeWorkedWith Which web frameworks have you done extensive d...
WelcomeChange Compared to last year, how welcome do you feel...
WorkWeekHrs On average, how many hours per week do you wor...
YearsCode Including any education, how many years have y...
YearsCodePro NOT including education, how many years have y...
Name: QuestionText, Length: 61, dtype: objectschema_raw['YearsCodePro']'NOT including education, how many years have you coded professionally (as a part of your work)?'We've now loaded the dataset, and are ready to move on to the next step of preprocessing & cleaning the data for our analysis.
Data Preparation & Cleaning
While the survey responses contain a wealth of information, we'll limit our analysis to the following areas:
- Demographics of the survey respondents & the global programming community
- Distribution of programming skills, experience and preferences
- Employment-related information & preferences
Let's select a subset of columns with the relevant data.
selected_columns = [
# Demographics
'Country',
'Age',
'Gender',
'EdLevel',
'UndergradMajor',
# Programming experience
'Hobbyist',
'Age1stCode',
'YearsCode',
'YearsCodePro',
'LanguageWorkedWith',
'LanguageDesireNextYear',
'NEWLearn',
'NEWStuck',
# Employment
'Employment',
'DevType',
'WorkWeekHrs',
'JobSat',
'JobFactors',
'NEWOvertime',
'NEWEdImpt'
]len(selected_columns)20Let's extract a copy of the data from these columns into a new data frame survey_df, which we can continue to modify further without affecting the original data frame.
survey_df = survey_raw_df[selected_columns].copy()schema = schema_raw[selected_columns]Let's view some basic information about the data frame.
survey_df.shape(64461, 20)survey_df.info()<class 'pandas.core.frame.DataFrame'>
RangeIndex: 64461 entries, 0 to 64460
Data columns (total 20 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Country 64072 non-null object
1 Age 45446 non-null float64
2 Gender 50557 non-null object
3 EdLevel 57431 non-null object
4 UndergradMajor 50995 non-null object
5 Hobbyist 64416 non-null object
6 Age1stCode 57900 non-null object
7 YearsCode 57684 non-null object
8 YearsCodePro 46349 non-null object
9 LanguageWorkedWith 57378 non-null object
10 LanguageDesireNextYear 54113 non-null object
11 NEWLearn 56156 non-null object
12 NEWStuck 54983 non-null object
13 Employment 63854 non-null object
14 DevType 49370 non-null object
15 WorkWeekHrs 41151 non-null float64
16 JobSat 45194 non-null object
17 JobFactors 49349 non-null object
18 NEWOvertime 43231 non-null object
19 NEWEdImpt 48465 non-null object
dtypes: float64(2), object(18)
memory usage: 9.8+ MB
Most columns have the data type object, either because they contain values of different types, or they contain empty values, which are represented np.NaN. It appears that every column contains some empty values, since the Non-Null count for every column is lower than the total number of rows (64461). We'll need to deal with empty values and manually adjust the data type for each column on a case-by-case basis.
Only two of the columns were detected as numeric columns (Age and WorkWeekHrs), even though there are a few other columns which have mostly numeric values. To make our analysis easier, let's convert some other columns into numeric data types, while ignoring any non-numeric value (they will get converted to NaNs)
survey_df['Age1stCode'] = pd.to_numeric(survey_df.Age1stCode, errors='coerce')
survey_df['YearsCode'] = pd.to_numeric(survey_df.YearsCode, errors='coerce')
survey_df['YearsCodePro'] = pd.to_numeric(survey_df.YearsCodePro, errors='coerce')Let's now view some basic statistics about the the numeric columns.
survey_df.describe()There seems to be a problem with the age column, as the minimum value is 1 and max value is 279. This is a common issues with surveys: responses may contain invalid values due to accidental or intentional errors while responding. A simple fix would be ignore the rows where the value in the age column is higher than 100 years or lower than 10 years as invalid survey responses.
survey_df.drop(survey_df[survey_df.Age < 10].index, inplace=True)
survey_df.drop(survey_df[survey_df.Age > 100].index, inplace=True)The same hold true for WorkWeekHrs. Let's ignore entries where the value for the column is higher than 140 hours.
survey_df.drop(survey_df[survey_df.WorkWeekHrs > 140].index, inplace=True)The gender column also allows picking multiple options, but to simplify our analysis, we'll remove values containing options.
survey_df['Gender'].value_counts()Gender
Man 45895
Woman 3835
Non-binary, genderqueer, or gender non-conforming 385
Man;Non-binary, genderqueer, or gender non-conforming 121
Woman;Non-binary, genderqueer, or gender non-conforming 92
Woman;Man 73
Woman;Man;Non-binary, genderqueer, or gender non-conforming 25
Name: count, dtype: int64import numpy as npsurvey_df.where(~(survey_df.Gender.str.contains(';', na=False)), np.nan, inplace=True)We've now cleaned up and prepared the dataset for analysis.
survey_dfimport seaborn as sns
import matplotlib
import matplotlib.pyplot as plt
%matplotlib inline
sns.set_style('darkgrid')
matplotlib.rcParams['font.size'] = 14
matplotlib.rcParams['figure.figsize'] = (9, 5)
matplotlib.rcParams['figure.facecolor'] = '#00000000'survey_df.Country.nunique()183Let's also check the fraction of empty values in this column.
survey_df.Country.isna().mean()np.float64(0.010885453923428608)Here are the countries with the highest number of respondents.
top_countries = survey_df.Country.value_counts().head(15)
top_countriesCountry
United States 12371
India 8364
United Kingdom 3881
Germany 3864
Canada 2175
France 1884
Brazil 1804
Netherlands 1332
Poland 1259
Australia 1199
Spain 1157
Italy 1115
Russian Federation 1085
Sweden 879
Pakistan 802
Name: count, dtype: int64We can visualize this using a bar chart.
plt.figure(figsize=(12,6))
plt.xticks(rotation=75)
sns.barplot(x=top_countries.index, y=top_countries.values);It appears the that a disproportionately high number of respondents are from USA & India - which one might expect since the Survey is in English.
plt.figure(figsize=(12, 6))
plt.title('Distribution of Age')
plt.xlabel('Age')
plt.ylabel('Number of respondents')
plt.hist(survey_df.Age, bins=np.arange(10,80,5), color='purple');gender_counts = survey_df.Gender.value_counts()
gender_countsGender
Man 45895
Woman 3835
Non-binary, genderqueer, or gender non-conforming 385
Name: count, dtype: int64A pie chart would be a good way to represent this split.
plt.figure(figsize=(12,6))
plt.pie(gender_counts, labels=gender_counts.index, autopct='%1.1f%%', startangle=180);Education Level
Let's compare the education levels using a horizontal bar plot
sns.countplot(data=survey_df, y='EdLevel')
plt.xticks(rotation=75);
plt.title(schema['EdLevel'])
plt.ylabel(None);
Let's also plot undergraduate majors, but this time use percentages, and sort by it.
undergrad_pct = survey_df.UndergradMajor.value_counts() * 100 / survey_df.UndergradMajor.count()
sns.barplot(x=undergrad_pct.values, y=undergrad_pct.index)
plt.title(schema.UndergradMajor)
plt.ylabel(None);
plt.xlabel('Percentage')
Text(0.5, 0, 'Percentage')Employment
We can also plot directly using Pandas
(survey_df.Employment.value_counts(normalize=True, ascending=True)*100).plot(kind='barh', color='g')
plt.title(schema.Employment)
plt.xlabel('Percentage');The DevType field is also relevant, but since the question allows multiple answers, the column contains lists of values separated by ;, which makes it a bit harder to analyze directly.
schema.DevType'Which of the following describe you? Please select all that apply.'survey_df.DevType.value_counts()DevType
Developer, full-stack 4396
Developer, back-end 3056
Developer, back-end;Developer, front-end;Developer, full-stack 2214
Developer, back-end;Developer, full-stack 1465
Developer, front-end 1390
...
Data or business analyst;Data scientist or machine learning specialist;Database administrator;Designer;Developer, back-end;Developer, desktop or enterprise applications;Developer, front-end;Developer, full-stack;Developer, game or graphics;Developer, mobile;Developer, QA or test;DevOps specialist;Engineer, data;Engineer, site reliability;Engineering manager;Marketing or sales professional;Product manager;Senior executive/VP;System administrator 1
Data or business analyst;Developer, back-end;DevOps specialist;Engineering manager 1
Academic researcher;Data or business analyst;DevOps specialist;Engineer, data 1
Developer, embedded applications or devices;Developer, QA or test;Engineering manager 1
Academic researcher;Designer;Developer, back-end;Developer, desktop or enterprise applications;Developer, front-end;Developer, full-stack;Developer, game or graphics;Developer, mobile;Developer, QA or test;Educator 1
Name: count, Length: 8213, dtype: int64Let's define a helper function which turns a column containing lists of values into a data frame with one column for each option.
def split_multicolumn(col_series):
result_df = col_series.to_frame().copy()
options = []
for idx, value in col_series[col_series.notna()].items():
for option in value.split(';'):
option = option.strip()
if option not in result_df.columns:
options.append(option)
result_df[option] = False
result_df.at[idx, option] = True
return result_df[options]
dev_type_df = split_multicolumn(survey_df.DevType)dev_type_dfdev_type_totals = dev_type_df.sum().sort_values(ascending=False)
dev_type_totalsDeveloper, back-end 26996
Developer, full-stack 26915
Developer, front-end 18128
Developer, desktop or enterprise applications 11687
Developer, mobile 9406
DevOps specialist 5915
Database administrator 5658
Designer 5262
System administrator 5185
Developer, embedded applications or devices 4701
Data or business analyst 3970
Data scientist or machine learning specialist 3939
Developer, QA or test 3893
Engineer, data 3700
Academic researcher 3502
Educator 2895
Developer, game or graphics 2751
Engineering manager 2699
Product manager 2471
Scientist 2060
Engineer, site reliability 1921
Senior executive/VP 1292
Marketing or sales professional 625
dtype: int64And we can now plot it
plt.figure(figsize=(12, 12))
sns.barplot(x=dev_type_totals.values, y=dev_type_totals.index)
plt.title(schema.DevType);
plt.xlabel('count')
Text(0.5, 0, 'count')Let's also create a scatter plot of YearsCode and YearsCodePro.
sns.scatterplot(data=survey_df, x='YearsCode', y='YearsCodePro', hue='Hobbyist')
plt.xlabel("Years of coding experience")
plt.ylabel("Years of professional coding experience");
There seems to be no correlation as such.
Q: Which were the most popular programming languages in 2020?
To answer, this we can use the LanguageWorkedWith column. Similar to DevType it allowed choosing multiple
selected_columns = [
# Demographics
'Country',
'Age',
'Gender',
'EdLevel',
'UndergradMajor',
# Programming experience
'Hobbyist',
'Age1stCode',
'DevType',
'YearsCode',
'YearsCodePro',
'LanguageWorkedWith',
'LanguageDesireNextYear',
'NEWLearn',
'NEWStuck',
# Employment
'Employment',
'WorkWeekHrs',
'JobSat',
'JobFactors',
'NEWOvertime',
'NEWEdImpt'
]