High-Injury Network Analysis

A common roadway safety analysis pattern involves the development of a High-Injury Network (HIN). Typically, this involves computing a profile of crash risk along roadway corridors based on historical crash data. Because crashes are point events and the roadway data we are evaluating is linear, it is necessary to find an effective way to smooth that data to generalize crash patterns. linref supports several ways to perform this smoothing, and this notebook walks through two of them: event profile distribution and event neighbor distribution. The examples below use the provided sample roadways and crashes datasets to create a simple HIN using each methodology.

Note: These methods build on the concepts of the sliding window analysis method described in the Highway Safety Manual, Chapter 4. They utilize the advanced computational abilities of the linref library and the linear referencing model to produce results that are more precise, flexible, and scalable. A more traditional sliding window analysis can also be conducted using similar features within linref if preferred.

Load Data

First, let’s set our default LRS and load our datasets:

[1]:
import linref as lr

# Set the default LRS
lr.set_default_lrs(
    key_col=['route'],
    chain_col='chain',
    loc_col='loc',
    beg_col='beg',
    end_col='end',
    geom_col='geometry',
    geom_m_col='geometry_m',
    closed='left_mod'
)

# Load built-in datasets
roadways = lr.datasets.load('roadways')
crashes  = lr.datasets.load('crashes')

print(f'Miles of roadways: {roadways.lr.event_lengths.sum():.2f}')
print(f'Number of crashes: {len(crashes)}')
Miles of roadways: 32.30
Number of crashes: 20

Resegment Roadways

Next, we need to resegment our roadways using a standard segment length. This will define the unit of our analysis. Typically, we will use a segment length between 0.1 and 1.0 miles, depending on the context and goals of the analysis as well as the density of crashes in the study area. Segment lengths may be longer in less dense rural areas or shorter in more dense urban areas. For this example, let’s use a segment length of 0.5 miles. To avoid particularly short segments at the ends of corridors, we will use the fill='balance' parameter.

[2]:
# First, dissolve the roadways to create continuous segments
dissolved = roadways.lr.dissolve()

# Resegment the roadways to a standard length
resegmented = dissolved.lr.resegment(length=0.5, fill='balance')

print(f'Number of original segments: {len(roadways)}')
print(f'Number of dissolved segments: {len(dissolved)}')
print(f'Number of resegmented segments: {len(resegmented)}')
Number of original segments: 10
Number of dissolved segments: 3
Number of resegmented segments: 65

Crash Data Smoothing

With our roadways resegmented into analysis units, we are ready to smooth the point-based crash data onto the linear segments. Because crashes are point events and our segments are linear, we need a method to generalize each crash’s influence along the corridor. linref offers multiple interchangeable approaches for this, and the two subsections below walk through common options using the same roadways and crashes datasets. You can choose whichever approach best fits your analysis.

Profile Distribution

In this approach, we distribute each crash’s value along a shaped window rather than to whole neighboring segments. We first extend each crash point into a linear window centered on the crash location using the extend method. This converts each point event into a linear event spanning a fixed distance on either side of the crash. We then apply an event profile that describes how the crash’s value is distributed along that window: a triangular profile places the greatest weight at the crash location and tapers linearly to zero at the window edges, concentrating influence near the actual crash while still smoothing it across neighboring segments. Other profiles are available (such as uniform, parabolic, and trapezoidal) to produce different smoothing shapes.

Here, we extend each crash by 1.0 mile in each direction to produce a 2.0-mile window, then relate those windows to the resegmented roadways. The sum aggregator computes the proportion of each crash window’s profiled value that overlaps each segment. Setting conserve=True normalizes each crash so that its total distributed value sums to exactly 1.0, ensuring the resulting crash_score conserves the true crash count across all segments. This is how edge cases are addressed, where crashes occur at the end of a roadway.

Note that extending point events into windows de-synchronizes the original point geometries from the new linear measures. By default, linref’s geometry_sync behavior drops the now-stale geometry from the result, which is appropriate here since the overlay relies only on the linear measures rather than the crash geometry. This behavior can be adjusted per call via the geometry_sync parameter or globally via linref.options.default_geometry_sync.

[6]:
# Extend each crash point into a 2.0-mile window centered on the crash
crash_windows = crashes.lr.extend(1.0)

# Relate the windows to the resegmented roadways and sum a triangular profile
relation = resegmented.lr.relate(crash_windows)
resegmented['crash_score'] = relation.sum(profile='triangular', conserve=True)

# For comparison, also compute simple crash counts
resegmented['crash_counts'] = resegmented.lr.relate(crashes).count()

print(f'Total crash score: {resegmented["crash_score"].sum():.2f}')
print(f'Total crash counts: {resegmented["crash_counts"].sum():.2f}')
print(resegmented.lr.get_group('I-5').iloc[5:14][['route', 'beg', 'end', 'crash_score', 'crash_counts']])
Total crash score: 20.00
Total crash counts: 20.00
   route  beg  end  crash_score  crash_counts
5    I-5  2.5  3.0      0.00000             0
6    I-5  3.0  3.5      0.00045             0
7    I-5  3.5  4.0      0.14000             0
8    I-5  4.0  4.5      0.38910             1
9    I-5  4.5  5.0      0.36980             0
10   I-5  5.0  5.5      0.30545             0
11   I-5  5.5  6.0      0.42540             1
12   I-5  6.0  6.5      0.30500             0
13   I-5  6.5  7.0      0.06480             0

Neighbor Distribution

As an alternative to the profile distribution approach, the distribute aggregator smooths crashes by spreading each crash directly across neighboring segments. We relate the crashes data to the resegmented roadways layer and apply the distribute aggregator. This method identifies the segment that the crash falls on and then distributes the value of the crash between that segment and a number of adjacent segments, effectively smoothing the data along each corridor. Exactly how this distribution is done can be modified using a variety of parameters that effect the relative proportion of a crash that is assigned to the initial segment and adjacent segments, decreasing with greater distance.

Here, we will use standard parameters with decay_func='linear' and decay_size=2, distributing the value of each crash between the segment that it occurred on and two segments on either side. For example, assume that our roadway is segmented at 0.5 mile intervals and a crash occurs on a given route at milepost 1.2. Using these parameters, the distribute aggregator will apply 0.333 of that crash to the [1.0, 1.5) segment, 0.222 to each of the [0.5, 1.0) and [1.5, 2.0) segments, and 0.111 to each of the [0.0, 0.5) and [2.0, 2.5) segments. Note that edge cases will have slightly higher overall weights due to the nature of this approach. For example, a crash occurring at milepost 0.6 will be distributed with a value of 0.25 on the [0.0, 0.5) segment, 0.375 on the [0.5, 1.0) segment, 0.25 on the [1.0, 1.5) segment, and 0.125 on the [1.5, 2.0) segment.

[4]:
# Create a relation between crashes and resegmented roadways
relation = resegmented.lr.relate(crashes)

# Perform crash distribution
resegmented['crash_score'] = relation.distribute(
    decay_func='linear',
    decay_size=2
)

# For comparison, also compute simple crash counts
resegmented['crash_counts'] = relation.count()

print(f'Total crash score: {resegmented["crash_score"].sum():.2f}')
print(f'Total crash counts: {resegmented["crash_counts"].sum():.2f}')
print(resegmented.lr.get_group('I-5').iloc[5:14][['route', 'beg', 'end', 'crash_score', 'crash_counts']])
Total crash score: 20.00
Total crash counts: 20.00
   route  beg  end  crash_score  crash_counts
5    I-5  2.5  3.0     0.000000             0
6    I-5  3.0  3.5     0.111111             0
7    I-5  3.5  4.0     0.222222             0
8    I-5  4.0  4.5     0.333333             1
9    I-5  4.5  5.0     0.333333             0
10   I-5  5.0  5.5     0.333333             0
11   I-5  5.5  6.0     0.333333             1
12   I-5  6.0  6.5     0.222222             0
13   I-5  6.5  7.0     0.111111             0

Consider the results of these analyses: though the crash_score column contains decimal values, in both approaches it still sums to a number equal to the count of total crashes being analyzed. Because of this, we can still summarize the results in terms of actual crash counts instead of a unitless index which may be beneficial for some applications.