Analyze NYC Collision Data on Linux CLI
/ 15 min read
Just When You Think it’s Safe to Go Outside
Statistics Relating to Traffic Collisions in New York City.
Background
NYC publishes vehicle collision data which anyone can access using their API. You can also download this information in standard CSV (Comma Separated Values) file format. The file is fairly large, 420 MB, with almost 2 Million lines.
# all_motor_vehicle_collision_data.csv"-rw-rw-r-- 1 austin austin 402M Mar 4 20:38 all_motor_vehicle_collision_data.csv…bash > wc -l all_motor_vehicle_collision_data.csv1972886 all_motor_vehicle_collision_data.csvThe head Command
bash > head -n5 all_motor_vehicle_collision_data.csvCRASH DATE,CRASH TIME,BOROUGH,ZIP CODE,LATITUDE,LONGITUDE,LOCATION,ON STREET NAME,CROSS STREET NAME,OFF STREET NAME,NUMBER OF PERSONS INJURED,NUMBER OF PERSONS KILLED,NUMBER OF PEDESTRIANS INJURED,NUMBER OF PEDESTRIANS KILLED,NUMBER OF CYCLIST INJURED,NUMBER OF CYCLIST KILLED,NUMBER OF MOTORIST INJURED,NUMBER OF MOTORIST KILLED,CONTRIBUTING FACTOR VEHICLE 1,CONTRIBUTING FACTOR VEHICLE 2,CONTRIBUTING FACTOR VEHICLE 3,CONTRIBUTING FACTOR VEHICLE 4,CONTRIBUTING FACTOR VEHICLE 5,COLLISION_ID,VEHICLE TYPE CODE 1,VEHICLE TYPE CODE 2,VEHICLE TYPE CODE 3,VEHICLE TYPE CODE 4,VEHICLE TYPE CODE 509/11/2021,2:39,,,,,,WHITESTONE EXPRESSWAY,20 AVENUE,,2,0,0,0,0,0,2,0,Aggressive Driving/Road Rage,Unspecified,,,,4455765,Sedan,Sedan,,,03/26/2022,11:45,,,,,,QUEENSBORO BRIDGE UPPER,,,1,0,0,0,0,0,1,0,Pavement Slippery,,,,,4513547,Sedan,,,,06/29/2022,6:55,,,,,,THROGS NECK BRIDGE,,,0,0,0,0,0,0,0,0,Following Too Closely,Unspecified,,,,4541903,Sedan,Pick-up Truck,,,09/11/2021,9:35,BROOKLYN,11208,40.667202,-73.8665,"(40.667202, -73.8665)",,,1211 LORING AVENUE,0,0,0,0,0,0,0,0,Unspecified,,,,,4456314,Sedan,,,,Display the first record only with head.
bash > head -n1 all_motor_vehicle_collision_data.csvCRASH DATE,CRASH TIME,BOROUGH,ZIP CODE,LATITUDE,LONGITUDE,LOCATION,ON STREET NAME,CROSS STREET NAME,OFF STREET NAME,NUMBER OF PERSONS INJURED,NUMBER OF PERSONS KILLED,NUMBER OF PEDESTRIANS INJURED,NUMBER OF PEDESTRIANS KILLED,NUMBER OF CYCLIST INJURED,NUMBER OF CYCLIST KILLED,NUMBER OF MOTORIST INJURED,NUMBER OF MOTORIST KILLED,CONTRIBUTING FACTOR VEHICLE 1,CONTRIBUTING FACTOR VEHICLE 2,CONTRIBUTING FACTOR VEHICLE 3,CONTRIBUTING FACTOR VEHICLE 4,CONTRIBUTING FACTOR VEHICLE 5,COLLISION_ID,VEHICLE TYPE CODE 1,VEHICLE TYPE CODE 2,VEHICLE TYPE CODE 3,VEHICLE TYPE CODE 4,VEHICLE TYPE CODE 5Using Perl
bash > perl -F, -an -E '$. == 1 && say $i++ . "\t$_" for @F' all_motor_vehicle_collision_data.csv0 CRASH DATE1 CRASH TIME2 BOROUGH3 ZIP CODE4 LATITUDE5 LONGITUDE6 LOCATION7 ON STREET NAME8 CROSS STREET NAME9 OFF STREET NAME10 NUMBER OF PERSONS INJURED11 NUMBER OF PERSONS KILLED12 NUMBER OF PEDESTRIANS INJURED13 NUMBER OF PEDESTRIANS KILLED14 NUMBER OF CYCLIST INJURED15 NUMBER OF CYCLIST KILLED16 NUMBER OF MOTORIST INJURED17 NUMBER OF MOTORIST KILLED18 CONTRIBUTING FACTOR VEHICLE 119 CONTRIBUTING FACTOR VEHICLE 220 CONTRIBUTING FACTOR VEHICLE 321 CONTRIBUTING FACTOR VEHICLE 422 CONTRIBUTING FACTOR VEHICLE 523 COLLISION_ID24 VEHICLE TYPE CODE 125 VEHICLE TYPE CODE 226 VEHICLE TYPE CODE 327 VEHICLE TYPE CODE 428 VEHICLE TYPE CODE 5- perl -an -E
- Split up the column values into array ‘@F’
- -F,
- Specifies a comma field separator.
- $. == 1
- The Perl special variable ’$.’ contains the current line number.
- Display the first line only.
- say $i++ . “\t$_” for @F
- Prints a tab separated counter variable ‘$i’, and the corresponding column name, stored in the Perl default variable ’$_’.
Using Text::CSV
Getting records that include a zip-code and at least one injury or fatality.
3 ZIP CODE10 NUMBER OF PERSONS INJURED11 NUMBER OF PERSONS KILLED- The previous method for splitting a comma delimited file has limitations. It cannot handle fields with embedded commas.
- The Street Name fields often have embedded commas which will throw off our column numbering.
- We can use Text::CSV module, which has both functional and OO interfaces.
- For one-liners, it exports a handy csv function. From the Text::CSV documentation ‘my $aoa = csv (in => “test.csv”) or die Text::CSV_XS->error_diag;’
- This will convert the CSV file into an array of arrays
- Modifying this example to ‘csv( in => $ARGV[0], headers => qq/skip/ )’
- The @ARGV array contains any input arguments
- The first element $ARGV[0] will contain the input CSV file
- We don’t need the header row, so it’ll be skipped
Text::CSV will get the correct fields from the CSV file
perl -MText::CSV=csv -E '$aofa = csv( in => $ARGV[0], headers => qq/skip/ ); ( $_->[3] =~ /^\S+$/ ) && say qq/$_->[3],$_->[10],$_->[11]/ for @{$aofa}' all_motor_vehicle_collision_data.csv | sort -t, -k 1 -r > sorted_injured_killed_by_zip.csv- Input file ‘all_motor_vehicle_collision_data.csv’
- perl -MText::CSV=csv
- Run the perl command with ‘-M’ switch to load a Perl module, Text::CSV
- Text::CSV=csv
- Export the ‘csv’ function from the ‘Text::CSV’ module.
- ( $_->[3] =~ /^\S+$/ )
- Use a Regular expression to only process rows that have non-blank data in the ZIP CODE field.
- say qq/$->[3],$->[10],$_->[11]/ for @{$aofa}
- Loop through the Array of Arrays ‘$aofa’
- Print the contents of columns 3,10,11 followed by a line break.
- The output is piped ’|’ into the Linux sort command.
- Sorting on the first field, ZIP CODE and redirecting, ’>‘into a new file, ‘sorted_injured_killed_by_zip.csv’.
- See the ss64.com site for more details on the Linux sort command.
- The new file has about 1.36 Million lines.
Using wc
Get a word count of the smaller work file with ‘wc’.
bash > wc -l sorted_injured_killed_by_zip.csv1359291 sorted_injured_killed_by_zip.csvbash > head -n10 sorted_injured_killed_by_zip.csv | column -t -s, --table-columns=ZipCode,#Injured,#KilledZipCode #Injured #Killed11697 4 011697 3 011697 2 011697 2 011697 2 011697 1 011697 1 011697 1 011697 1 011697 1 0- wc -l
- Counts the number of lines in our new file
- head -n 10
- Prints out the first 10 lines of the file
- column -t -s, —table-columns=ZipCode,#Injured,#Killed
- column
- -t switch will tell ‘column’ to print in table format.
- -s switch specifies an input delimiter of ’,’.
- The output is tabbed.
List the 10 worst zip codes for injuries
perl -n -E '@a=split(q/,/,$_);$h{$a[0]} += $a[1]; END{say qq/$_,$h{$_}/ for keys %h}' sorted_injured_killed_by_zip.csv | sort -nr -t, -k 2 | head -n10 | column -t -s, --table-columns=ZipCode,#InjuredZipCode #Injured11207 1008911236 747211203 742611212 667611226 610311208 602711234 550511434 540311233 515911385 4440- @a=split(q/,/,$_);
- As there are no embedded commas in this file we use the Perl ‘split’ function to break up the 3 CSV fields in each row into array ‘@a’.
- $h{$a[0]} += $a[1];
- The first element of each row, ZIP CODE is used as a key for Hash’%h’.
- The value is the accumulated number of injuries for that ZIP CODE.
- $h{$a[0]} += $a[1]
- We accumulate the second element, $[1], which contains ‘NUMBER OF PERSONS INJURED’
- We can set a value for a Hash key without checking if it exists already.
- This is called Autovivification which is explained nicely by The Perl Maven.
- END{say qq/$,$h{$}/ for keys %h}
- The ‘END{}‘block runs after all the rows are processed.
- The keys(Zip Codes) are read and printed along with their corresponding values.
- We could have used Perl to sort the output by the keys, or values.
- I used the Linux sort.
- sort -nr -t, -k 2
- Will perform a numeric sort, descending on the # of people injured.
- head -n10
- Will get the first 10 records printed.
- column -t -s, —table-columns=ZipCode,#Injured
- The column command will produce a prettier output.
- -t for table format.
- -s to specify that the fields are comma separated
- —table-columns to add column header names.
- The column command will produce a prettier output.
Observation About the 10 Worst Zip Codes for Injuries
Zip code 11207, which encompasses East New York, Brooklyn, as well as a small portion of Southern Queens, has a lot of issues with traffic safety.
Display the 10 worst zip codes for traffic fatalities
bash > perl -n -E '@a=split(q/,/,$_);$h{$a[0]} += $a[2]; END{say qq/$_,$h{$_}/ for keys %h}' sorted_injured_killed_by_zip.csv | sort -nr -t, -k 2 | head -n10 | column -t -s, --table-columns=ZipCode,#KilledZipCode #Killed11236 4411207 3411234 2911434 2511354 2511229 2411208 2411206 2311233 2211235 21With a few minor adjustments, we got the worst zip codes for traffic collision fatalities
- $h{$a[0]} += $a[2]
- Accumulate the third element, $[2], which contains ‘NUMBER OF PERSONS KILLED’
Observation About the 10 Worst Zip Codes for Fatalities
- Zip code 11236, which includes Canarsie Brooklyn is the worst for traffic fatalities according to this data.
- Zip code 11207 is very bad for traffic fatalities, as well as being the worst for collision injuries
- These stats are not 100 percent accurate.
- Out of 1,972,886 collision records only 1,359,291 contained Zip codes.
- We have 613,595 records with no zip code, which were not included in the calculations.
Display the injured/killed stats grouped by NYC Borough
Similar to how we created the ‘sorted_injured_killed_by_zip.csv’, we can run the following command sequence to create a new file ‘sorted_injured_killed_by_borough.csv’
perl -MText::CSV=csv -E '$aofa = csv( in => $ARGV[0], headers => qq/skip/ ) ; ( $_->[2] =~ /^\S+/ ) && say qq/$_->[2],$_->[10],$_->[11]/ for @{$aofa}' all_motor_vehicle_collision_data.csv | sort -t, -k 3rn -k 2rn -k 1 >| sorted_injured_killed_by_borough.csv- 2 BOROUGH - The BOROUGH field is the third column, starting from 0
- ( $_->[2] =~ /^\S+/ )
- Only get rows which have non blank data in the BOROUGH field.
- sort -t, -k 3rn -k 2rn -k 1
- I added some more precise sorting, which is unnecessary except to satisfy my curiosity.
- -k 3rn
- Sort by column 3(starting @ 1), which is the fatality count field.
- This is sorted numerically in descending order.
- -k 2rn
- When equal, the injury count is also sorted numerically, descending.
- -k 1
- The Borough is sorted in ascending order as a tiebreaker.
Display the first 10 rows of ‘sorted_injured_killed_by_borough.csv’
bash > head -n10 sorted_injured_killed_by_borough.csv | column -t -s, --table-columns=Borough,#Injured,#KilledBorough #Injured #KilledMANHATTAN 12 8QUEENS 3 5QUEENS 15 4QUEENS 1 4STATEN ISLAND 6 3BROOKLYN 4 3BROOKLYN 3 3QUEENS 3 3BROOKLYN 1 3QUEENS 1 3Do a sanity check to see if we got all five Boroughs.
cut -d, -f 1 sorted_injured_killed_by_borough.csv | sort -uBRONXBROOKLYNMANHATTANQUEENSSTATEN ISLAND- cut -d, -f 1
- cut to split the comma delimited file records.
- -d,
- Specifies that the cut will comma delimited
- -f 1
- Get the first field from the
cut, which is the Borough Name.
- Get the first field from the
- sort -u
- Sorts and prints only the unique values to STDOUT
- We got all 5 New York City boroughs in this file.
Display collision injuries for each borough
bash > perl -n -E '@a=split(q/,/,$_);$h{$a[0]} += $a[1]; END{say qq/$_,$h{$_}/ for keys %h}' sorted_injured_killed_by_borough.csv | sort -nr -t, -k 2 | column -t -s,BROOKLYN 137042QUEENS 105045BRONX 62880MANHATTAN 61400STATEN ISLAND 15659Brooklyn emerges as the Borough with the most traffic injuries.
Display collision fatalities for each borough
bash > perl -n -E '@a=split(q/,/,$_);$h{$a[0]} += $a[2]; END{say qq/$_,$h{$_}/ for keys %h}' sorted_injured_killed_by_borough.csv | sort -nr -t, -k 2 | column -J -s, --table-columns Borough,#Killed{ "table": [ { "borough": "BROOKLYN", "#killed": "564" },{ "borough": "QUEENS", "#killed": "482" },{ "borough": "MANHATTAN", "#killed": "300" },{ "borough": "BRONX", "#killed": "241" },{ "borough": "STATEN ISLAND", "#killed": "88" } ]}- Similar to the Injury count by Borough, this counts all fatalities by borough and prints the output in JSON format.
- column -J -s, —table-columns Borough,#Killed
- Use the column command with the -J switch, for JSON, instead of -t for Table.
Check the Dataset Date Range
I forgot to mention what date range is involved with this dataset. We can check this with the cut command
cut -d, -f1 all_motor_vehicle_collision_data.csv | cut -d/ -f3 | sort -u201220132014201520162017201820192020202120222023- Get the date field, 0 CRASH DATE which is in ‘mm/dd/yyyy’ format.
- cut -d, -f all_motor_vehicle_collision_data.csv
- Get the first column/field of data for every row of this CSV file.
- -d, specifies that we are cutting on the comma delimiters.
- -f 1 specifies that we want the first column/field only
- This is the date in mm/dd/yyyy format.
- cut -d/ -f3
- Will cut the date using / as the delimiter.
- Grab the third field from this, which is the four digit year.
- sort -u
- The years are then sorted with duplicates removed.
The dataset started sometime in 2012 and continues until now, March 2023.
Display the 20 worst days for collisions in NYC
bash > cut -d, -f1 all_motor_vehicle_collision_data.csv | awk -F '/' '{print $3 "-" $1 "-" $2}' | sort | uniq -c | sort -k 1nr | head -n20 | column -t --table-columns=#Collisions,Date#Collisions Date1161 2014-01-211065 2018-11-15999 2017-12-15974 2017-05-19961 2015-01-18960 2014-02-03939 2015-03-06911 2017-05-18896 2017-01-07884 2018-03-02883 2017-12-14872 2016-09-30867 2013-11-26867 2018-11-09857 2017-04-28851 2013-03-08851 2016-10-21845 2017-06-22845 2018-06-29841 2018-12-14- Get a count for all collisions for each date on record
- Display the first 20 with the highest collision count
- cut
- Get the first column from the dataset.
- Pipe this date into the awk command.
- AWK is a very useful one-liner tool as well as being a full scripting language.
- awk -F ’/’ ‘{print $3 ”-” $1 ”-” $2}
- -F ’/’
- Split the date into separate fields using the / as a delimiter.
- $1 contains the month value, $2 contains the day of month and $3 contains the four digit year value.
- These will be printed in the format yyyy-mm-dd.
- -F ’/’
- Dates are then sorted and piped into the uniq command.
- uniq -c
- Will create a unique output.
- -c switch gets a count of all the occurrences for each value.
- The output is piped into another sort command, which sorts by the number of occurrences descending.
Observation About Collision Dates
There is no obvious explanation as to why some days have a lot more collisions than others. Weatherwise, January 21 2014 was a cold day, but otherwise uneventful. November 15 2018 had some snow, but not a horrific snowfall. The clocks went back on November 4, so that wouldn’t be a factor.
The Twenty Worst Times of Day for Collisions
bash > cut -d, -f2 all_motor_vehicle_collision_data.csv | sort | uniq -c | sort -k 1nr | head -n20 |column -t --table-columns=#Collisions,Time#Collisions Time27506 16:0026940 17:0026879 15:0024928 18:0024667 14:0022914 13:0020687 9:0020641 12:0020636 19:0019865 16:3019264 8:0019107 10:0019106 14:3019010 0:0018691 11:0018688 17:3016646 18:3016602 20:0016144 8:3016008 13:30Group the collisions by the hour of day to give a 60 minute time frame.
Display the Hour of Day Counts for Each Collision
bash > cut -d, -f2 all_motor_vehicle_collision_data.csv | cut -d : -f1 | sort | uniq -c | sort -k 1nr | head -n10 | column -t --table-columns=#Collisions,Hour#Collisions Hour143012 16139818 17132443 14123761 15122971 18114555 13108925 12108593 8105206 9102541 11- Similar to the previous example, except this time the cut command is used to split the time HH:MM, delimited by ’:’
- cut -d : -f 1
- -d
- The ‘cut’ delimiter is ’:’
- -f 1
- Grab the first field, ‘HH’ of the ‘HH:MM’.
- -d
- Use something like the printf command to append ‘:00’ to those hours.
As you would expect, most collisions happen during rush hour.
The Worst Years for Collisions
bash > cut -d, -f1 all_motor_vehicle_collision_data.csv | cut -d '/' -f3 | sort | uniq -c | sort -k 1nr | head -n10 | column -t --table-columns=#Collisions,Year#Collisions Year231564 2018231007 2017229831 2016217694 2015211486 2019206033 2014203734 2013112915 2020110546 2021103745 2022- We use the first column, 0 CRASH DATE again
- cut -d ’/’ -f3
- Extracts the ‘yyyy’from the ‘mm/dd/yyyy’
Observation About the Yearly Collision Trends
- There is some improvement seen in 2020, 2021 and 2022, if you can believe the data.
- By only printing out the worst 10 years, partial years 2012 and 2023 were excluded.
Create a CSV File of Sorted Injuries and Deaths
Work file, sorted_injured_killed_by_year.csv, with three columns, Year, Injured count and Fatality count.
We need the Text::CSV Perl module here due to those embedded commas in earlier fields. Below are the three fields needed.
0 CRASH DATE10 NUMBER OF PERSONS INJURED11 NUMBER OF PERSONS KILLEDThe Worst Years for Collision Injuries
perl -n -E '@a=split(q/,/,$_);$h{$a[0]} += $a[1]; END{say qq/$_, $h{$_}/ for sort {$h{$b} <=> $h{$a} } keys %h}' sorted_injured_killed_by_year.csv | head -n10 | column -t -s, --table-columns=Year,#InjuredYear #Injured2018 619412019 613892017 606562016 603172013 551242022 518832021 517802015 513582014 512232020 44615- This is similar to how we got the Zip Code and Borough data previously.
- This time the Perl sort is used instead of the Linux sort.
- END{say qq/$, $h{$}/ for sort {$h{$b} <=> $h{$a} } keys %h}
- for statement loops through the ‘%h’ hash keys(years).
- The corresponding hash values(Injured count), are sorted in descending order.
- sort {$h{$b} <=> $h{$a} }
- $a and $b are default Perl sort variables.
- Rearranged it to sort {$h{$a} <=> $h{$b} }, to sort the injury count in ascending order.
While the collision count may have gone down, there isn’t any real corresponding downward trend in injuries.
The Worst Years for Collision Fatalities
bash > perl -n -E '@a=split(q/,/,$_);$h{$a[0]} += $a[2]; END{say qq/$_, $h{$_}/ for sort {$h{$b} <=> $h{$a} } keys %h}' sorted_injured_killed_by_year.csv | head -n10 | column -t -s, --table-columns=Year,#KilledYear #Killed2013 2972021 2942022 2852020 2682014 2622017 2562016 2462019 2442015 2432018 231- Slightly modified version of the injury by year count.
As with the injuries count, there isn’t any real corresponding downward trend in traffic collision fatalities.
Conclusion
There’s lots more work that can be done to extract meaningful information from this dataset.
What’s clear to me, is that all the political rhetoric and money poured into Vision Zero has yielded little in terms of results.
Most of the solutions are obvious from a logical point of view, but not a political point of view. I walk and cycle these streets and know how dangerous it is to cross at the “designated” crosswalks when cars and trucks are turning in on top of you. Cycling in NYC is even worse.
Sugggested solutions
- Delayed green lights co cars cars don’t turn in on pedestrians at crosswalks.
- Much higher tax and registraton fees for giant SUV’s and pickup trucks. The don’t belong in the city.
- Better bike lanes, instead of meaningless lines painted on the road.
- Many bike lanes are used as convenient parking for NYPD and delivery vehicles.
- Basic enforcement of traffic laws, which isn’t being done now.
- Drivers ignore red lights, speed limits, noise restrictions etc. when they know they aren’t being enforced.
- Driving while texting or yapping on the phone is the norm, not the exception.
- Drastically improve public transit, especially in areas not served by the subway system.
Some Perl CLI Resources
Perldocs - perlrun
Peteris Krumins has some great e-books
Dave Cross - From one of his older posts on perl.com
More NYC Traffic Info
SODA Developers Guide
NYC Open Data
NYC Streetsblog
Hellgate NYC - Local NYC News
Liam Quigley - Local Reporter
More Liam Quigley - Twitter
These Stupid Trucks are Literally Killing Us – YouTube