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

Анализ трендов по найму на Hacker News — Часть II

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