CoderCastrov logo
CoderCastrov
Парсер

Мини-проект: парсинг веб-сайта и анализ данных COVID-19

Мини-проект: парсинг веб-сайта и анализ данных COVID-19
просмотров
8 мин чтение
#Парсер

Краткий проект по парсингу веб-сайта и созданию модели

С учетом второй, более серьезной волны COVID-19, охватившей мою страну (Грецию), меня заинтересовало, как будет развиваться пандемия. Национальная организация общественного здравоохранения (EODY) ежедневно публикует количество подтвержденных случаев COVID-19. Это число резко увеличилось за последний месяц, а также количество пациентов с COVID-19 на аппаратах ИВЛ и количество смертей.

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

Проблема заключается в том, что публикуется только фактическое объявление Национальной организации общественного здравоохранения (EODY) на их веб-странице. Нет никаких API, файлов Excel или чего-либо еще, что можно было бы легко обработать (Университет Джонса Хопкинса собирает данные COVID-19 из многих стран/регионов, включая Грецию, но имеет только количество подтвержденных случаев, количество смертей и количество подтвержденных случаев выздоровления). Мое решение заключалось в парсинге данных с их сайта. Обратите внимание, что в некоторых случаях парсинг нарушает правила страницы. В данном случае мы парсим общедоступные данные из общественной/государственной организации и стараемся минимизировать количество запросов к URL.



Парсинг данных о COVID-19 с веб-страницы

В этом разделе я описываю, как я выполнил парсинг ежедневного количества пациентов с COVID-19 на аппаратах ИВЛ и количество смертей с веб-страницы Национальной общественной здравоохранения Греции (EODY). Jupyter-ноутбук, который я использовал в Google Colab, можно найти на моем GitHub. Там даже есть ссылка для открытия ноутбука в Colab, чтобы вы могли поэкспериментировать. Обратите внимание, что код содержит несколько команд print. Вывод информации был ценным при разработке кода.

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

Мы собираемся использовать:

  • requests для получения HTML-кода каждой страницы
  • Beautifulsoup для обработки HTML-кода
  • re для работы с регулярными выражениями

и, конечно же, pandas.

Мы заинтересованы в объявлениях с заголовком "Ημερήσια έκθεση επιτήρησης COVID-19", что означает "Ежедневный отчет о мониторинге COVID-19". Если мы ищем эту фразу в HTML-коде, мы обнаружим, что ссылки на ежедневные объявления помечены в HTML-коде атрибутом aria-label. Таким образом, мы создаем функцию (get_announcement), которая принимает url и строку поиска в качестве входных данных и возвращает список всех ссылок на странице url, содержащих строку поиска в своей метке.


def get_announcement(url="",search=""):
  """Функция, которая принимает url в качестве входных данных и возвращает список всех ссылок, содержащих строку поиска""" 
  results=[]
  page = requests.get(url)
  soup = BeautifulSoup(page.content, 'html.parser')
  for label in soup.find_all("a",attrs={"aria-label":True}):
    text=label.contents[0]
    if text.find(search)>=0:
      # следующие две команды print можно удалить, они используются для отладки
      print(text)   
      print(label['href'])
      results.append(label['href'])
  return(results)

Ссылка на следующую страницу выглядит как <a class="next page-numbers" href=...>, где href содержит URL следующей страницы. Мы можем использовать это для создания цикла while (приведенного ниже), который:

  • начинается с URL,
  • использует функцию get_announcement для поиска URL ежедневных отчетов
  • проверяет, есть ли ссылка на следующую страницу, в котором случае он повторяет цикл с URL следующей страницы.
announcements=[] # URL ежедневных пресс-релизов
url="[https://eody.gov.gr/category/anakoinoseis/](https://eody.gov.gr/category/anakoinoseis/)"
announcements=get_announcement(url=url,search="Ημερήσια έκθεση επιτήρησης COVID-19")
page = requests.get(url)
soup = BeautifulSoup(page.content, 'html.parser')
next_page=soup.find("a",{'class': 'next page-numbers'})
while next_page:
  url=next_page["href"]
  print("Checking page "+url)
  new_announcements=get_announcement(url=url,search="Ημερήσια έκθεση επιτήρησης COVID-19")
  if len(new_announcements)>0:
    announcements=announcements+new_announcements
  page = requests.get(url)
  soup = BeautifulSoup(page.content, 'html.parser')
  next_page=soup.find("a",{'class': 'next page-numbers'})

Теперь у нас есть все URL ежедневных объявлений/отчетов о COVID-19 в списке с именем announcements. Далее нам нужно извлечь количество пациентов с COVID-19 на аппаратах ИВЛ и количество смертей от COVID-19 из каждого URL. Мы можем изучить страницу. В качестве примера давайте используем https://eody.gov.gr/20201114_briefing_covid19/

Заголовок страницы и время публикации содержатся в мета-тегах og:title и article:published_time соответственно. Ниже приведен код, который загружает первый URL (отчет от 14 ноября 2020 года на момент написания) и выводит эти два элемента.

print(f'Используем {announcements[0]} в качестве примера')
page = requests.get(announcements[0])
soup = BeautifulSoup(page.content, 'html.parser')
title=soup.find('meta',{'property': 'og:title'})
print(title['content'])
timestamp=soup.find('meta',{'property': 'article:published_time'})
print(timestamp['content'])

Количество пациентов на аппаратах ИВЛ находится между фразой

  • "σχετιζόμενα με ήδη γνωστό κρούσμα" (это часть предложения, которое информирует о количестве подтвержденных случаев COVID, связанных с другими подтвержденными случаями) и
  • "συμπολίτες μας νοσηλεύονται διασωληνωμένοι." (это означает "наши соотечественники госпитализированы на аппаратах ИВЛ")

Мы извлекаем число между этими двумя фразами.

Затем мы делаем то же самое для количества смертей от COVID-19, которое находится между

  • "Τέλος," (означает "Наконец,") и
  • "ακόμα καταγεγραμμέ" (означает "зарегистрировано больше")

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

Ниже приведен код, используемый для извлечения чисел.

text=soup.prettify()
ventilator_start=text.index("σχετιζόμενα με ήδη γνωστό κρούσμα")
ventilator_end=text.index("συμπολίτες μας νοσηλεύονται διασωληνωμένοι.")
ventilator= re.findall(r'\b\d+\b', text[ventilator_start:ventilator_end])
ventilator=int(ventilator[0])
# выводим текст с количеством пациентов на аппаратах ИВЛ
print(re.sub('<[^<]+?>', '', text[ventilator_start:ventilator_end+43])) 
print(ventilator)
deaths_start=text.index("Τέλος,")
deaths_end=text.index("ακόμα καταγεγραμμέ")
deaths= re.findall(r'\b\d+\b', text[deaths_start:deaths_end])
deaths=int(deaths[0])
# выводим текст с количеством смертей от COVID-19
print(re.sub('<[^<]+?>', '', text[deaths_start:deaths_end+31]))
print(deaths)

Наконец, мы создаем DataFrame с именем announcements_content, который для каждого пресс-релиза будет содержать URL, заголовок страницы, время публикации, количество пациентов на аппаратах ИВЛ и количество смертей от COVID-19.

announcements_content=pd.DataFrame(announcements,columns=['url'])
announcements_content['title']=""
announcements_content['timestamp']=""
announcements_content['ventilator']=""
announcements_content['deaths']=""
announcements_content.head()

Цикл for ниже получает содержимое каждого объявления и заполняет DataFrame. Обратите внимание, что есть часть try-catch при извлечении количества смертей от COVID-19. Это потому, что были случаи, когда не было объявлено ни одной смерти от COVID-19.

Код выводит каждый URL, а также часть текста о госпитализированных пациентах на аппаратах ИВЛ и смертях от COVID-19 с соответствующими извлеченными числами. Это может показаться ненужным шумом, но, как я уже сказал ранее, это было ценно во время начальной разработки по причине отладки.

for idx,row in announcements_content.iterrows():
  url=row['url']
  print(f'Индекс {idx}')
  print(url)
  page = requests.get(url)
  soup = BeautifulSoup(page.content, 'html.parser')
  title=soup.find('meta',{'property': 'og:title'})
  print(title['content'])
  timestamp=soup.find('meta',{'property': 'article:published_time'})
  text=soup.prettify()
  ventilator_start=text.index("σχετιζόμενα με ήδη γνωστό κρούσμα")
  ventilator_end=text.index("συμπολίτες μας νοσηλεύονται διασωληνωμένοι.")
  ventilator= re.findall(r'\b\d+\b', text[ventilator_start:ventilator_end])
  ventilator=int(ventilator[0])
  print("----VENTILATOR-----")
  print(re.sub('<[^<]+?>', '', text[ventilator_start:ventilator_end+43]))
  print(ventilator)
  try:
   deaths_start=text.index("Τέλος,")
   deaths_end=text.index("ακόμα καταγεγραμμέ")
   deaths= re.findall(r'\b\d+\b', text[deaths_start:deaths_end])
   deaths=int(deaths[0])
   print("----DEATHS----")
   print(re.sub('<[^<]+?>', '', text[deaths_start:deaths_end+31]))
   print(deaths)
  except:
   print("DEATHS - not found")
   deaths=0
  announcements_content['title'][idx]=title['content']
  announcements_content['timestamp'][idx]=timestamp['content']
  announcements_content['ventilator'][idx]=ventilator
  announcements_content['deaths'][idx]=deaths

Если ноутбук выполняется в Colab, нам нужно загрузить созданный нами набор данных. Ниже приведен код, который сохраняет набор данных в формате xlsx и использует библиотеку google.colab для его загрузки.

from pandas import ExcelWriter
writer = ExcelWriter('deaths_ventilator_20201114.xlsx')
announcements_content.to_excel(writer,'all')
writer.save()
from google.colab import files
files.download('deaths_ventilator_20201114.xlsx')

Анализ данных

Для создания предиктивной модели мы будем использовать R (прошу прощения у настоящих пользователей Python). Код находится в том же репозитории git.

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

library(openxlsx)# Загрузка данных
data = read.xlsx(xlsxFile = "deaths_ventilator_20201114.xlsx", sheet = 1, skipEmptyRows = FALSE)
# Сортировка данных от самых старых до самых новых
data=data[order(data$timestamp),]

Говорят, что между заражением COVID-19, госпитализацией и смертью есть задержка. Поэтому мы собираемся использовать количество пациентов на искусственной вентиляции легких за 6-20 дней для прогнозирования количества смертей. Для этого мы создаем столбцы ventilator_6, ventilator_7,..., ventilator_20 с количеством пациентов на искусственной вентиляции легких за 6, 7,..., 20 дней назад. Это делается с помощью следующего кода. Обратите внимание, что мы программно создаем новые столбцы. (Если быть честным, в первой версии я создал каждый столбец вручную).

rows=nrow(data)
for (i in 6:20){
  name=paste0("respirator_",i)
  data[[name]]=0
  data[[name]][i:rows]=data$respirator[1:(rows-i+1)]
}

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

# Оставляем строки с полными столбцами и
# Удаляем столбцы index, url, title, timestamp и respirator 
model_data=data[21:nrow(data),]
model_data=model_data[,-c(1:5)]# Разделение на обучающий и тестовый наборы данных в соотношении 80% -20%
set.seed(2019)
idx=sample(1:nrow(model_data), 0.8*nrow(model_data))
train=model_data[idx,]
test=model_data[-idx,]

Код для подгонки линейной модели:

# Линейная модель
lm_model=lm(deaths~.,train)
summary(lm_model)
lm_predictions=predict(lm_model,test)
mean((test$deaths-lm_predictions)^2)

Мы можем видеть из вывода summary, что самая важная переменная - это количество пациентов на искусственной вентиляции легких за 6 дней, за которыми следуют пациенты за 12, 13 и 7 дней. (На самом деле, чтобы полностью проверить это, мы должны использовать также количество пациентов на искусственной вентиляции легких на текущую дату и за 1, 2, 3, 4 и 5 дней до нее. Это остается в качестве упражнения). Это обосновывает наше решение использовать задержку при использовании количества пациентов. Линейная модель имеет среднеквадратическую ошибку 23.71183.

Код для подгонки регрессионного дерева:

# Регрессионное дерево
library(caret)
library(rpart)
tree_model <- rpart(deaths~.,train)
par(xpd = NA) # иначе на некоторых устройствах текст обрезается
plot(tree_model)
text(tree_model)
tree_predictions=predict(tree_model,test)
mean((test$deaths-tree_predictions)^2)

и дает среднеквадратическую ошибку 12.21273.

Наконец, код для случайного леса:

library(randomForest)
rf_model = randomForest(deaths ~ ., data=train, ntree=100, importance=TRUE)
rf_predictions=predict(rf_model,test)
mean((test$deaths-rf_predictions)^2)
varImpPlot(rf_model)

и создает модель со среднеквадратической ошибкой 7.809795, при этом подтверждается, что самая важная переменная - это количество пациентов на искусственной вентиляции легких за 6 дней.

Важность переменных в модели случайного леса

Мы можем использовать тот факт, что мы используем количество пациентов на искусственной вентиляции легких старше 5 дней и что у нас есть доступные соответствующие числа до текущей даты. Таким образом, мы можем сделать прогноз количества смертей от COVID-19 на сегодня и на следующие дни. Код приведен ниже.

# Будущие прогнозы
day5=rev(t(data$respirator[103:117]))
day4=rev(t(data$respirator[102:116]))
day3=rev(t(data$respirator[101:115]))
day2=rev(t(data$respirator[100:114]))
day1=rev(t(data$respirator[99:113]))
input=rbind(day1,day2,day3,day4,day5)
input=as.data.frame(input)
colnames(input)=colnames(train[,-1])
future_predictions=predict(rf_model,input)
future_predictions

и возвращает примерное количество смертей 41.

В то время как я писал это (14-15 ноября 2020 года), были объявлены сегодняшние цифры, и количество смертей от COVID-19 составило 38. Следует подчеркнуть, что этот анализ проводится исключительно в образовательных целях и следует использовать с осторожностью.

Обновление: Я написал более подробную статью о своих попытках моделирования смертности от COVID-19. Вы также можете прочитать ее на Medium.