Understanding feature selection and feature engineering when performing regressions#
Even with a large and complete dataset, not all of the features end up being important, and you don't always have all the fields you need. Sometimes performing calculations between two or more columns can yield a large improvement in your predictor's performance.
import pandas as pd
import numpy as np
from sklearn.linear_model import LogisticRegression
pd.set_option("display.max_columns", 200)
pd.set_option("display.max_colwidth", 200)
%matplotlib inline
people = pd.read_csv("data/combined-person-data.csv")
people.head(2)
How often did each severity of injury show up? (e.g. not injured, non-incapacitating injury, etc)
people.INJ_SEVER_CODE.value_counts()
We're only interested in fatalities, so let's create a new had_fatality
column for when people received a fatal injury.
Confirm there were 1681 people with fatal injuries.
people['had_fatality'] = (people.INJ_SEVER_CODE == 5).astype(int)
people.had_fatality.value_counts()
Working on Features#
Starting our analysis#
We're going to run a regression on the impact of being male vs female on crash fatalities. Prepare a dataframe called train_df
with the appropriate information in it.
- Tip: What column(s) are your input, and what is your output? Aka independent and dependent variables
- Tip: You'll need to convert your input column into something numeric, I suggest using
.replace
- Tip: We aren't interested in the "Unknown" sex - either filtering or
np.nan
+.dropna()
might be useful ways to get rid of those columns
people['SEX_CODE'] = people.SEX_CODE.replace({'M': 0, 'F': 1, 'U': np.nan})
train_df = people[['SEX_CODE', 'had_fatality']].copy()
train_df = train_df.dropna()
Confirm that your train_df
has two columns and 815,827 rows.
Tip: If you have more rows, make sure you dropped all of the rows with Unknown sex.
Tip: If you have more columns, make sure you only have your input and output columns.
train_df.shape
Run your regression#
See the effect of sex on whether the person's injuries are fatal or not. After we train the regression, we can use my ✨favorite technique✨ to display features and their odds ratios:
feature_names = X.columns
coefficients = clf.coef_[0]
pd.DataFrame({
'feature': feature_names,
'coefficient (log odds ratio)': coefficients,
'odds ratio': np.exp(coefficients).round(4)
}).sort_values(by='odds ratio', ascending=False)
X = train_df.drop(columns='had_fatality')
y = train_df.had_fatality
clf = LogisticRegression(C=1e9, solver='lbfgs')
clf.fit(X, y)
feature_names = X.columns
coefficients = clf.coef_[0]
pd.DataFrame({
'feature': feature_names,
'coefficient (log odds ratio)': coefficients,
'odds ratio': np.exp(coefficients).round(4)
}).sort_values(by='odds ratio', ascending=False)
Use words to interpret this result#
# Women are about 50% less likely to die in a car crash
Adding more features#
The actual crash data has more details - whether it was snowy/icy, whether it was a highway, etc.
Read in combined-crash-data.csv
, calling it crashes
, and merge it with our people dataset. I'll save you a lookup: the REPORT_NO
is what matches between the two.
crashes = pd.read_csv("data/combined-crash-data.csv")
crashes.head(2)
merged = people.merge(crashes, on='REPORT_NO')
merged.head()
Examining more possible features#
How often was it wet, dry, snowy, icy, etc? What was the most common condition?
merged.SURF_COND_CODE.value_counts()
Do you feel that a Dry road condition should be the average of Wet and Snow?
# Nope
The answer to that should be no, which means we can't use this data as numeric data. We want a different coefficient for each of these - I want to know the impact of dry, the impact of wet, the impact of snow, all separately.
Start by replacing each code with a proper description. I'll even include them here:
00
- Not Applicable01
- Wet02
- Dry03
- Snow04
- Ice05
- Mud, Dirt, Gravel06
- Slush07
- Water (standing/moving)08
- Sand09
- Oil88
- Other99
- Unknown
But watch out, pandas read the column in as numbers so they might have come through a little differently than their codes.
merged['SURF_COND_CODE'] = merged.SURF_COND_CODE.replace({
0: 'Not Applicable',
1: 'Wet',
2: 'Dry',
3: 'Snow',
4: 'Ice',
5: 'Mud, Dirt, Gravel',
6: 'Slush',
7: 'Water (standing/moving)',
8: 'Sand',
9: 'Oil',
88: 'Other',
99: 'Unknown',
})
Confirm you have 147,803 wet, and a few codes you can't understand, like 6.03
and 7.01
.
merged.SURF_COND_CODE.value_counts()
Replace the codes you don't understand with Other
.
merged['SURF_COND_CODE'] = merged.SURF_COND_CODE.replace({
6.03: 'Other',
7.01: 'Other',
9.88: 'Other',
8.05: 'Other'
})
Confirm you have 3,196 'Other'.
merged.SURF_COND_CODE.value_counts()
One-hot encoding#
We're going to use pd.get_dummies
to build a variable you'll call surf_dummies
. Each surface condition should be a 0
or 1
as to whether it was that condition (dry, icy, wet, etc).
Use a prefix=
so we know they are surface conditions.
You'll want to drop the column you'll use as the reference category.
Before we do this: which column works best as the reference?
# Dry
Now build your surf_dummies
variable.
surf_dummies = pd.get_dummies(merged.SURF_COND_CODE, prefix='surface').drop(columns='surface_Dry')
surf_dummies.head()
Confirm your surf_dummies
looks roughly like this:
surface_Ice | Surce_Mud, Dirt, Gravel | surface_Not Applicable | ... | surface_Wet |
---|---|---|---|---|
0 | 0 | 0 | ... | 0 |
0 | 0 | 0 | ... | 0 |
0 | 0 | 1 | ... | 0 |
0 | 0 | 1 | ... | 0 |
0 | 0 | 0 | ... | 1 |
Another regression#
Let's run another regression to see the impact of both sex and surface condition on fatalities.
Build your train_df
#
To build your train_df
, I recommend doing it either of these two ways. They both first select the important columns, then add in the one-hot encoded surf_dummies
columns.
train_df = pd.DataFrame({
'col1': merged.col1,
'col2': merged.col2,
'col3': merged.col3,
})
train_df = train_df.join(surf_dummies)
train_df = train_df.dropna()
or like this:
train_df = train_df[['col1','col2','col3']].copy()
train_df = train_df.join(surf_dummies)
train_df = train_df.dropna()
The second one is shorter, but the first one makes it easier to use comments to remove columns later.
train_df = pd.DataFrame({
'sex': merged.SEX_CODE,
'had_fatality': merged.had_fatality
})
train_df = train_df.join(surf_dummies)
train_df = train_df.dropna()
Run your regression and check your odds ratios#
Actually no, wait, first - what kind of surface do you think will have the highest fatality rate?
# Wet
train_df.shape
Confirm your train_df
has 815,843 rows and 9 columns.
- Tip: When you run your regression, if you get an error about not knowing what to do with
U
, it's because you didn't convert your sex to numbers (or if you did, you didn't do it in your original dataframe)
X = train_df.drop(columns='had_fatality')
y = train_df.had_fatality
clf = LogisticRegression(C=1e9, solver='lbfgs')
clf.fit(X, y)
feature_names = X.columns
coefficients = clf.coef_[0]
pd.DataFrame({
'feature': feature_names,
'coefficient (log odds ratio)': coefficients,
'odds ratio': np.exp(coefficients).round(4)
}).sort_values(by='odds ratio', ascending=False)
Is this what you expected? Why do you think this result might be the case?
More features: Vehicles#
Maybe the car they're in is related to the car they were in. Luckily, we have this information - read in combined_vehicle_data
as vehicles
.
vehicles = pd.read_csv("data/combined-vehicle-data.csv")
vehicles.head(2)
Weights of those cars#
The car weights are stored in another file since the info had to come from an API. I looked up the VINs - vehicle identification numbers - in a government database to try to get data for each of them.
Read them and build a new dataframe that is both the vehicle data along with their weights. You can call it vehicles
since you don't need the original weightless vehicle data any more.
weights = pd.read_csv("data/vins_and_weights.csv")
weights.head(2)
vehicles = vehicles.merge(weights, left_on='VIN_NO', right_on='VIN')
vehicles.head(2)
Confirm that your combined vehicles
dataset should have 534,436 rows and 35 columns. And yes, that's less than we were working with before - you haven't combined it with the people/crashes dataset yet.
vehicles.shape
Filter your data#
We only want vehicles that are "normal" - somewhere between 1500 and 6000 pounds. Filter your vehicles to only include those in that weight range.
vehicles = vehicles[(vehicles.weight >= 1500) & (vehicles.weight <= 6000)]
Confirm that you have 532,370 vehicles in the dataset.
vehicles.shape
Add this vehicle information to your merged data#
Now we'll have a dataframe that contains information on:
- The people themselves and their injuries
- The crash
- The vehicles
Every person came with a VEHICLE_ID
column that is the vehicle they were in. You'll want to merge on that.
merged = merged.merge(vehicles, on='VEHICLE_ID')
Confirm you have 99 columns and 616,212 rows. That is a lot of possible features!
merged.shape
Another regression, because we can't get enough#
Build another train_df
and run another regression about how car weight impacts the chance of fatalities. You'll want to confirm that your dataset has 616,212 and 2 columns.
train_df = merged[['weight', 'had_fatality']]
train_df = train_df.dropna()
train_df.shape
X = train_df.drop(columns='had_fatality')
y = train_df.had_fatality
clf = LogisticRegression(C=1e9, solver='lbfgs')
clf.fit(X, y)
feature_names = X.columns
coefficients = clf.coef_[0]
pd.DataFrame({
'feature': feature_names,
'coefficient (log odds ratio)': coefficients,
'odds ratio': np.exp(coefficients).round(4)
}).sort_values(by='odds ratio', ascending=False)
Can you translate that into plain English? Remember weight is in pounds.
I feel like pounds isn't the best measure for something like this. Remember how we had to adjust percentages with AP and life expectancy, and then change around the way we said things? It sounded like this:
Every 10% increase in unemployment translates to a year and a half loss of life expectancy
Instead of every single pound, maybe we could do every... some other number of pounds? One hundred? One thousand?
Run another regression with weight in thousands of pounds. Get another odds ratio. Give me another sentence English.
train_df = merged[['weight', 'had_fatality']]
train_df['weight'] = train_df['weight'] / 1000
train_df = train_df.dropna()
X = train_df.drop(columns='had_fatality')
y = train_df.had_fatality
clf = LogisticRegression(C=1e9, solver='lbfgs')
clf.fit(X, y)
feature_names = X.columns
coefficients = clf.coef_[0]
pd.DataFrame({
'feature': feature_names,
'coefficient (log odds ratio)': coefficients,
'odds ratio': np.exp(coefficients).round(4)
}).sort_values(by='odds ratio', ascending=False)
# Every thousand pounds heavier your car is increase translates to a 15% decrease in fatalities
Two-car accidents, struck and striker#
Here's the thing, though: it isn't just the weight of your car. It's the weight of both cars! If I'm in a big car and I have a wreck with a smaller car, it's the smaller car that's in trouble.
To get that value, we need to do some feature engineering, some calculating of new variables from our existing variables.
We need to jump through some hoops to do that.
Two-car accidents#
First we're going to count how many vehicles were in each accident. Since we're looking to compare the weight of two cars hitting each other, we're only going to want crashes with only two cars.
counted = vehicles.REPORT_NO.value_counts()
counted.head(10)
By using .value_counts
I can see how many cars were in each crash, and now I'm going to filter to get a list of all of the ones with two vehicles.
two_car_report_nos = counted[counted == 2].index
two_car_report_nos
And now we'll filter my vehicles so we only have those that were in two-vehicle crashes.
vehicles = vehicles[vehicles.REPORT_NO.isin(two_car_report_nos)]
Struck and striker#
To do the math correctly, we need both the risk of someone dying in the smaller car and the risk of someone dying in the bigger car. To do this we need to separate our cars into two groups:
- The 'struck' vehicle: did the person die inside?
- The 'striker' vehicle: how much heavier was it than the struck car?
But we don't know which car was which, so we have to try out both versions - pretending car A was the striker, then pretending car B was the striker. It's hard to explain, but you can read Pounds That Kill - The External Costs of Vehicle Weight.pdf
for more details on how it works.
cars_1 = vehicles.drop_duplicates(subset='REPORT_NO', keep='first')
cars_2 = vehicles.drop_duplicates(subset='REPORT_NO', keep='last')
cars_merged_1 = cars_1.merge(cars_2, on='REPORT_NO', suffixes=['_striker', '_struck'])
cars_merged_2 = cars_2.merge(cars_1, on='REPORT_NO', suffixes=['_striker', '_struck'])
vehicles_complete = pd.concat([cars_merged_1, cars_merged_2])
vehicles_complete.head()
Put people in their cars#
Which car was each person in? We'll assign that now.
merged = people.merge(vehicles_complete, left_on='VEHICLE_ID', right_on='VEHICLE_ID_struck')
merged.head(3)
Add the crash details#
You did this already! I'm going to do it for you. We're merging on REPORT_NO_x
because there are so many REPORT_NO
columns duplicated across our files that pandas started giving them weird names.
merged = merged.merge(crashes, left_on='REPORT_NO_x', right_on='REPORT_NO')
merged.head(3)
Filter#
We already filtered out vehicles by weight, so we don't have to do that again.
Calculated features#
I'm sure you forgot what all the features are, so we'll bring back whether there was a fatality or not
Feature: Accident was fatal#
merged['had_fatality'] = (merged.INJ_SEVER_CODE == 5).astype(int)
merged.had_fatality.value_counts()
Feature: Weight difference#
Remove everything missing weights for strikers or struck vehicles. You might need to merged.columns
to remind yourself what the column names are.
merged = merged.dropna(subset=['weight_striker', 'weight_struck'])
Confirm your dataset has roughly 335,000 rows.
merged.shape
Create a new feature called weight_diff
about how much heavier the striking car was compared to the struck car. Make sure you've done the math correctly!
merged['weight_diff'] = merged['weight_striker'] - merged['weight_struck']
Feature adjustment#
Make all of your weight columns in thousands of pounds instead of just in pounds. It'll help you interpret your results much better.
merged.weight_striker = merged.weight_striker / 1000
merged.weight_struck = merged.weight_struck / 1000
merged.weight_diff = merged.weight_diff / 1000
merged[['weight_striker', 'weight_struck', 'weight_diff']].describe()
Another regression!!!#
What is the impact of weight difference on fatality rate? Create your train_df
, drop missing values, run your regression, analyze your odds ratios.
train_df = merged[['weight_diff', 'had_fatality']].copy()
train_df = train_df.dropna()
X = train_df.drop(columns='had_fatality')
y = train_df.had_fatality
clf = LogisticRegression(C=1e9, solver='lbfgs')
clf.fit(X, y)
feature_names = X.columns
coefficients = clf.coef_[0]
pd.DataFrame({
'feature': feature_names,
'coefficient (log odds ratio)': coefficients,
'odds ratio': np.exp(coefficients).round(4)
}).sort_values(by='odds ratio', ascending=False)
Please translate your odds ratio into plain English.
# For every 1000 lbs heavier the striking car is, you have a 60% greater chance of dying
Adding in more features#
How about speed limit? That's important, right? We can add the speed limit of the striking vehicle with SPEED_LIMIT_striker
.
train_df = merged[['weight_diff', 'had_fatality', 'SPEED_LIMIT_striker']].copy()
train_df = train_df.dropna()
X = train_df.drop(columns='had_fatality')
y = train_df.had_fatality
clf = LogisticRegression(C=1e9, solver='lbfgs')
clf.fit(X, y)
feature_names = X.columns
coefficients = clf.coef_[0]
pd.DataFrame({
'feature': feature_names,
'coefficient (log odds ratio)': coefficients,
'odds ratio': np.exp(coefficients).round(4)
}).sort_values(by='odds ratio', ascending=False)
Can you translate the speed limit odds ratio into plain English?
Feature engineering: Speed limits#
Honestly, that's a pretty bad way to go about things. What's more fun is if we translate speed limits into bins.
First, we'll use pd.cut
to assign each speed limit a category.
speed_bins = [-np.inf, 10, 20, 30, 40, 50, np.inf]
merged['speed_bin'] = pd.cut(merged.SPEED_LIMIT_struck, bins=speed_bins)
merged[['SPEED_LIMIT_striker', 'speed_bin']].head(10)
Then we'll one-hot encode around 20-30mph speed limits.
speed_dummies = pd.get_dummies(merged.speed_bin,
prefix='speed').drop('speed_(20.0, 30.0]', axis=1)
speed_dummies.head()
Running a regression#
I like this layout for creating train_df
, it allows us to easily add dummies and do a little replacing/encoding when we're building binary features like for sex.
If the below gives you an error, it's because
SEX_CODE
is already a number. In that case, just remove.replace({'M': 1, 'F': 0, 'U': np.nan })
.
train_df = pd.DataFrame({
'weight_diff': merged.weight_diff,
'sex': merged.SEX_CODE,#.replace({'M': 1, 'F': 0, 'U': np.nan }),
'had_fatality': merged.had_fatality,
})
train_df = train_df.join(speed_dummies)
train_df = train_df.join(surf_dummies)
train_df = train_df.dropna()
train_df.head()
X = train_df.drop(columns='had_fatality')
y = train_df.had_fatality
clf = LogisticRegression(C=1e9, solver='lbfgs', max_iter=4000)
clf.fit(X, y)
feature_names = X.columns
coefficients = clf.coef_[0]
pd.DataFrame({
'feature': feature_names,
'coefficient (log odds ratio)': coefficients,
'odds ratio': np.exp(coefficients).round(4)
}).sort_values(by='odds ratio', ascending=False)
Describe the impact of the different variables in simple language. What has the largest impact?
Now you pick the features#
Up above you have examples of:
- Creating features from numbers (speed limits)
- Creating features from 0/1 (sex)
- Creating features from binning numbers that are one-hot encoded (speed limit bins -
speed_bins
) - Creating features from categories that are one-hot encoded (surface -
surf_dummies
What else do you think matters? Try to plug in more features and see if you can get anything interesting.
- Hot tip: The funniest/most interesting thing feature you can add is also the dumbest. Ask me about it in #algorithms if you end up getting down here.