Общий обзор

В США личные автомобили остаются наиболее распространенным видом транспорта. Большинство из нас зависит от приложений маршрутизации и карт, чтобы добраться до новых мест или даже просто для быстрой проверки условий движения. Одной из самых удобных функций этих приложений является просмотр мест автомобильных аварий в режиме реального времени и их влияние на время в пути.

Почти каждый день перед уходом с работы я проверяю пробки, чтобы понять, по какой автостраде мне следует добраться до дома. Эта функция отлично подходит для немедленных проверок, но может ли она работать для поездок, запланированных на ближайшее время? Во время экспериментов с картами Google я не мог видеть данные об авариях при использовании функции «Выезд в» более чем за 2 часа до времени (правда, это был ограниченный эксперимент).

Если бы приложения для маршрутизации могли надежно предсказать, как долго авария повлияет на трафик, они, вероятно, могли бы учитывать аварии для поездок, запланированных в ближайшем будущем. Можем ли мы мы предсказать, как долго та или иная авария будет влиять на движение транспорта?

Описание входных данных

Мы будем использовать набор данных US Accidents 2016–2021 Dataset.
Этот набор данных содержит следующую информацию о более чем 2,8 миллиона дорожно-транспортных происшествий в США:

  • Тяжесть аварии
  • Краткое описание
  • Подробные погодные условия (видимость, влажность, осадки, температура, охлаждение ветром, тип погоды)
  • Ближайшие дорожные объекты (неровности, перекрестки, перекрестки, благоустройство, перекрестки и т. д.)
  • Местоположение (широта/долгота, штат, округ, почтовый индекс)
  • Время (часовой пояс, время начала аварии, время окончания аварии, дневное/ночное время)

Стратегия решения проблемы

Приведенный выше набор данных содержит информацию, которую можно узнать, как только будет обнаружено или сообщено о происшествии, — погодные условия, время, местоположение и особенности близлежащих дорог. Используя эти данные, мы можем обучить модель для прогнозирования продолжительности, в течение которой авария влияет на трафик.

Для этого нам нужна целевая переменная для прогнозирования! К счастью, в нашем наборе данных столбцы Start_Time и End_Time — это именно то, что нам нужно — значение End_Time описывается как «когда влияние аварии на транспортном потоке прекращено».

Шаги для решения этой проблемы:

  1. Читайте в наших данных
  2. Создайте нашу целевую переменную, продолжительность аварии, из значений времени начала и окончания.
  3. Очистите и обработайте данные по мере необходимости
  4. Выполните исследовательский анализ, чтобы узнать больше о наших данных и доступных функциях, и решите, какие функции следует сохранить.
  5. Обучите модель, используя наш набор данных, для прогнозирования продолжительности дорожно-транспортного происшествия, оценивая производительность и настраивая гиперпараметры и функции по мере необходимости.
  6. Создайте приложение для ручного запроса несчастных случаев и отображения прогнозов продолжительности

Причина того, что приложение использует ручные запросы, заключается в том, что у меня нет доступа к API для получения информации о ближайших дорогах. Теоретически эта модель должна быть в состоянии вписаться в любое существующее приложение, которое может получать такую ​​информацию с учетом местоположения, и что-то вроде Aviation Weather API можно использовать для получения подробных погодных условий с учетом ближайшего аэропорта к месту аварии.

Обсуждение ожидаемого решения

Мы должны рассчитывать на модель, которая учитывает выбранные нами функции и предсказывает продолжительность аварии с разумной степенью точности.

Из-за характера этой проблемы мы можем ожидать высокую дисперсию. Ни одна из используемых функций не будет являться данными о фактическом происшествии, а скорее об окружающих его условиях. Это ограничение для прогнозирования во время аварии без ввода информации человеком. Погода, время, было ли это рядом с перекрестком или нет и т. д. говорят нам только об обстоятельствах. Эти функции могут косвенно информировать о вещах, которые могут повлиять на то, сколько времени потребуется, чтобы авария была устранена: время реагирования на чрезвычайные ситуации, большой поток автомобилей, доступность буксировки и т. д.

Имея в виду вышеизложенное, мы можем соответствующим образом установить наши ожидания и подумать о функциях в правильном кадрировании.

Метрики с обоснованием

Учитывая, что мы будем использовать регрессионную модель, использования оценки R2 модели в наборе данных для обучения и тестирования будет достаточно для определения производительности. Я не ожидаю очень высокого R2 в этих данных, учитывая возможность дисперсии, но, конечно, должна быть некоторая корреляция, чтобы наша модель была полезной.

Исследовательский анализ данных

Давайте сначала посмотрим на запись в нашем наборе данных:

Глядя на одну запись, можно легко получить приблизительное представление о том, какие данные представлены и в каком типе данных они хранятся. Например, мы можем видеть, что:

  • Серьезность — это категориальное
  • Записи времени являются строками и должны быть обработаны перед использованием.
  • Описание не обязательно содержит дополнительную информацию.
  • Число имеет нулевые значения
  • Дорожные объекты сохраняются как True/False

Обработка нулей

Давайте посмотрим, сколько нулевых значений у нас есть во всех столбцах:

#check where cols are null
df.isnull().sum().sort_values(ascending=False)

Number                   1743911
Precipitation(in)         549458
Wind_Chill(F)             469643
Wind_Speed(mph)           157944
Wind_Direction             73775
Humidity(%)                73092
Weather_Condition          70636
Visibility(mi)             70546
Temperature(F)             69274
Pressure(in)               59200
Weather_Timestamp          50736
Airport_Code                9549
Timezone                    3659
Nautical_Twilight           2867
Civil_Twilight              2867
Sunrise_Sunset              2867
Astronomical_Twilight       2867
Zipcode                     1319
City                         137
Street                         2

К счастью для нас, наибольшее нулевое значение — это Number, которое относится только к почтовому адресу — это не имеет значения для нашей модели. Однако в параметре Осадки также много пустых значений. Возможно, это нулевое значение там, где просто нет дождя/снега. Проверка значений столбца Weather_Condition может подтвердить это:

#check weather_condition values where precipitation is null
df[df["Precipitation(in)"].isnull()]["Weather_Condition"].value_counts().sort_values(ascending=False).head(10)

Clear               172786
Overcast             72357
Mostly Cloudy        72029
Partly Cloudy        51271
Fair                 45103
Scattered Clouds     44052
Cloudy                8959
Haze                  6675
Light Rain            3908
Fog                   3230

Выглядит неплохо! Мы можем принять значение 0,0 для осадков, где оно равно нулю. Даже для погодных условий «Небольшой дождь» это может объяснить, где дождь прекратился, но на улице все еще влажно.

Перемещаясь по другим нулевым значениям, мы видим, что охлаждение ветром, скорость и направление имеют нули. Охлаждение ветром, скорее всего, не будет использоваться в нашей модели, поскольку эта функция не всегда присутствует в более теплых штатах/сезонах. Направление ветра также не будет использоваться, так как его важность зависит от направления аварии (и интуитивно должно иметь незначительное влияние). Проверка погодных условий с нулевой скоростью ветра, аналогичных приведенным выше, возвращает:

Clear               37935
Overcast            13077
Fair                10204
Mostly Cloudy        9383
Partly Cloudy        6630
Scattered Clouds     5615
Light Rain           3157
Cloudy               2288
Haze                 1994
Light Snow           1514

Точно так же можно предположить, что нулевая скорость ветра - это спокойные условия или скорость ветра 0 миль в час.

Для нулевых значений Weather_Condition, к сожалению, нет умного способа вменить эти значения. Учитывая, что это небольшая выборка нашего набора данных (0,024), мы можем удалить эти записи. Проверяя наши нули после этого сброса и приведенных выше вменений, мы видим:

Number                   1697490
Wind_Chill(F)             406544
Wind_Direction             17505
Humidity(%)                15183
Temperature(F)             11412
Visibility(mi)              7268
Pressure(in)                3594
Nautical_Twilight           2256
Civil_Twilight              2256
Sunrise_Sunset              2256
Astronomical_Twilight       2256
City                         131
Street                         1

Намного лучше! Мы можем отбросить значения параметров Temperature, Visibility и Sunrise_Sunset, равные нулю, учитывая, как мало записей содержат их. Остальные столбцы в нашей модели использоваться не будут.

Анализ и создание функций

Нам нужно создать нашу функцию продолжительности аварии с учетом значений Start_Time и End_Time, что мы делаем с помощью функции ниже:

def time_diff(row):
    '''
    Inputs:
    row - dataframe row containing "End_Time" and "Start_Time" columns with strings containing datetime information
    
    Outputs:
    diff (float) - new value that is difference between start & end, expressed in minutes
    
    accounts for diff length/spots after decimal
    2016-02-08 00:37:08
    2021-11-11 19:36:30.000000000
    2021-10-23 15:50:00.000000
    '''
    format_t = "%Y-%m-%d %H:%M:%S"
    
    if len(row["End_Time"]) == 19:
        diff = (datetime.strptime(row["End_Time"], format_t) - datetime.strptime(row["Start_Time"], format_t))
    
    elif len(row["End_Time"]) == 26:
        diff = (datetime.strptime(row["End_Time"][:-7], format_t) - datetime.strptime(row["Start_Time"][:-7], format_t))
    
    elif len(row["End_Time"]) == 29:
        diff = (datetime.strptime(row["End_Time"][:-10], format_t) - datetime.strptime(row["Start_Time"][:-10], format_t))

    diff = diff.total_seconds() / 60
    
    return diff

Набор данных содержал три разных формата времени, различающихся количеством цифр после запятой — отсюда и логика в функции. Мы можем создать новый столбец с помощью этой функции, например:

#create new column from above function
df["duration(min)"] = df.apply(lambda row: time_diff(row), axis=1)

А теперь, исследуя продолжительность:

df["duration(min)"].describe()

count    2.774706e+06
mean     3.543284e+02
std      9.216739e+03
min      2.000000e+00
25%      7.375000e+01
50%      1.200000e+02
75%      2.237000e+02
max      1.682579e+06

df["duration(min)"].value_counts().head(20)

360.000000    343307
240.000000     54464
15.000000      36366
30.000000      34581
60.000000      27902
75.000000      25449
105.000000     24223
45.000000      18323
120.000000     16202
20.000000      12749
34.966667      11118
75.016667      10727
59.000000       7617
49.966667       6059
20.500000       5667
165.000000      5525
78.016667       5039
21.000000       4728
29.500000       4239
64.966667       4206

Это выглядит немного прикольно! Наш максимум слишком высок, чтобы быть разумным, и существует ненормальное количество записей продолжительностью 360,0 минут. Во-первых, мы установим продолжительность отсечки, после чего мы удаляем записи. Есть 19291 запись, продолжительность которых превышает 1 полный день (1440 минут), поэтому мы их исключим. Теперь смотрим на дистрибутив:

Мы наблюдаем всплеск на 360-й минуте. Это усиливает теорию о том, что это значение по умолчанию. Эти записи нельзя использовать в нашей модели, поэтому мы их отбросим, ​​что приведет к следующему распределению:

Теперь, когда у нас есть чистая зависимая переменная, мы можем сосредоточиться на наших функциях. Сохраняя значения Start_Time как дату и время в столбце Time_of_Incident , мы можем создавать функции Day и Hour:

#create column of day of week to get dummies for
df["Day"] = df.apply(lambda row: row.Time_of_Incident.day_name(), axis=1)

#same for hour of the day
df["Hour"] = df.apply(lambda row: row.Time_of_Incident.hour, axis=1)

Глядя на столбец Weather_Condition, можно увидеть 117 уникальных значений! Когда мы в конце концов закодируем это, это будет означать 117 функций. Глядя на значения, мы видим, что есть некоторое совпадение:

#check the most common 25 weather vals
df["Weather_Condition"].value_counts().sort_values(ascending=False).head(25)

Fair                       1095690
Cloudy                      344926
Mostly Cloudy               310864
Partly Cloudy               213764
Light Rain                  111852
Clear                        46329
Fog                          39659
Light Snow                   37945
Haze                         32617
Overcast                     29347
Rain                         27171
Fair / Windy                 14928
Scattered Clouds             10620
Heavy Rain                   10520
Thunder in the Vicinity       6901
Smoke                         6711
Cloudy / Windy                6705
T-Storm                       6512
Mostly Cloudy / Windy         6216
Thunder                       5986
Light Drizzle                 5969
Light Rain with Thunder       5247
Snow                          4521
Partly Cloudy / Windy         3810
Wintry Mix                    3794

Ясно, ясно, ясно / ветрено, все одинаковы, и аналогично со значениями облачности. Чтобы исправить это, я создал словарь с ключами в качестве общих слов и значениями в качестве новой сортировки погодных условий. Затем, используя функцию ниже, создал новый столбец только с 10 категориями:

#create new weather column that narrows possible values
def narrow_weather(row):
    #define keywords to categorize by
    keywords = {
        "rain" : "rain",
        "storm" : "storm",
        "drizzle" : "rain",
        "snow" : "snow",
        "sleet" : "snow",
        "fair" : "fair",
        "clear" : "fair",
        "windy" : "windy",
        "fog" : "fog",
        "haze" : "fog",
        "hail" : "hail",
        "thunder" : "storm",
        "overcast" : "cloudy",
        "cloud" : "cloudy",
        "wintry" : "snow",
        "drizzle" : "rain",
        "mist" : "fog",
        "smoke" : "smoke",
        "shower" : "rain",
        "precipitation" : "rain",
        "dust" : "dust",
        "ice" : "hail",
        "sand" : "dust",
        "squall" : "storm",
    }
    
    entry = np.nan
    for k, v in keywords.items():
        if k in row["Weather_Condition"].lower():
            entry = v

    return entry


df["weather"] = df.apply(lambda row: narrow_weather(row), axis=1)

Теперь исследуем значения скорости ветра:

df["Wind_Speed(mph)"].describe()

count    2.353551e+06
mean     7.177552e+00
std      5.489298e+00
min      0.000000e+00
25%      3.000000e+00
50%      7.000000e+00
75%      1.000000e+01
max      1.087000e+03

У нас снова очень высокий максимум! Глядя на самые высокие значения:

df["Wind_Speed(mph)"].sort_values(ascending=False).head(10)

1486460    1087.0
2225067     984.0
710258      812.0
2311806     518.0
2311795     518.0
1414359     243.0
2104007     232.0
1955910     211.0
1417386     186.0
1224096     186.0

df[df["Wind_Speed(mph)"] > 75].shape[0]
61

Кажется, что у нас всего несколько выбросов, и всего 61 запись со скоростью выше 75 миль в час. Мы будем использовать это как точку отсечки, а затем посмотрим на распределение:

Аналогичным образом, если посмотреть на температуру, у нас есть максимальное значение 196 градусов по Фаренгейту (выше, чем самая высокая зарегистрированная температура в мире!) и 8 значений выше 120 градусов по Фаренгейту, поэтому мы будем обрабатывать их путем отбрасывания.

Теперь, когда у нас есть чистые функции, мы можем начать анализировать отношения между нашими функциями и продолжительностью аварии! Используя метод DataFrame corr, мы можем получить первый взгляд:

#return highest correlation features
#abs used because we care about the importance, not the direction
df.corr(numeric_only=True)["duration(min)"].abs().sort_values(ascending=False)

duration(min)        1.000000
Hour                 0.106259
Distance(mi)         0.079753
Severity             0.059988
End_Lat              0.051920
Start_Lat            0.051916
Junction             0.051709
Station              0.044890
Traffic_Signal       0.043059
Pressure(in)         0.034518
Temperature(F)       0.031535
Number               0.028719
Humidity(%)          0.027520
Wind_Chill(F)        0.026542
Crossing             0.017282
Precipitation(in)    0.011894
End_Lng              0.011672
Start_Lng            0.011655
Amenity              0.009659
Visibility(mi)       0.005732
Wind_Speed(mph)      0.003740
No_Exit              0.003130
Stop                 0.002897
Give_Way             0.002889
Railway              0.002466
Bump                 0.002106
Traffic_Calming      0.001607
Roundabout           0.000793
Turning_Loop              NaN

К сожалению, некоторые из наших функций с высокой корреляцией — это вещи, которые мы не можем использовать в нашей модели:

  • Расстояние (мили) — это мера длины дороги, на которой затруднено движение транспорта. Возможно, мы сможем узнать об этом через несколько минут после аварии, но не в момент ее обнаружения.
  • Серьезность — это мера того, как долго трафик подвергается воздействию. Я удивлен, что это значение не выше! Но, естественно, его нельзя использовать.

Интересно, что наша широта имеет некоторую корреляцию с продолжительностью! Нанося широту/долготу на диаграмму рассеяния цветом, обозначающим продолжительность аварии, мы получаем:

Из этого мы можем понять, почему широта имеет значение — но, скорее всего, существует более сильная корреляция с состоянием, в котором происходит авария! Построив график средней продолжительности по состояниям, получим:

Это подтверждает, что штат действительно влияет на продолжительность аварии — в Западной Вирджинии и Орегоне продолжительность аварии гораздо выше, чем в большинстве других штатов. Мы можем упорядочить состояния по средней продолжительности в серии pandas, а затем создать новый столбец фрейма данных с индексом. Пересчет корреляции после должен показать нам влияние государства на продолжительность аварии:

#lets ordinally encode state column and re-run df.corr() to see how heavily state matters

#compute mean
mean_duration = df.groupby('State')["duration(min)"].mean()

#order above by mean duration
mean_duration = mean_duration.sort_values(ascending=True)
states_ordered = mean_duration.index

#new column in dataframe mapped from states_ordered
df["state_ordered"] = df["State"].map(lambda x: states_ordered.get_loc(x))


#return highest correlation features
df.corr(numeric_only=True)["duration(min)"].abs().sort_values(ascending=False)

duration(min)        1.000000
state_ordered        0.173288
Hour                 0.106259
Distance(mi)         0.079753
...

И посмотри на это! У нас есть самая высокая корреляционная характеристика.

Теперь мы можем легко построить график продолжительности на основе объекта дороги, создав новый фрейм данных для хранения каждого объекта дороги, средней продолжительности и наличия объекта дороги в виде логического значения:

#plot the road feature categoricals all on one plot
cat_df = pd.DataFrame(columns=["category","t_f","mean_duration"])
for val in all_categoricals:
    for bool_val in [True, False]:
        entry = [val, bool_val, df[df[val] == bool_val]["duration(min)"].mean()]
        cat_df.loc[len(cat_df)] = entry

sns.barplot(data=cat_df, x="category", y="mean_duration", hue="t_f").set_title("Road Feature Presence vs. Mean Duration")
plt.xticks(rotation=45)
plt.savefig("static/road_features.png",bbox_inches="tight")

Из этого мы можем видеть, что большинство дорожных особенностей оказывают некоторое влияние. Turning_Loop содержит только ложные записи и может быть исключен из нашего набора данных.

Ниже приведены гистограммы и диаграммы рассеяния некоторых других функций по сравнению с продолжительностью:

Важно учитывать, что для наших вышеперечисленных функций измеряемая продолжительность — это продолжительность воздействия на трафик. В ситуациях, когда дорожное движение изначально очень слабое (3 часа ночи, сельская местность, экстремальная погода и т. д.), многие аварии могут иметь незначительное влияние на движение или вообще не влиять на него. Это не повредит нашей модели или цели, но об этом следует помнить при рационализации взаимосвязей в данных.

Предварительная обработка данных

К счастью, большая часть наших данных уже хранится в подходящем формате для использования в нашей модели. Единственное, что нам нужно сделать, это сразу закодировать наши категориальные переменные. Для такого столбца, как Junction, который хранится как True/False, мы просто преобразуем его в 0 для False и 1 для True. Для такого столбца, как погода, который имеет 10 возможных значений, мы разделяем его на столбец для каждого значения и заполняем 1 там, где значение присутствует, и 0 в других местах. Если бы мы не сузили категории погоды раньше, это привело бы к 117 функциям в нашей модели для просто погоды! Мы можем легко достичь вышеуказанных целей, используя pandas.get_dummies:

#columns we want to keep for our model, split by continuous and categorical
con_features = ["Temperature(F)", "Visibility(mi)", "Wind_Speed(mph)",
"Precipitation(in)"]
cat_features = ["state_ordered","weather", "Junction", "Stop", "Traffic_Signal",
"Sunrise_Sunset", "Day", "Hour", "Station", "Crossing"]

val_to_predict = ["duration(min)"]

#initialize X with continuous features, then merge with one hot encoded categoricals
X = df[con_features]
X = X.join(pd.get_dummies(df[cat_features]))

#initialize Y with the column we are trying to predict
Y = df[val_to_predict]
#use numpy ravel to set y to correct dimensions
Y = np.array(Y).ravel()

#split into test and train sets
x_train, x_test, y_train, y_test = train_test_split(X, Y, train_size=0.8)

Метод get_dummies, используемый для столбцов Hour и state_ordered, не должен ничего делать — это потому, что мы изначально создали эти столбцы как целочисленное представление. Для state_ordered это было сделано намеренно во время EDA по той же причине, что и для столбца погода. Мы можем использовать штат США в нашей модели, не добавляя таким образом около 50 функций!

Поскольку прогнозируемая переменная не должна зависеть от времени, случайное разбиение на обучающие и тестовые наборы допустимо.

Моделирование

Теперь, когда наши данные подготовлены, мы можем начать моделирование. Я начинаю с GradientBoostingRegressor от sklearn без каких-либо изменений параметров, чтобы получить базовую производительность:

#instantiate model
reg = GradientBoostingRegressor(random_state=42)

#fit to training data
reg.fit(x_train, y_train)

#predict on test data
y_pred = reg.predict(x_test)

#score on test data
reg.score(x_test, y_test)
0.12270359181619717

#score on train data
reg.score(x_train, y_train)
0.12274743774121522

Не выглядит слишком большим вне летучей мыши! По крайней мере, похоже, что мы не подгоняем наши тренировочные данные — показатель R2 по сравнению с тренировочным и тестовым набором почти идентичен, но слишком низок, чтобы можно было быть уверенным в нашей точности.

Хотя мы могли начать настройку гиперпараметров здесь, я думаю, что базовая производительность не является многообещающей, и было бы полезнее снова подумать о нашей проблеме. Предполагаемый вариант использования этой модели — будущая маршрутизация. Переменная, которую мы пытаемся предсказать, продолжительность аварии, непрерывна и, возможно, слишком детализирована. Вместо того, чтобы предсказывать точную продолжительность, мы могли бы попытаться предсказать диапазон и переключиться на проблему классификации!

Имея в виду вышеизложенное, мы можем создать новый столбец фрейма данных, который является порядковым категориальным значением на основе продолжительности аварии:

#create new ETA column based off of duration(min) val
#15mins or less = 0
#15-30mins = 1
#30mins-1hr = 2
#1-3hr = 3
#3hr-6hr = 4
#rest of day = 5
df['ETA'] = 0
df.loc[df['duration(min)'] <= 15, 'ETA'] = 0 
df.loc[(df['duration(min)'] <= 30) & (df["duration(min)"] > 15), 'ETA'] = 1
df.loc[(df['duration(min)'] <= 60) & (df["duration(min)"] > 30), 'ETA'] = 2
df.loc[(df['duration(min)'] <= 180) & (df["duration(min)"] > 60), 'ETA'] = 3
df.loc[(df['duration(min)'] <= 360) & (df["duration(min)"] > 180), 'ETA'] = 4
df.loc[df['duration(min)'] > 360, 'ETA'] = 5

и заново создайте наши обучающие/тестовые наборы (тот же код, что и выше, но в функции, которую нужно набирать меньше):

#functionize encoding
def prep_data(df):
    con_vals = ["Temperature(F)", "Visibility(mi)", "Wind_Speed(mph)",
    "Precipitation(in)"]
    cat_vals = ["state_ordered","weather", "Junction", "Stop", "Traffic_Signal",
    "Sunrise_Sunset", "Day", "Hour", "Station", "Crossing"]
    x = df[con_vals]
    y = df['ETA']
    x = x.join(pd.get_dummies(df[cat_vals]))
    y = np.array(y).ravel()
    x_train, x_test, y_train, y_test = train_test_split(x, y, train_size=0.8)
    
    return x_train, x_test, y_train, y_test

x_train, x_test, y_train, y_test = prep_data(df)

Теперь мы можем создать экземпляр модели классификации — я решил использовать классификатор XGBoost с целью multi:softprob. Это потому, что я хотел быть уверенным в каждой классификации, а также в самой классификации:

#set XGBoost params, objects
#xgboost DMatrix objects can be easily set with the split data
train = xgb.DMatrix(data=x_train, label=y_train)
test = xgb.DMatrix(data=x_test, label=y_test)
params = {
    "eta" : 0.05, #learning rate param
    "objective" : "multi:softprob", #provides confidence of each category
    "num_class" : 6, #number of categories in predicted variable
    "max_depth" : 15, #depth of trees
    }
epochs = 50


#train model
model = xgb.train(params, train, epochs)

#predict on test set
y_pred = model.predict(test)

#get accuracy score. np.argmax is used because 
#the output of predict contains array of confidence for each category
accuracy_score(y_test, np.argmax(y_pred,axis=1))
0.6103670199881391

Вышеупомянутое выполнение заняло около 8 минут, и мы получили оценку точности 0,61 на тестовом наборе! Важно отметить, что это показатель, отличный от показателя R2 в регрессионной модели. Это можно интерпретировать как 61% шанс правильно классифицировать аварию.

Настройка гиперпараметров

Теперь, когда у нас есть приличная базовая производительность, мы можем использовать GridSearchCV из научного набора для настройки гиперпараметров и повышения производительности:

estimator = xgb.XGBClassifier(objective="multi:softprob",
                             seed=42)
params = {
    "max_depth" : range(2, 20, 4),
    "n_estimators" : range(50, 250, 50),
    "learning_rate" : [0.2, 0.1, 0.05]
}

#create grid_search object with above parameters
grid_search = GridSearchCV(
    estimator=estimator,
    param_grid=params,
    n_jobs = -1, #run on all cores
    cv = 2, #cross validation
    verbose=3
)

#fit to train data
grid_search.fit(x_train, y_train)

Обычно я бы использовал более высокое значение перекрестной проверки. Однако этот набор данных большой и менее подвержен переоснащению обучающих данных, поэтому я могу обойтись меньшим значением и сэкономить массу времени вычислений, поскольку приведенное выше значение в 120 раз больше!

Я также перебираю значение max_depth — это изменяет глубину дерева. Чем выше глубина, тем сложнее становится наша модель и тем точнее она становится, но больше подвержена переоснащению. То же самое можно сказать и о n_estimators — это изменяет количество деревьев, а слишком большое их количество может привести к переоснащению.

Значение learning_rate влияет на то, насколько быстро модель обновляет веса в деревьях. Более низкое значение может привести к более точной модели, но увеличивает время вычислений и требует больше времени для сходимости, а также рискует получить переоснащение.

Полученные результаты

После запуска оптимальная модель имела точность 0,63 на тренировочном наборе и имела параметры:

  • скорость обучения: 0,1
  • макс_глубина: 18
  • n_оценщиков: 200

Это небольшое увеличение производительности по сравнению с нашим базовым уровнем, но все же увеличение! Точность, полнота и f1-баллы для каждой категории в тестовом наборе данных можно увидеть ниже:

              precision    recall  f1-score   support

           0       0.82      0.30      0.44     12082
           1       0.91      0.53      0.67     61317
           2       0.79      0.27      0.41     52695
           3       0.68      0.97      0.80    264520
           4       0.81      0.35      0.49     57547
           5       0.83      0.56      0.67     30723

    accuracy                           0.72    478884
   macro avg       0.81      0.50      0.58    478884
weighted avg       0.75      0.72      0.69    478884

Наши значения точности все приличные, за исключением категории 3. Мы видим, что отзыв здесь очень высок. Глядя на поддержку, мы видим, что наш набор данных несбалансирован — есть гораздо больше образцов, которые попадают в третью категорию по сравнению с остальными. Наша модель вознаграждается за возвращение категории 3 из-за вероятности того, что случайная точка данных находится в категории 3. Глядя на гистограмму распределения полных наборов данных при категоризации, мы видим:

Для сравнения с точностью нашего прогноза общее количество точек данных в нашем наборе данных составляет ~ 55%.

Важность функций показана ниже:

#get feature importances, store in pandas series
pd.Series(data=grid_search.best_estimator_.feature_importances_, index=x_train.columns).sort_values(ascending=False)

state_ordered           0.087935
Sunrise_Sunset_Day      0.082273
Traffic_Signal          0.048076
Wind_Speed(mph)         0.039504
weather_smoke           0.037424
Stop                    0.037335
Junction                0.036533
Day_Sunday              0.036297
Day_Saturday            0.034697
Hour                    0.033719
Station                 0.033649
weather_windy           0.030267
weather_dust            0.030218
weather_storm           0.030016
weather_rain            0.029880
Temperature(F)          0.029564
weather_hail            0.029323
weather_fair            0.029111
Crossing                0.027843
weather_fog             0.026316
Day_Friday              0.026270
Visibility(mi)          0.026105
weather_snow            0.026076
Day_Monday              0.026025
Day_Tuesday             0.025664
Day_Wednesday           0.025463
weather_cloudy          0.025074
Day_Thursday            0.024987
Precipitation(in)       0.024355

Мы видим, что функции с наибольшим весом в 2 раза — это состояние и дневное/ночное время. Далее мы видим, что важны близлежащие светофоры, скорость ветра, задымленная погода (экстремальная погода?) и некоторые другие вещи.

Мы видим, что день события, выпадающий на выходные, важен. Это может означать, что мы можем еще больше уменьшить нашу размерность, изменив признак День на категориальный между Выходной/День недели.

Результаты каждой итерации поиска по сетке можно увидеть ниже:

Из этого мы видим, что наша лучшая производительность достигается при максимальных значениях, разрешенных для n_estimators и max_depth. Учитывая это, мы, вероятно, могли бы повысить производительность, повторно запустив gridsearch с более высокими значениями этих параметров.

Мы смогли добиться более желаемых результатов, используя классификационную модель. Это компромисс, но, учитывая характер проблемы — мы используем косвенные, косвенные данные, которые не являются информацией о самом происшествии — это расширяет цели и позволяет нашей модели быть более полезной, чем она была бы в противном случае.

Я создал приложение flask для запроса модели и оценки результатов с различными параметрами — скриншот показан ниже. Если вы хотите сделать то же самое, вы можете сделать это, загрузив этот репозиторий и следуя показанным инструкциям!

Заключение

Чтобы определить, как долго авария может повлиять на трафик, мы создали классификационную модель для оценки продолжительности затронутого трафика, используя только данные, доступные во время инцидента, которые можно интегрировать в существующие приложения для маршрутизации или планирования.

Благодаря этому процессу я получил представление о большем количестве соображений и новых методах. В обстоятельствах, когда высокая точность не достигается с помощью регрессии, переключение на классификацию числовых диапазонов может быть жизнеспособным обходным путем, в зависимости от степени детализации, необходимой для решения.

Я также узнал об порядковом кодировании категориальных переменных, особенно в отношении штатов США. Ранжируя состояния (категории) в соответствии со средней продолжительностью аварии (целевая переменная), я смог значительно сократить пространство функций, сохраняя при этом функцию.

Эти два урока я буду иметь в виду для будущих проектов, и мне было очень приятно увидеть работу!

Улучшение

Дальнейшее улучшение может быть сделано несколькими способами:

  • Дальнейшее уменьшение размерности некоторых функций, таких как Hour или state_ordered, за счет встраивания.
  • Экспериментирование с диапазонами, используемыми для наших категориальных значений продолжительности аварии
  • Балансировка категориального распределения нашего набора данных перед обучением
  • Приоритет точности перед отзывом в функции потерь
  • Использование feature_selection sklearn для изучения дополнительных функций
  • Через GridSearch проверено больше параметров (больше max_depth, выше n_estimators)
  • Добавление дополнительных функций, которые могут быть доступны вскоре после сообщения об аварии (например, описание)

Что касается последнего пункта выше, мы можем взглянуть на столбец Описание нашего набора данных и выполнить небольшой анализ, чтобы увидеть, может ли это улучшить нашу модель. Создание нескольких столбцов для общих ключевых слов и построение графика средней продолжительности присутствия каждого из них возвращает график ниже:

Это показывает сильную разницу между средней продолжительностью аварии и наличием этих слов в описании! Однако я не знаю, насколько быстро можно получить эти описания. Просить пользователей приложения описать аварию небезопасно и, вероятно, приведет к большей дисперсии этих функций. Однако, если есть способ быстро получить эти данные, возможно, его стоит включить в эту модель!

Подтверждение

Набор данных о дорожно-транспортных происшествиях в США:

Весь используемый код можно найти на GitHub, а также полный список используемых библиотек в readme.

Не стесняйтесь обращаться с вопросами, потенциальными улучшениями или сотрудничеством, и спасибо за чтение! :)