Недавно я опубликовал пост, рассказывающий, как можно просто начать использовать наше 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-а:
- Сохраняемым записям можно выставлять TTL, после которого они автоматически удаляются и не приходится заботиться о высвобождении места.
- Одному ключу можно сопоставить 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: Добавил к резюме данные из последнего опыта работы. Теперь у тех, у кого недостаточно данных в остальных полях, должно стать лучше. Но мне этот подход не очень нравится по причине того, что в прошлом опыте может быть и то, что соискателю больше не интересно