Недавно я опубликовал пост, рассказывающий, как можно просто начать использовать наше API. Мне самому захотелось поиграться с данными, которые можно из него получить, и я решил написать приложение, рекомендующее актуальные вакансии на основе информации из резюме. В конце статьи будет ссылка на результат, где каждый сможет получить список рекомендаций по своему резюме.

На hh.ru можно искать вакансии, подходящие к резюме, но они используют нашу общую систему поиска, а мне захотелось сделать более персонализированную подборку.

Со стороны пользователя это будет обычный веб-сайт, на котором можно авторизоваться с помощью API HeadHunter, получить список личных резюме и просмотреть рекомендованные вакансии. Со стороны разработчика еще добавляются модуль, который будет выкачивать через API актуальные вакансии, и модуль, который будет строить на основе собранных вакансий рекомендации.

Сборщик вакансий


Алгоритм простой. Раз в N минут получаем список вакансий, опубликованных за последние N минут. Тут надо учесть, что за один запрос возвращается не более 500 вакансий, поэтому запросы делаются с разбивкой на страницы. Путь к вакансиям в API выглядит как-то так: /vacancies?per_page={}&date_from={}&date_to={}&page={}. В списках содержатся не все данные вакансий, а значит, придется каждую вакансию запросить отдельно. Еще следует учесть, что иногда за короткий промежуток времени публикуется большое количество вакансий, так что программа не успевает за отведенные N минут скачать все опубликованные. Значит, качаем вакансии в несколько потоков. Кому интересно, вот ссылка на код качальщика на Github-е. Сразу извиняюсь за качество кода — я не «питонщик». Данный скрипт периодически запускается по крону.

Отдельно хочу отметить, что сначала я пытался сохранять вакансии в MySQL. Но на моем сервере очень ограниченное количество ресурсов и нет возможности при построении рекомендаций держать всё в памяти, а выгружать все каждый раз из MySQL небыстро. Поэтому приходилось получать данные частями, на рекомендации уходило около часа. Тогда я решил поискать in-memory хранилище для вакансий. Выбор пал на Redis из-за наличия поддержки в Python, простоты установки и использования, наличия поддержки структур данных и сохранения состояния при рестарте.

Векторизация вакансий


Логично, что все данные вакансии хранить нет смысла. Поэтому при скачивании преобразуем вакансию в вектор параметров. Под параметрами подразумеваются слова, которые входят в разные вакансии. Каждому документу (вакансии) соответствует вектор одинаковой длины. Каждому элементу вектора соответствует определенное слово и задается значение — вес, который это слово имеет в текущем документе.

Если взять все слова, которые содержатся во всех вакансиях, то получится слишком длинный список. Важно найти ограниченный список таких слов, которые бы максимально характеризовали все документы. Из этих слов составим словарь.

Вакансии принадлежат определенным профобластям. Я предположил, что имеет смысл разбить вакансии по профобластям и извлечь самые важные слова для каждой профобласти. Для создания словаря я скачал около 113 000 вакансий. Одно и то же слово может иметь несколько словоформ. Было бы хорошо представить их как одно слово. Для этого применяется стемминг — нахождение основы слова. В «Питоне» есть хорошая реализация (PyStemmer), поддерживающая русский язык.
import Stemmer
stemmer = Stemmer.Stemmer('russian')
print stemmer.stemWord('хабром')
хабр
print stemmer.stemWord('хабру')
хабр

После стемминга я разбил все документы по группам, соответствующим профобластям. Если вакансии соответствует несколько профобластей, то, конечно, она будет в нескольких группах. Каждый документ внутри каждой группы преобразуем в вектор. Для этого нам поможет sklearn-овкий CountVectorizer. Ему на вход подается список документов. Он достает все слова из списка и считает, сколько раз какое слово встречается в конкретном документе. Это и будет вектор.
from sklearn.feature_extraction.text import CountVectorizer

corpus = ['aa bb cc', 'bb bb dd']
vectorizer = CountVectorizer(min_df=1)
X = vectorizer.fit_transform(corpus)
print X.toarray()
[[1 1 1 0]
 [0 2 0 1]]

Некоторые слова слишком часто встречаются во многих документах и являются незначительными. А некоторые — наоборот, встречаются не так часто, но хорошо описывают документ или несколько документов. Для компенсации считается TF-IDF для каждой группы векторов. При подсчете вес некоторого слова пропорционален количеству употребления этого слова в документе и обратно пропорционален частоте употребления слова в других документах коллекции. Для подсчета этой меры в sklearn есть TfidfTransformer. Он принимает на вход векторы, полученные из CountVectorizer-а, и возвращает пересчитанные векторы такой же размерности.
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.feature_extraction.text import TfidfTransformer

corpus = ['aa bb cc', 'bb bb dd']
vectorizer = CountVectorizer(min_df=1)
X = vectorizer.fit_transform(corpus)
transformer = TfidfTransformer()
X_tfidf = transformer.fit_transform(X)
print X_tfidf.toarray()
[[ 0.6316672   0.44943642  0.6316672   0.        ]
 [ 0.          0.81818021  0.          0.57496187]]

После того как посчитали TF-IDF для документов каждой группы, считаем в каждой группе среднее арифметическое для каждого параметра в векторах. Находим определенное количество параметров с максимальным значением и сохраняем слова, соответствующие этим значениям. Это и будут самые важные слова для конкретной специализации. Я сохранял для каждой специализации по 350 слов, чтобы в итоге получить словарь примерно из 10 000 слов. Вектором именно такой длины будет характеризоваться каждая вакансия. Вот полный код для создания словаря. Каждый документ, описывающий вакансию, был составлен из слов заголовка, основной информации и ключевых навыков.

Теперь у нас есть словарь, используя который можно превратить каждую вакансию при сохранении в вектор, соответствующий только этим словам. Для этого создается новый CountVectorizer, параметризованный словарем.

Хранение актуальных данных


Для каждой вакансии, данные которой мы хотим сохранить, производим стемминг данных, прогоняем через CountVectorizer и TfidfTransformer и сохраняем в Redis. При сохранении в Redis векторов вакансий я столкнулся с проблемой недостатка места в оперативной памяти. Я для рекомендаций использую вакансии за последние 5 дней, а это около 130 000. Для каждой из них приходится хранить вектор размером 10000 элементов. У меня этот объем занял 7,5GB. Столько оперативки нет на моем сервере. Тогда я подумал о том, что, раз данные я сохраняю в json и они получилиcь очень разреженными, они наверняка отлично сжимаются. Поэтому перед сохранением я их энкодю в zlib. В итоге те же данные стали занимать примерно 250MB.

Отдельно хочу отметить пару приятных функций Redis-а:
  1. Сохраняемым записям можно выставлять TTL, после которого они автоматически удаляются и не приходится заботиться о высвобождении места.
  2. Одному ключу можно сопоставить HashMap. Так, вместе с вектором я для вакансии сохраняю её регион и зп.

В «Питоне» сохранение в Redis выглядит так:
import redis
import json
r = redis.StrictRedis(host='localhost', port=6379, db=0)
timeout = 5*24*60*60
data = {}
data['features'] = json.dumps(vector).encode("zlib")
data['salary'] = salary
data['area'] = area_id
r.hmset(vacancy_id, data)
r.expire(vacancy_id, timeout)

Для желающих попробовать Redis есть инструкция. Поднимается за пару минут.

Система рекомендаций


Когда пользователь авторизуется на сайте, его резюме сохраняется в приложении. Чтобы сравнивать резюме с вакансиями, его так же надо преобразовать в вектор той же размерности и с таким же порядком параметров. Для создания вектора я брал текст из заголовка, ключевых навыков и поля «Обо мне». Также я предварительно сохранил CountVectorizer со словарем и TfidfTransformer, обученные на данных вакансий, которые я скачал в самом начале. Используя их, легко получить векторы для резюме.

Для создания рекомендаций находим векторы вакансий, похожие на вектор резюме. В качестве меры схожести я использовал косинусное расстояние. В sklearn есть готовая реализация.

Для каждого резюме сохраняем список самых похожих вакансий.

Еще надо учесть такие вещи, как зарплата и региональность. Поэтому исключаем для каждого резюме вакансии из неподходящих регионов, и если зарплата не соответствует определенной вилке. Часто в вакансии не указывается сумма. А именно: 31% из сохраненных 113 000 не содержал ЗП. Я решил, что такие вакансии тоже стоит рекомендовать.

Скрипт по подбору рекомендаций запускается периодически по крону. Это значит, что придется немного подождать, чтобы получить рекомендацию для своего резюме.

Сайт


Собственно, вот результат того, что получилось. Пробуйте. Если кому интересны исходники, то вот.

Какие могут быть проблемы у моего подхода. Во-первых, недостаточность данных в резюме: либо из-за их скудности, либо из-за специфичности. Также вероятность хорошей рекомендации снижается для регионов. Можно было бы улучшить показатели за счет профобластей и предыдущего опыта работы. Если бы это была популярная система, можно было бы добавить оценку качества рекомендаций, чтобы использовать её для дальнейших предложений. Если по резюме есть отклики и приглашения, использование этих данных тоже может повысить релевантность. Еще было бы здорово искать соответствия не только для одинаковых слов, но и для родственных. С этой задачей помог бы справиться word2vec. Но в любом случае пока это только пилотная версия.

Итак, я написал систему рекомендаций вакансий по информации, взятой из резюме. Все данные были получены через API HeadHunter. Используйте API, если у вас возникнет желание сделать свой сервис или мобильное приложение, связанное с HR тематикой. О проблемах или недостающем функционале пишите нам в issues.

UPD: По результатам готовых рекомендаций я понял, что не стоило вообще включать в выдачу результаты с низким коэффициентом схожести. Так же имело смысл исключить из результатов, если в векторе заполнено слишком мало параметров

UPD2: Добавил к резюме данные из последнего опыта работы. Теперь у тех, у кого недостаточно данных в остальных полях, должно стать лучше. Но мне этот подход не очень нравится по причине того, что в прошлом опыте может быть и то, что соискателю больше не интересно
Поделиться с друзьями
-->

Комментарии (14)


  1. BupycNet
    21.06.2016 13:10

    А у вас есть callback API? Хотелось бы написать микросервис для PushAll, чтобы можно было онлайн получать уведомления, если появляются вакансии по определённым параметрам.


    1. shurik2533
      21.06.2016 13:20

      нет, callback-и не делаются, но есть сохраненные поиски вакансий, которые можно периодически дергать


      1. BupycNet
        21.06.2016 13:29

        Неплохой вариант спасибо.
        Думюа тогда сделаю в стиле «Появилось 5 новых вакансий по вашему запросу» и проверку с периодичностью раз в 10 минут например.


  1. spmbt
    21.06.2016 13:14

    Как идею приложения, а также идею для расширения API, могу предложить "Свой шаблон отклика-письма на HeadHunter (и moikrug) без Copy-Paste", который сейчас реализован в виде юзерскрипта.

    Приложение должно хранить шаблон письма-отклика, с которым пользователь обращается к работодателю. В скрипте этот текстовый шаблон копируется вручную в браузер и хранится в localStorage.

    Вопрос: может ли API предоставить память на сервере для хранения этого шаблона (или вообще память) на основе токена пользователя? Тогда оно было бы полезно тем, что в каждый новый браузер пользователь не пришлось бы вводить шаблон.

    Далее, второй вопрос: зачем сделана двухэтажная паролизация? Надо сначала войти на страницу выдачи токена под своим паролем, а затем начать пользоваться токеном. Понятно, что так защищается пароль от его постоянного использования. Но можно ли иметь запрос API? который по паролю один раз выдаст токен?


    1. shurik2533
      21.06.2016 15:03

      Как идея для приложения — вполне хорошо. Возможно, кому-то будет удобно использовать шаблон для отклика на вакансию.
      Если бы функция шаблона для откликов на вакансию существовала бы на сайте, то логично, что эти шаблоны где-нибудь хранились. А предоставление памяти на основе токена, мне кажется, не совсем вписывается в рамки API.

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


  1. kloppspb
    21.06.2016 14:00

    Вы заставили пересмотреть последние 20 лет жизни. Потому как на резюме программиста мне порекомендовали:

    * Продавец-кассир (м. Старая Деревня)
    * Консультант в Евросеть (м.Старая деревня)
    * Оператор call-центра
    * Менеджер по работе с клиентами (Старая деревня)
    * Автомаляр
    * Кассир-продавец в супермаркет (м. Старая Деревня)
    * Администратор пункта выдачи Интернет-заказов (м. Старая Деревня)
    * Продавец-кассир (ТЦ «Питерлэнд»)
    * Бухгалтер по ТМЦ и ОС
    * Руководитель отдела продаж
    * Кладовщик
    * Кредитный специалист (м.Старая Деревня)
    * Продавец в салон связи (Метро Старая деревня)


    1. shurik2533
      21.06.2016 14:33

      Видимо, что-то пошло не так, либо недостаточно данных, либо присутствовал какой-то текст, который перевесил в сторону других вакансий. Данные берутся из заголовка, текста «Обо мне» и ключевых навыков. Возможно, данные из предыдущего опыта работы помогут исправить ситуацию.
      Уважаемые читатели, буду признателен, если еще кто-нибудь отпишется о качестве рекомендаций


      1. dmitriy_novikov
        21.06.2016 14:41

        у меня, на резюме программиста, в целом терпимый список получился, хоть и не без явных «промахов»:

        Front-end разработчик (JavaScript/HTML/CSS)
        Web-программист
        Web-дизайнер
        Frontend-разработчик (Черногория)
        Копирайтер
        Контент-менеджер
        Ведущий javascript / фронтенд разработчик
        Верстальщик-программист JavaScript
        Менеджер проекта (управление полным циклом интернет-проекта)
        Интернет-маркетолог
        Старший специалист по сбору данных (С#)
        Инженер по тестированию
        SMM-маркетолог
        Senior Java Developer
        Фронтенд-программист (JavaScript и SQL)
        Android developer
        Ассистент программиста .NET/WEB
        Руководитель отдела разработки (Microsoft Dynamics CRM) в Нижнем Новгороде
        PHP developer (Montenegro)
        Разработчик back-end на С++


        1. shurik2533
          21.06.2016 15:05
          +2

          Спасибо. Я буду дорабатывать. Еще, видимо, рекомендации с низким показателем схожести вообще не стоило включать в выдачу


  1. Stas911
    21.06.2016 18:51

    Забавно, недавно как раз писал абсолютно то же самое для скрипта прокачки своего резюме под конкретную вакансию :)


  1. Iktash
    22.06.2016 11:28
    +1

    Интересно было бы еще учитывать то, какие вакансии я стал просматривать внимательнее и на какие откликаться. Так можно было бы существенно пывысить качество персональных рекомендаций через пару дней использования.


    1. shurik2533
      22.06.2016 12:06

      Да, было бы хорошо. Не уверен, что можно через API реализовать, какие вакансии были просмотрены внимательнее, но на какие вакансии человек откликался — это есть.


  1. Bronik
    22.06.2016 12:20

    Что-то как-то странно работает алгоритм.
    Предложили вакансии, которые ну вообще никак не связаны с моим опытом и профилем (интернет-маркетинг)
    Я оказывается могу быть: Директор по стратегическому развитию, HR директор / Директор по персоналу, Коммерческий директор и т.д.


    1. shurik2533
      22.06.2016 12:41

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