Машинное обучение обычно ассоциируется с датасетами, метриками и бесконечными экспериментами в ноутбуках. Но в какой-то момент для нас ML переста�� быть абстрактной технологией - и стал маршрутом. Причём буквально. Эта история о том, как модели, гипотезы и пара неочевидных решений привели нас не только к рабочему результату, но и на самый настоящий остров Парамушир (северные Курилы).
Попытка улететь на вертолёте из Петропавловска-Камчатского в Северо-Курильск сразу превратилась в маленькое приключение: местные хором пугали погодой и перспективами — «можете не прилететь, а если вдруг прилетите, то потом не улетите». Камчатка в этом смысле честна и беспощадна, особенно к планам. Но желание полетать на вертолёте здесь перевешивало все разумные доводы — уж слишком манила сама идея увидеть полуостров с воздуха, да ещё и недорого (у вертолетных экскурсии на Камчатке кусачие цены). На вопрос "как не играть в рулетку с погодой" - ответ очевиден, ML.
P.S. хоть Хабр и не про туризм, но вдруг кому интересно, на Парамушире есть и свои достопримечательности, такие как действующий вулкан Эбеко и аэродромное плато (бывшие японские ангары) поэтому не только ради дешевого полета на вертолете мы оказались в Северо-Курильске.
Подготовка датасета: таргет
Историю вылета мы парсили с сайта: https://en.spotterlead.net. Правда, парсили мы историю и готовили датасет до того, как там установили Cloudflare. Мы, к сожалению, обходить пока его не научились - если кто научился, поделитесь, пожалуйста, в комментариях!
Если кратко, как интерпретировалась история и размечалось целевое событие - если рейс за дату вылетел, то записываем дату, время и Target = 1 (вылетел), если вылет был отменен, то тоже все записываем, однако Target = 0. Если рейс за дату был перенесен на время, то в датасете появляется 2 строчки: с Target = 0 - рейс с первоначальным временем (который перенесли и вылет не состоялся), с Target = 1 - рейс в новое время (на https://en.spotterlead.net в случае переноса вылета второй строчкой указывается успешный рейс).
Привожу код, но боюсь, он уже бесполезен для парсинга. Пишем на Python, основные используемые библиотеки: urllib.request, bs4 и, конечно, pandas
from urllib.request import Request, urlopen
from urllib.request import urlparse
import requests
from urllib.error import HTTPError, URLError
import ast
from bs4 import BeautifulSoup
import pandas as pd
from datetime import date
from tqdm import tqdm
#определяем функцию парсинга информации со страницы типа https://en.spotterlead.net/flights/{flight}/{date}, а потом в цикле пройдем все рейсы и искомые даты
def info(flight, date):
url = f'https://en.spotterlead.net/flights/{flight}/{date}'
url_request = Request(url, headers={"user-agent": 'Mozilla/5.0'})
webpage = urlopen(url_request).read()
bs = BeautifulSoup(webpage, 'html.parser')
try:
jsonchik_ = json.loads(bs.find('script', type="application/ld+json", \
text = re.compile('(departureTime)|(arrivalTime)')).string)
key_ = 'arrivalTime' if 'arrivalTime' in jsonchik_.keys() else 'departureTime'
index_ = jsonchik_[key_].index('T')
date_flight_ = datetime.strptime(jsonchik_[key_][:index_], "%Y-%m-%d").date()
time_flight_ = jsonchik_[key_][index_+1:index_+6]
if_changed_time = 0 if len(bs.find_all('span', {'class': 'changed-time'})) == 0 else 1
time_changed_ = '00:00' if not if_changed_time else \
bs.find('span', {'class': 'changed-time'}).string
if_changed_date = 0 if len(bs.find_all('span', {'class': 'changed-date'})) == 0 else 1
date_answer_ = date_flight_ if not if_changed_date else \
datetime.strptime(bs.find('span', {'class': 'changed-date'}).string, "%d.%m.%y").date()
if_canceled = 0 if len(bs.find_all(text = re.compile('canceled'))) == 0 else 1
return date_answer_, if_changed_time, if_changed_date, key_[:-4], \
date_flight_, time_flight_, time_changed_, if_canceled
except:
print('web site error', end = '\r')
return 'error', 'error'
#начинем в цикле проходить все рейсы и искомые даты
flights_ = ['TGA2654', 'TGA2653', 'PTK2605', 'PTK2606']#, 'HZ2654', 'HZ2653']
numdays = 210
base = date.today()
date_list = [base - timedelta(days=x) for x in range(numdays)]
df = pd.DataFrame(columns=['Flight', 'Direction', 'Date', 'Time', 'Target'])
for flight_ in flights_:
print(f'{flight_} :')
for i in tqdm(date_list):
tmp_ = info(flight_, i.strftime('%Y-%m-%d'))
if i == tmp_[0] and tmp_[1] and not tmp_[-1]:
if tmp_[2]:
df = df.append({'Flight': flight_, 'Direction': tmp_[3], \
'Date': tmp_[0], 'Time': tmp_[6],\
'Target': 0}, ignore_index=True)
df = df.append({'Flight': flight_, 'Direction': tmp_[3], \
'Date': tmp_[4], 'Time': tmp_[5],\
'Target': 1}, ignore_index=True)
else:
df = df.append({'Flight': flight_, 'Direction': tmp_[3], \
'Date': tmp_[0], 'Time': tmp_[5],\
'Target': 1}, ignore_index=True)
elif i == tmp_[0] and not tmp_[1] and not tmp_[-1]:
df = df.append({'Flight': flight_, 'Direction': tmp_[3], \
'Date': tmp_[0], 'Time': tmp_[5],\
'Target': 0}, ignore_index=True)
elif tmp_[0] == 'error':
break
suffix_ = date.today().strftime('%Y-%m-%d')
df.to_csv(f'flights_{suffix_}.csv', sep = ';', index=False)
Пример итогового датафрейма:
Подготовка датасета: фичестор
Историю погоды мы брали с https://api.openweathermap.org - у ребят достаточно несложный api. Ответ на get-запрос легко обварачивается в json, а дальше как видит художник, так данные вы и вольны интерпретировать!
ВНИМАНИЕ! Сервис у них платный, однако, 1000 обращений к api в сутки беслпатно. После нескольких проб и отладки кода, нам для датасета хватило двух суточных лимитов - у нас около 700 строк в датасете: 700 запросов на погоду в Петропавловск-Камчатском + 700 запросов на историю погоды в Северо-Курильске.
url = "https://api.openweathermap.org/data/3.0/onecall/timemachine?"
df_target = pd.DataFrame()
payload = {"appid": '*****', 'units': 'metric'}
#severo-kurilsk
payload['lat'] = 44.01
payload['lon'] = 145.51
for i in tqdm(df.iloc[:][['Date', 'time_kuril']].drop_duplicates().values):
dt = (datetime.strptime(i[0], "%Y-%m-%d")+timedelta(hours=i[1])+timedelta(hours=-11)).timestamp()
payload['dt'] = int(dt)
response = requests.get(url, params=payload)
response_json = response.json()
df_tmp = pd.DataFrame(response_json['data'])
df_tmp = df_tmp.loc[:, df_tmp.columns.difference(['dt', 'sunrise', 'sunset', 'weather'])].add_suffix('_sev_kur')
df_tmp['Date'] = [i[0]]
df_tmp['time_kuril'] = [i[1]]
if len(df_target) == 0:
df_target = df_tmp.copy(deep =True)
else:
df_target = pd.concat([df_target, df_tmp])
weather_sev_kur = df_target.copy(deep =True)
weather_sev_kur.to_csv('weather_sev_kur.csv', sep = ';', index=False)
Про джойн датафреймов рассказывать не буду :) Все вполне очевидно! :)
Итог выглядит примерно вот так:
Построение модели
Делили датасет через sklearn.model_selection.train_test_split. Не использовали разделения по дате, потому что глубина истории не превышала года, а зимой TargetRate ниже чем летом - хотелось избежать искажения выборки что при построении, что при валидации модели.
Выбрали в качестве целевого класса моделей - случайный лес. Гипотеза была выбрана эмпирически: человек принимает решение в жизни аналогично дереву решения, пилотов вертолетов много (ну точно не 1 человек, надеюсь) => много деревьев решения = случайный лес. Забегая вперед, расскажу, что гипотеза оправдалась! Ну по крайней мере, по нашим оценкам.
P.S. пробовали и логистическую регрессию, результат похуже. Другие классы не пробовали, т.к. для бустинга выборка маловата.
Рассказывать пайплайн построения, кросс-валидации и проверки на тесте не буду. Считаю, что все равно никто читать не будет эту часть. Построение модели - это искусство, единого пайплайна нету. Грести под одну гребенку всех инженеров ML не имею желания.
Отобрали следующие фичи: направление ветра в Петропавлоск-Камчатском (далее ПК), температура в ПК, скорость ветра в Северо-Курильске (далее СК), направление ветра в СК, давление в СК, влажность в ПК, влажность в СК, облачность в ПК, облачность в СК.
Валидация
Просто приведу картинку! По оси икс скор, по оси игрек либо количество вылетов (гистограмма), либо TargetRate (ломаные линии).
Мы поставили модельку на раписание, и исходя из прогноза покупали билеты туда и обратно. Все удалось! По словам местных, работает хорошо (не отлично) - когда прогноз вылета маловероятен, то рейсы отменяют или переносят.
Если вдруг захотите сэкономить на вертолетной экскурсии и посетить не только Камчатку, залетайте к нам: https://движение-на-восток.рф/прогноз-вылета
Надеюсь, было интересно и полезно!