Анализ трендов по найму на 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 этой серии, в ней рассказывается о результатах этого анализа. Обязательно ознакомьтесь!
