Background

I worked with the "CTA_Ridership" dataset that I took from a data portal provided by the City of Chicago, which contains the number of rides per day for each CTA station name/ID since 2001. I used this data to assess CTA ridership and how it evolved from the beginning of 2001 until September of 2008.

Data Preparation/Cleaning

To get started I downloaded essential libraries of Python.

In [22]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from matplotlib.pyplot import figure
%matplotlib inline
In [23]:
data = pd.read_csv('CTA_-_Ridership_-__L__Station_Entries_-_Daily_Totals_UPDATED.csv')
In [24]:
data.head()
Out[24]:
station_id stationname date daytype rides
0 40350 UIC-Halsted 01/01/2001 U 273
1 41130 Halsted-Orange 01/01/2001 U 306
2 40760 Granville 01/01/2001 U 1059
3 40070 Jackson/Dearborn 01/01/2001 U 649
4 40090 Damen-Brown 01/01/2001 U 411
In [25]:
data.shape
Out[25]:
(923384, 5)

Dataset has three object types and two integers.

In [26]:
data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 923384 entries, 0 to 923383
Data columns (total 5 columns):
station_id     923384 non-null int64
stationname    923384 non-null object
date           923384 non-null object
daytype        923384 non-null object
rides          923384 non-null int64
dtypes: int64(2), object(3)
memory usage: 35.2+ MB
In [27]:
data.rename(columns={'stationname':'Station_Name','date':'Date','daytype':'Day_Type','rides':'Rides'},inplace=True)
data.head()
Out[27]:
station_id Station_Name Date Day_Type Rides
0 40350 UIC-Halsted 01/01/2001 U 273
1 41130 Halsted-Orange 01/01/2001 U 306
2 40760 Granville 01/01/2001 U 1059
3 40070 Jackson/Dearborn 01/01/2001 U 649
4 40090 Damen-Brown 01/01/2001 U 411

Here I'm comparing Saturdays with Sundays and Holidays. A - Saturday, U - Sundays and Holidays.

In [28]:
a_u = data.loc[data.Day_Type.isin(["A","U"]), ['Day_Type','Rides']].groupby('Day_Type').sum()
a_u
Out[28]:
Rides
Day_Type
A 299231035
U 242730083
In [29]:
from matplotlib.ticker import FuncFormatter
In [30]:
ind = np.arange(len(a_u))
plt.bar(ind, height=a_u.Rides)
plt.xticks(ind, ["Saturdays", "Sundays/Holidays"])
plt.title("Total Rides by Day Type")
def millions(val, pos):
    return f'{val*1e-6:.0f}M'

formatter = FuncFormatter(millions)
plt.gca().yaxis.set_major_formatter(formatter)

plt.ylabel("Number of Rides")
Out[30]:
Text(0, 0.5, 'Number of Rides')

Here we see that Saturday has a higher average ridership than Sundays and Holidays.

In [31]:
for_sns = data.loc[data.Day_Type.isin(["U","A"]), ['Day_Type','Rides']]
sns.barplot(x='Day_Type',y='Rides',data=for_sns, order=["A", "U"])
Out[31]:
<matplotlib.axes._subplots.AxesSubplot at 0x1a17637198>

To make further analysis more efficient, I converted the date column's object values to a non-null DateTime using a specific function, then derived years, months and weekdays by creating new variables for each.

In [32]:
import datetime
data.Date = pd.to_datetime(data.Date)
data.head()
Out[32]:
station_id Station_Name Date Day_Type Rides
0 40350 UIC-Halsted 2001-01-01 U 273
1 41130 Halsted-Orange 2001-01-01 U 306
2 40760 Granville 2001-01-01 U 1059
3 40070 Jackson/Dearborn 2001-01-01 U 649
4 40090 Damen-Brown 2001-01-01 U 411
In [33]:
data['Year'] = data.Date.dt.year
data['Month'] = data.Date.dt.month
data['Weekday_Name'] = data.Date.dt.weekday_name
data.head()
Out[33]:
station_id Station_Name Date Day_Type Rides Year Month Weekday_Name
0 40350 UIC-Halsted 2001-01-01 U 273 2001 1 Monday
1 41130 Halsted-Orange 2001-01-01 U 306 2001 1 Monday
2 40760 Granville 2001-01-01 U 1059 2001 1 Monday
3 40070 Jackson/Dearborn 2001-01-01 U 649 2001 1 Monday
4 40090 Damen-Brown 2001-01-01 U 411 2001 1 Monday

This data frame includes reports for 6481 days.

In [34]:
data.Date.max()-data.Date.min()
Out[34]:
Timedelta('6481 days 00:00:00')

Data Visualization

It's evident from the upcoming plot that CTA ridership has been steadily increasing over the years.

In [35]:
grouped = data.groupby(['Year']).Rides.sum()
grouped.loc[grouped.index != 2018].plot(kind="line", y="Rides")
plt.ylim(bottom=0)
Out[35]:
(0, 200527857.8)
In [36]:
by_year = data.loc[data.Year !=2018].groupby('Year').Rides.sum()
by_year = by_year.to_frame()
by_year = by_year.reset_index()
In [37]:
by_year
Out[37]:
Year Rides
0 2001 151739502
1 2002 152364552
2 2003 150319580
3 2004 148312412
4 2005 154987157
5 2006 161966231
6 2007 157903245
7 2008 165290763
8 2009 167215635
9 2010 173561960
10 2011 184188885
11 2012 189958315
12 2013 186706688
13 2014 194826889
14 2015 198041408
15 2016 195555726
16 2017 188665453
In [38]:
fig, ax = plt.subplots()
index = ['2001','2002','2003','2004','2005','2006','2007','2008','2009','2010',
        '2011','2012','2013','2014','2015','2016','2017']
plt.bar(index, by_year['Rides'])
plt.title('Rides over the years')
plt.xlabel('Year')
plt.ylabel('Rides')
plt.xticks(rotation=50)
Out[38]:
([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
 <a list of 17 Text xticklabel objects>)

Here I am creating a multi-index data frame for a heatmap.

In [39]:
multi = data.loc[data.Year!=2018].groupby(['Year','Month']).Rides.sum()
multi_plot = multi.unstack()
multi_plot
Out[39]:
Month 1 2 3 4 5 6 7 8 9 10 11 12
Year
2001 12286897 11415793 12914296 12287737 13148263 13016951 13382993 13374592 12314751 13916090 12476056 11205083
2002 12309991 11506712 12183410 12815884 12973404 12714364 13392822 13036551 13078121 14374766 12434841 11543686
2003 11860130 11203581 12341355 12391027 12602473 12715662 13376266 12687510 13328526 14448488 11813039 11551523
2004 11309196 11347503 12773186 12333972 12067329 12968602 12849068 12529356 13107556 13384825 12271717 11370102
2005 11581403 11401625 12659665 12755659 12964006 13498294 13139642 13538824 14194677 14336020 12938006 11979336
2006 12407347 11889136 13710273 12951806 14089195 14001407 14148715 14444081 14264352 14747955 13317975 11993989
2007 12617830 11366892 13445416 12574531 13647689 13698349 14046147 14035867 13603498 14710126 12796204 11360696
2008 12329732 12410563 12916861 13837585 13944972 14077655 14898685 14702332 14780937 15822528 13156204 12412709
2009 12670605 12495760 13906626 13884272 13766591 14391134 15133049 14337678 15039598 15404020 13580544 12605758
2010 12713539 12794456 14553926 14590334 14380178 15119802 15169200 15321024 15671310 15909767 14441053 12897371
2011 13481258 13081983 15432089 14812301 15151493 15980399 16825884 17036083 16220005 16695016 15339673 14132701
2012 14665493 14770667 16276278 15465258 16155559 16288673 15773338 16883876 16287906 17825589 15716325 13849353
2013 14757704 14033842 15251953 15885919 15715412 15256300 16037793 16234436 16001038 17598968 15654406 14278917
2014 14268127 14587621 16421749 16599115 16649634 16487634 17042911 16734994 17306655 18411645 15337239 14979565
2015 14698498 14097691 16449070 16404520 16492679 17255694 17977486 17049933 17772902 18632339 15984523 15226073
2016 14836599 15212277 16677633 16141007 16597555 17085925 16813640 16886476 17097002 17885645 16322767 13999200
2017 14626737 14142406 16073109 15169117 16298485 16737259 15917931 16967756 16572350 17336213 15338822 13485268

After generally rising from 2004 to 2015, CTA ridership dropped steadily in 2015 and early 2016.

In [40]:
multi_plot.sum(axis=1).plot()
plt.ylim(bottom=0)
Out[40]:
(0, 200527857.8)
In [41]:
multi_plot.loc[:, :].plot()
Out[41]:
<matplotlib.axes._subplots.AxesSubplot at 0x1a17a95860>

One might expect to see a significant decrease in the summertime because people are more active in the warm weather. They can walk, bike or skate, etc., to get to the desired spot, but from the plot below, we observe the most significant decrease happens during the winter time, especially January.

Why do we take a train? To get to the work, school, or seek entertainment. Admittedly, if the weather is unpleasant, and I can afford to use a Lyft or Uber service, I would instead do that than walk to the train station.

In [42]:
multi_plot.loc[:, [1, 3, 7, 10]].plot()
Out[42]:
<matplotlib.axes._subplots.AxesSubplot at 0x1a17dfa8d0>
In [43]:
sns.heatmap(multi_plot,cmap="YlGnBu")
Out[43]:
<matplotlib.axes._subplots.AxesSubplot at 0x1a1a07d550>
Ten busiest stations.

It's not surprising that the downtown stations are the busiest given the number of people that work and seek entertainment near those stations. A notable exception is O'Hare, which is understandable given that the CTA provides a convenient way to access one of the busiest airports in the world.

In [44]:
by_station = data[['Station_Name','Rides']]
by_station = by_station.groupby('Station_Name').sum()
by_station = by_station.sort_values(by='Rides', ascending=False)
by_station.head(10)
Out[44]:
Rides
Station_Name
Clark/Lake 90440007
Lake/State 89436950
Chicago/State 84027079
95th/Dan Ryan 68656216
Belmont-North Main 67860822
Fullerton 66562305
Grand/State 62054451
O'Hare Airport 59339717
Jackson/State 57532433
Roosevelt 55203280
Ten least busy stations.

The low ridership at the Washington/Wabash, which is the downtown area, is explainable. It didn't open until August 31, 2017.

In [45]:
by_station.tail(10)
Out[45]:
Rides
Station_Name
Foster 4332919
South Boulevard 4269501
Noyes 4015029
Dempster-Skokie 3959918
King Drive 3645039
Washington/Wabash 3383379
Kostner 2184938
Cermak-McCormick Place 1715943
Oakton-Skokie 1695782
Homan 27

My next interest is to find out the cause of the decrease for the CTA ridership last couple of years. I found an 'L' stops dataset from the same data portal that provided me with location and basic service availability information for each place on the CTA system where a train stops, along with formal station names and stops descriptions. I also think that decrease could occur on only particular lines. Let's investigate it further with the new dataset.

In [46]:
stops_data = pd.read_csv("CTA_-_System_Information_-_List_of__L__Stops.csv")
In [47]:
stops_data.shape
Out[47]:
(300, 17)
In [48]:
stops_data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 300 entries, 0 to 299
Data columns (total 17 columns):
STOP_ID                     300 non-null int64
DIRECTION_ID                300 non-null object
STOP_NAME                   300 non-null object
STATION_NAME                300 non-null object
STATION_DESCRIPTIVE_NAME    300 non-null object
MAP_ID                      300 non-null int64
ADA                         300 non-null bool
RED                         300 non-null bool
BLUE                        300 non-null bool
G                           300 non-null bool
BRN                         300 non-null bool
P                           300 non-null bool
Pexp                        300 non-null bool
Y                           300 non-null bool
Pnk                         300 non-null bool
O                           300 non-null bool
Location                    300 non-null object
dtypes: bool(10), int64(2), object(5)
memory usage: 19.4+ KB
In [49]:
stops_data.head()
Out[49]:
STOP_ID DIRECTION_ID STOP_NAME STATION_NAME STATION_DESCRIPTIVE_NAME MAP_ID ADA RED BLUE G BRN P Pexp Y Pnk O Location
0 30161 E 18th (Loop-bound) 18th 18th (Pink Line) 40830 True False False False False False False False True False (41.857908, -87.669147)
1 30162 W 18th (54th/Cermak-bound) 18th 18th (Pink Line) 40830 True False False False False False False False True False (41.857908, -87.669147)
2 30022 N 35th/Archer (Loop-bound) 35th/Archer 35th/Archer (Orange Line) 40120 True False False False False False False False False True (41.829353, -87.680622)
3 30023 S 35th/Archer (Midway-bound) 35th/Archer 35th/Archer (Orange Line) 40120 True False False False False False False False False True (41.829353, -87.680622)
4 30213 N 35-Bronzeville-IIT (Harlem-bound) 35th-Bronzeville-IIT 35th-Bronzeville-IIT (Green Line) 41120 True False False True False False False False False False (41.831677, -87.625826)
In [50]:
stops_data.columns
Out[50]:
Index(['STOP_ID', 'DIRECTION_ID', 'STOP_NAME', 'STATION_NAME',
       'STATION_DESCRIPTIVE_NAME', 'MAP_ID', 'ADA', 'RED', 'BLUE', 'G', 'BRN',
       'P', 'Pexp', 'Y', 'Pnk', 'O', 'Location'],
      dtype='object')

For the better readability I converted column names. I decided to work only with line's columns to find out certain lines where decrease has occured.

In [51]:
stops_data.rename({"G": "GREEN", "BRN": "BROWN", "P": "PURPLE", "Y": "YELLOW", "Pnk": "PINK",
                   "O": "ORANGE"}, axis="columns", inplace=True)
In [52]:
stops_data.head()
Out[52]:
STOP_ID DIRECTION_ID STOP_NAME STATION_NAME STATION_DESCRIPTIVE_NAME MAP_ID ADA RED BLUE GREEN BROWN PURPLE Pexp YELLOW PINK ORANGE Location
0 30161 E 18th (Loop-bound) 18th 18th (Pink Line) 40830 True False False False False False False False True False (41.857908, -87.669147)
1 30162 W 18th (54th/Cermak-bound) 18th 18th (Pink Line) 40830 True False False False False False False False True False (41.857908, -87.669147)
2 30022 N 35th/Archer (Loop-bound) 35th/Archer 35th/Archer (Orange Line) 40120 True False False False False False False False False True (41.829353, -87.680622)
3 30023 S 35th/Archer (Midway-bound) 35th/Archer 35th/Archer (Orange Line) 40120 True False False False False False False False False True (41.829353, -87.680622)
4 30213 N 35-Bronzeville-IIT (Harlem-bound) 35th-Bronzeville-IIT 35th-Bronzeville-IIT (Green Line) 41120 True False False True False False False False False False (41.831677, -87.625826)
In [53]:
colors = ['RED', 'BLUE', 'GREEN','BROWN', 'PURPLE', 'YELLOW', 'PINK', 'ORANGE']
stops = stops_data.groupby('MAP_ID').sum()[colors]
In [54]:
stops = stops > 0
In [55]:
stops['line_count'] = stops.apply(lambda x: sum(x), axis=1)
In [56]:
stops.head()
Out[56]:
RED BLUE GREEN BROWN PURPLE YELLOW PINK ORANGE line_count
MAP_ID
40010 False True False False False False False False 1
40020 False False True False False False False False 1
40030 False False True False False False False False 1
40040 False False False True False False True True 3
40050 False False False False True False False False 1

Here I'm merging stops dataset with the previous one.

In [57]:
merged = data.merge(stops, how="inner", left_on="station_id", right_index=True)
In [58]:
merged.shape
Out[58]:
(907700, 17)

Here I divided Rides by line count so we can get the number of rides for every single line.

In [59]:
merged["rides_split"] = merged['Rides'] / merged["line_count"]
In [60]:
merged.head()
Out[60]:
station_id Station_Name Date Day_Type Rides Year Month Weekday_Name RED BLUE GREEN BROWN PURPLE YELLOW PINK ORANGE line_count rides_split
0 40350 UIC-Halsted 2001-01-01 U 273 2001 1 Monday False True False False False False False False 1 273.0
281 40350 UIC-Halsted 2001-01-02 W 1775 2001 1 Tuesday False True False False False False False False 1 1775.0
380 40350 UIC-Halsted 2001-01-03 W 1945 2001 1 Wednesday False True False False False False False False 1 1945.0
520 40350 UIC-Halsted 2001-01-04 W 2049 2001 1 Thursday False True False False False False False False 1 2049.0
576 40350 UIC-Halsted 2001-01-05 W 2145 2001 1 Friday False True False False False False False False 1 2145.0
In [61]:
tidy = merged.melt(id_vars=["rides_split", "Year"], value_vars=colors)
In [62]:
agg = tidy.loc[tidy.value == True].groupby(["variable", "Year"]).sum()[['rides_split']].reset_index().pivot(index="Year", columns="variable", values="rides_split")
agg
Out[62]:
variable BLUE BROWN GREEN ORANGE PINK PURPLE RED YELLOW
Year
2001 33865407.6 2.072044e+07 1.168378e+07 1.184586e+07 6.799123e+06 3.721133e+06 5.664414e+07 1.340514e+06
2002 34198497.0 2.061362e+07 1.164657e+07 1.186950e+07 6.487685e+06 3.691313e+06 5.754893e+07 1.290552e+06
2003 34254024.6 2.049722e+07 1.142480e+07 1.179073e+07 6.081805e+06 3.725025e+06 5.624450e+07 1.248086e+06
2004 34722251.0 1.999848e+07 1.117730e+07 1.168161e+07 6.227071e+06 3.640151e+06 5.418368e+07 1.222303e+06
2005 36420877.8 2.073970e+07 1.197740e+07 1.222783e+07 7.372824e+06 3.675578e+06 5.515340e+07 1.264254e+06
2006 37143123.0 2.137513e+07 1.316785e+07 1.300450e+07 8.299912e+06 3.683375e+06 5.842055e+07 1.267907e+06
2007 35154547.6 2.050687e+07 1.379676e+07 1.337732e+07 9.051837e+06 3.608735e+06 5.733811e+07 1.222013e+06
2008 37469468.2 2.150122e+07 1.442288e+07 1.388000e+07 9.929510e+06 3.857722e+06 5.890128e+07 1.351210e+06
2009 35841714.0 2.323442e+07 1.397110e+07 1.334631e+07 9.732629e+06 3.825156e+06 6.206706e+07 1.381305e+06
2010 38389348.6 2.449536e+07 1.415624e+07 1.325109e+07 9.785793e+06 3.820456e+06 6.431838e+07 1.445807e+06
2011 41998771.2 2.644686e+07 1.471940e+07 1.403480e+07 1.042249e+07 3.964854e+06 6.697212e+07 1.488367e+06
2012 44258122.0 2.762370e+07 1.511313e+07 1.466414e+07 1.094478e+07 3.982528e+06 6.737357e+07 1.649713e+06
2013 45488965.8 2.783215e+07 1.854055e+07 1.472896e+07 1.104796e+07 3.911393e+06 5.916106e+07 1.669586e+06
2014 46062158.0 2.813722e+07 1.550121e+07 1.478298e+07 1.134472e+07 3.992435e+06 6.896777e+07 1.667722e+06
2015 47954372.8 2.786614e+07 1.600629e+07 1.517913e+07 1.188922e+07 4.058505e+06 7.050709e+07 1.209839e+06
2016 47410395.2 2.844400e+07 1.509343e+07 1.515046e+07 1.155099e+07 3.910705e+06 6.946917e+07 1.519796e+06
2017 46313891.6 2.806281e+07 1.495477e+07 1.508449e+07 1.153090e+07 3.800883e+06 6.540234e+07 1.527440e+06
2018 34203006.4 2.138958e+07 1.157787e+07 1.141146e+07 9.054718e+06 2.803359e+06 4.760219e+07 1.111365e+06

Here we see an abrupt decrease in the yellow line in 2015. If you google, you'll run into an article that says; there was a five-month shutdown in the CTA Yellow Line this year.

In [63]:
agg.loc[agg.index != 2018, ["YELLOW"]].plot(kind="line", legend="right")
Out[63]:
<matplotlib.axes._subplots.AxesSubplot at 0x1a17a7a588>

We also notice from the following plot that ridership on the pink line had increased at a fast speed during 2004-2015. Brown line is the least active line.

In [64]:
(agg / agg.loc[2001]).loc[agg.index != 2018].plot(kind="line", legend="right")
Out[64]:
<matplotlib.axes._subplots.AxesSubplot at 0x1a1a236d68>

Conclusion

From the analysis above, we were able to extract insightful information like a decrease or increase of the ridership for particular lines. What could be the primary influence on the decline of ridership on some lines? Can you consider that it might be a matter of safety, that people try to avoid taking trains from certain lines or it's just a matter of location?! My further goal is to try to answer this question using "Crime data" from the Chicago data portal.

In [65]:
df = pd.read_csv('Crimes_-_2001_to_present.csv')
In [66]:
df.head()
Out[66]:
ID Case Number Date Block IUCR Primary Type Description Location Description Arrest Domestic ... Ward Community Area FBI Code X Coordinate Y Coordinate Year Updated On Latitude Longitude Location
0 10000092 HY189866 03/18/2015 07:44:00 PM 047XX W OHIO ST 041A BATTERY AGGRAVATED: HANDGUN STREET False False ... 28.0 25.0 04B 1144606.0 1903566.0 2015 02/10/2018 03:50:01 PM 41.891399 -87.744385 (41.891398861, -87.744384567)
1 10000094 HY190059 03/18/2015 11:00:00 PM 066XX S MARSHFIELD AVE 4625 OTHER OFFENSE PAROLE VIOLATION STREET True False ... 15.0 67.0 26 1166468.0 1860715.0 2015 02/10/2018 03:50:01 PM 41.773372 -87.665319 (41.773371528, -87.665319468)
2 10000095 HY190052 03/18/2015 10:45:00 PM 044XX S LAKE PARK AVE 0486 BATTERY DOMESTIC BATTERY SIMPLE APARTMENT False True ... 4.0 39.0 08B 1185075.0 1875622.0 2015 02/10/2018 03:50:01 PM 41.813861 -87.596643 (41.81386068, -87.596642837)
3 10000096 HY190054 03/18/2015 10:30:00 PM 051XX S MICHIGAN AVE 0460 BATTERY SIMPLE APARTMENT False False ... 3.0 40.0 08B 1178033.0 1870804.0 2015 02/10/2018 03:50:01 PM 41.800802 -87.622619 (41.800802415, -87.622619343)
4 10000097 HY189976 03/18/2015 09:00:00 PM 047XX W ADAMS ST 031A ROBBERY ARMED: HANDGUN SIDEWALK False False ... 28.0 25.0 03 1144920.0 1898709.0 2015 02/10/2018 03:50:01 PM 41.878065 -87.743354 (41.878064761, -87.743354013)

5 rows × 22 columns

In [67]:
df.rename(columns={'Description':'description'}, inplace=True)
In [68]:
df.rename(columns={'Location Description':'location_description'}, inplace=True)
In [69]:
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 6800825 entries, 0 to 6800824
Data columns (total 22 columns):
ID                      int64
Case Number             object
Date                    object
Block                   object
IUCR                    object
Primary Type            object
description             object
location_description    object
Arrest                  bool
Domestic                bool
Beat                    int64
District                float64
Ward                    float64
Community Area          float64
FBI Code                object
X Coordinate            float64
Y Coordinate            float64
Year                    int64
Updated On              object
Latitude                float64
Longitude               float64
Location                object
dtypes: bool(2), float64(7), int64(3), object(10)
memory usage: 1.0+ GB