Анализ трендов по найму на Hacker News — Часть II
Table Of Content
В Части I этой статьи я показал свой анализ данных, которые я извлек и пометил из темы "Ask HN: Кто нанимает?" за последние 7 лет. Вторая часть - это пошаговое руководство по тому, как я получал, очищал и анализировал эти данные с помощью различных фрагментов кода на Python.
Моя первая задача заключалась в том, чтобы собрать данные с Hacker News. Все программисты на Python знают, что для парсинга HTML-страниц нет ничего лучше, чем Beautiful Soup. Хотя я обнаружил публичный API Hacker News, я понял, что он не дает мне никакой дополнительной информации, которую мой скрэпер Beautiful Soup уже не предоставляет, поэтому я остановился на обычном HTML и Beautiful Soup. Вот код для получения и кэширования страницы HN, а затем извлечения информации о названии вакансии (записанной в виде списка предложений, разделенных дефисом или вертикальной чертой) и описаний из текста каждого поста:
import requests
from bs4 import BeautifulSoup
def fetch_hn_page():
url = 'https://news.ycombinator.com/item?id=15601729'
response = requests.get(url)
return response.text
def parse_hn_page(html):
soup = BeautifulSoup(html, 'html.parser')
job_titles = []
descriptions = []
for item in soup.find_all('tr', class_='athing'):
title = item.find('a', class_='storylink').text
job_titles.append(title)
description = item.find_next_sibling('tr').find('span', class_='comment').text
descriptions.append(description)
return job_titles, descriptions
html = fetch_hn_page()
job_titles, descriptions = parse_hn_page(html)
После того, как у меня были чистые данные в виде списка, следующая задача заключалась в извлечении информации из них в табличной форме. К счастью, для данных, которые нас в основном интересуют, особенно после 2014 года, комментарии имеют первую строку, разделенную на столбцы, вот так:
Replicated | QA Automation Engineer | $100k - $130k + equity | Los Angeles | [https://www.replicated.com](https://www.replicated.com/)
Однако есть небольшая проблема: порядок этих столбцов не гарантируется. Название компании почти всегда является первым элементом, но остальные элементы могут быть в любом порядке. Вот еще одна строка заголовка, чтобы объяснить, что я имею в виду:
Thinknum | New York | Multiple Positions | On-site - Full-time | $90k-$140k + equity
Как видите, местоположение появляется в четвертом столбце для первой публикации, но во втором столбце для второй. Кроме того, порядок элементов не гарантируется, и также нет минимального количества столбцов. Некоторые компании указывают местоположение, другие нет. Некоторые указывают информацию о компенсации, а другие пропускают ее.
Чтобы обойти проблему выше, я решил создать стратегию классификации каждого из типов столбцов. Вот, например, простое совпадение для определения, содержит ли столбец URL:
import re
def classify_url(token):
url = re.findall('https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+', token)
return url[0] if url else None
Если совпадение найдено, мы просто сохраняем найденный URL в отдельном столбце. Аналогично, код для классификации некоторых других столбцов выглядит следующим образом:
def classify_location(token):
locations = re.findall('([A-Z][a-z]+(?:\s[A-Z][a-z]+)*)', token)
return locations[0] if locations else None
def classify_salary(token):
salary = re.findall('\$[\d,]+', token)
return salary[0] if salary else None
Теперь у нас начинает появляться таблицоподобная структура из нашего кода, однако ей не хватает двух самых интересных столбцов: местоположения и технологий.
Для технологий я составил список всех технологий, которые я вижу в постах, с максимальным количеством их вариаций, и просто сравнивал их с заголовком поста. Вот список технологий, которые я использовал:
technologies = ['Python', 'Java', 'JavaScript', 'C++', 'Ruby', 'PHP', 'Go', 'Swift', 'Rust', 'Scala', 'Kotlin', 'TypeScript', 'HTML', 'CSS', 'React', 'Angular', 'Vue', 'Node.js', 'Django', 'Flask', 'Spring', 'Rails', 'Laravel', 'ASP.NET', 'Express', 'jQuery', 'Bootstrap', 'MySQL', 'PostgreSQL', 'MongoDB', 'Redis', 'Elasticsearch', 'AWS', 'Azure', 'Google Cloud', 'Docker', 'Kubernetes', 'Git', 'Jenkins', 'Travis CI', 'CircleCI', 'Heroku']
Если хотя бы одна из технологий совпадает, ее первое вхождение используется в качестве постоянного имени и добавляется в столбец с технологиями. Для удобства анализа данных было полезно хранить данные в денормализованном формате, поэтому если вакансия упоминает несколько технологий, для нее создается несколько строк.
Возможно, самая интересная проблема, которую мне пришлось решить, заключалась в правильной классификации местоположения вакансии. Поскольку в этих темах может появиться практически бесконечное количество местоположений и их вариаций, я решил, что мне нужен хороший пакет для обработки естественного языка (NLP) для классификации местоположения. Меня соблазнил NLTK, который является де-факто библиотекой для обработки естественного языка, используемой программистами на Python. Однако я наткнулся на spaCy, который выглядел немного более современным, чем NLTK. Моя первая задача заключалась в том, чтобы узнать, содержит ли предложение или его часть местоположение, и spaCy's named entity recognizer (ner) предоставил удобное решение:
import spacy
def classify_location(sentence):
nlp = spacy.load('en_core_web_lg')
doc = nlp(sentence)
locations = []
for ent in doc.ents:
if ent.label_ == 'GPE':
locations.append(ent.text)
return locations[0] if locations else None
Для данного предложения или его части код выше способен определить часть, представляющую местоположение. Я заметил, что иногда, когда предложение или токен содержат несколько местоположений, ner spaCy не всегда успешно определяет границы этого местоположения. Например, он может пометить 'San Francisco & Pleasanton, CA' как одно местоположение, когда на самом деле это указывает на два разных местоположения. К счастью, вы можете обучить spaCy лучше распознавать такие сущности. Вот как это делается:
import spacy
from spacy.tokens import Doc
def train_location_recognizer():
nlp = spacy.load('en_core_web_lg')
ner = nlp.get_pipe('ner')
TRAIN_DATA = [
("San Francisco & Pleasanton, CA", {"entities": [(0, 13, "GPE"), (18, 28, "GPE")]}),
# Другие обучающие данные
]
for _, annotations in TRAIN_DATA:
for ent in annotations.get("entities"):
ner.add_label(ent[2])
other_pipes = [pipe for pipe in nlp.pipe_names if pipe != 'ner']
with nlp.disable_pipes(*other_pipes):
optimizer = nlp.begin_training()
for itn in range(10):
losses = {}
random.shuffle(TRAIN_DATA)
for text, annotations in TRAIN_DATA:
doc = nlp.make_doc(text)
example = Doc(doc.vocab, words=doc.words)
for ent in annotations.get("entities"):
example.ents += (ent,)
nlp.update([example], losses=losses, sgd=optimizer)
print(losses)
train_location_recognizer()
После этого обучения spaCy начинает довольно точно распознавать местоположения в тексте. Однако на этом этапе решается только половина нашей проблемы.
spaCy может распознавать, что строки 'Cambridge, UK', 'Cambridge, MA' и 'Cambridge, USA' все содержат местоположение с названием Cambridge, но он не может определить, что два из этих трех местоположений на самом деле идентичны, а третье находится на другом континенте. Это означало, что помимо распознавателя именованных сущностей мне также понадобился геокодер, который позволил бы мне последовательно геокодировать 'Cambridge, MA' и 'Cambridge, USA' в один и тот же город. Быстрый поиск в Google привел меня к действительно потрясающей библиотеке geopy, которая включает практически все популярные сервисы геокодирования, включая Google Maps, Bing Maps и Mapquest и т. д. Тем, который я выбрал, был бесплатный сервис с названием Photon, который основан на OpenStreetMap. Причиной выбора Photon было то, что a) он бесплатный, и b) вы можете буквально загрузить его 55 ГБ базу данных поиска на свой компьютер. Это позволяет выполнять мгновенные поиски, которые мне нужно было делать для десятков тысяч строк с местоположениями, определенными spaCy.
Это был последний кусочек головоломки, который мне нужно было решить:
from geopy.geocoders import Photon
def geocode_location(search_string):
geocoder = Photon(domain="localhost:2322", scheme='http')
location = geocoder.geocode(search_string, language='en')
props = location.raw['properties']
city = props['city']
state = props['state']
country = props['country']
return city, state, country
search_string = 'Cambridge, MA'
city, state, country = geocode_location(search_string)
Код выше правильно сопоставляет строку, содержащую информацию о Cambridge, Massachusetts, с одним и тем же городом в Новой Англии.
Вот и все! Все необходимые элементы для правильного получения, разбора, токенизации, распознавания, классификации и геокодирования данных из темы "Ask HN: Кто нанимает?". Если вы еще не прочитали Часть I этой серии, в ней рассказывается о результатах этого анализа. Обязательно ознакомьтесь!