Перейти к основному содержимому

Аналитика по резюме выпускников

Подготовка к работе

Импорты необходимых библиотек
import os
import json
import math
import requests
import pandas as pd
from datetime import datetime
from dotenv import load_dotenv

Вся ниже представленная информация базируется на возможностях, предоставляемых HeadHunter API.

Обратите внимание

Вам потребуется аккаунт пользователя, который идентифицируется HH как работодатель. Это может быть, как корпоративный аккаунт от университета (в рамках HH есть центральный HR-аккаунт, который может регистрировать новых корпоративных пользователей), так и совсем сторонний работодатель. С точки зрения забора данных это не важно. Дополнительных финансовых вложений не потребуется, мы будем использовать только данные резюме, которые требуют только авторизацию под аккаунтом работодателя.

Дополнительно к аккаунту от работодателя Вам потребуется зарегистрировать приложение для работы с API. Авторизуйтесь (зарегистрировать приложение можно под личным аккаунтом на HH, но рекомендую, для порядка, делать это всё из под одной корпоративной учётки) в личном кабинете разработчика и зарегистрируйте новое приложение. По моему опыту процесс одобрения приложения со стороны HH не превышает одной недели.

После регистрации приложения у него появятся следующие реквизиты: Client ID, Client Secret, Токен приложения. Это позволит Вам авторизовать пользователя и получить реквизиты для работы с API. Сам процесс авторизации неплохо описан самим HeadHunter в статье на Habr-е. Обратите внимание, что токен авторизации пользователя имеет ограниченное время жизни, его придётся обновлять.

Надеюсь у Вас всё получится! 💪

ID университетов в HH

Для поиска ID-шников университетов, которые мы будем использовать для работы с API, вы можете использовать 3 способа:

  1. «Ручку» API HH для поиска по учебным заведениям. Вот пример ссылки для поиска «ИТМО»;
  2. «Ручку» API psal.ru, с данными об университетах, которые отслеживаются проектом (на момент написания, апрель 2023 года, это университеты, входящие в федеральные программы «Приоритет 2030» и «Передовые инженерные школы»);
  3. Ещё можно воспользоваться расширенным поиском на самом hh.ru, где есть UI-ки для указания учебного заведения. Вы можете воспользоваться DevTool браузера и при поиске/выборе учебного заведения, забирать необходимое Вам ID.

Я буду использовать вариант упомянутый в пункте 2 и для полноты картины остановлюсь на некоторых моментах:

  1. У университетов есть филиалы, но в используемом API psa.ru используется только ID головных организаций. Для примера можно посмотреть МФТИ, как это бывает.
  2. Данные HH, видимо, не всегда поспевают за переименованиями университетов и проходится «отлавливать» вузы по старым названиям. Из того, с чем столкнулся, при поиске ID-шников: «Уральский государственный медицинский университет» — «Уральская государственная медицинская академия»; «Смоленский государственный медицинский университет» — «Смоленская государственная медицинская академия»; «Тихоокеанский государственный медицинский университет» — «Владивостокский государственный медицинский университет».
  3. Университеты объединяются, в этом случае в API psa.ru используется ID одной из объединённых организаций, если в данных HH нет ещё нового названия университета. Конкретный пример — для «Уфимский университет науки и технологий» используется «Башкирского государственного университета».
  4. По каким-то причинам мне не удалось найти в HH «Университет Иннополис» 🤷‍♂️

Исходные данные об университетах

Сформируем таблицу университетов с основными данными и ID для работы с HH API.

Вспомогательная функция для работы с API https://app.psal.ru/api
def get_results_from_api(url, page=1):
results = pd.DataFrame()

resp = requests.get(url + f"?page={page}")

if resp.status_code == 200:
json_data = resp.json()
results = pd.concat([results, pd.json_normalize(json_data['results'])], ignore_index=True)

if json_data['next']:
next_page_df = get_results_from_api(url, page + 1)
results = pd.concat([results, next_page_df], ignore_index=True)
else:
return pd.DataFrame()

return results
Формируем таблицу со списком университетов
universities = get_results_from_api('https://app.psal.ru/api/university/')
universities = universities[universities['deleted_at'].isnull()]
universities = universities.apply(lambda x: pd.concat([
x[list(filter(lambda y: y in ['id', 'title_short', 'title_display', 'domain', 'educational_institution_id'], x.keys()))],
pd.Series({'educational_institution_id': x['hh'][0]['educational_institution_id']})
]), axis=1)
universities = universities[~universities['educational_institution_id'].isnull()]
universities['educational_institution_id'] = universities['educational_institution_id'].astype(int)
universities.head(5)
idtitle_shorttitle_displaydomaineducational_institution_id
01Адыгейский государственный университетАГУadygnet.ru41900
12Алтайский государственный университетАлтГУasu.ru41808
23Амурский государственный университетАмГУamursu.ru42650
34Астраханский государственный университетАГУ им. В.Н. Татищеваasu.edu.ru40229
56Белгородский государственный технологический у...БГТУbstu.ru45479

Забираем резюме из HH

Для начала настроим всё, что нам понадобится для работы с HH API.

Подготовка для работы с HH API
load_dotenv('../.env')
access_token = os.getenv('HH_ACCESS_TOKEN_USER')
session = requests.session()
session.headers.update({'Authorization': f"Bearer {os.getenv('HH_ACCESS_TOKEN_USER')}"})
# Расшифровка GET-параметров в запросе
# area - регион поиска (113 - вся Россия)
# educational_institution - id-конкретного университета
# job_search_status - кандидаты находятся в активном поиске
# relocation - живут или готовы к релокации из региона
# order_by - сортируем по ожидаемой заработной плате
# period - 1 день (ниже см. почему выбран именно такой период)
# per_page - кол-во результатов на странице ответа
# page - номер страницы в выдаче
# text - возможность поиска по текстам вакансии, но мы её игнорируем
search_query = '''
/resumes?
area=113
&educational_institution={edu_inst_id}
&job_search_status=active_search
&relocation=living_or_relocation
&order_by=salary_desc
&period=1
&per_page={per_page}
&page={page}
&text=
'''.replace('\n', '')

# Функция для работы с API HH
def get_data(edu_inst_id, page=0, per_page=100) -> list:
url = f"https://api.hh.ru{search_query.format(edu_inst_id=edu_inst_id, per_page=per_page, page=page)}"
resp = session.get(url)
resumes = []

if resp.status_code == 200:
json_data = resp.json()
total = json_data['found']
count_of_page = math.ceil(total / per_page)
resumes += json_data['items']

if count_of_page != page + 1:
resumes += get_data(edu_inst_id, page + 1)
else:
print(f'{url} returned status code {resp.status_code}')

return resumes

Далее сходим в API и сохраним результаты по каждому университету в отдельный json-файл. Потребуется время, процесс можно отслеживать в папке data/json_from_hh_api/, куда сохраняются результирующие json-чики. В конце посмотрим ТОП-10 университетов по количеству резюме их выпускников на данный момент.

Сбор резюме по университетам
resume_by_universities = pd.DataFrame()

for i, u in universities.iterrows():
educational_institution_id = u['educational_institution_id']
university_title = u['title_display']
resume_by_university = get_data(educational_institution_id)
file_path = f"data/resume/{educational_institution_id}.json"

with open(file_path, 'w') as f:
json.dump(resume_by_university, f, ensure_ascii=False, indent=4)

resume_by_universities = pd.concat([resume_by_universities, pd.DataFrame([{
'title_display': u['title_display'],
'educational_institution_id': educational_institution_id,
'resume_count': len(resume_by_university),
'file_path': file_path
}])], ignore_index=True)

resume_by_universities.sort_values(by='resume_count', ascending=False).head(10)
title_displayeducational_institution_idresume_countfile_path
109РАНХиГС39639843data/resume/39639.json
107ВШЭ39307568data/resume/39307.json
43РЭУ им. Г.В. Плеханова38958563data/resume/38958.json
51СПбПУ39765416data/resume/39765.json
72УРФУ43473416data/resume/43473.json
25МГТУ им. Н.Э. Баумана38921399data/resume/38921.json
46РУДН39271387data/resume/39271.json
87РУТ (МИИТ)39532337data/resume/39532.json
29МПГУ39337332data/resume/39337.json
81КФУ43555300data/resume/43555.json
Обратите внимание

На примере выпускников РАНХиГС мы видим, что за 1 день, API ~850 резюме. При этом у HH есть ограничение в 2 тыс. записей, который они отдают по запросу (это касается как API, так и UI-ек на их сайте). По этим причинам в данном примере и используется 1 день в GET-параметре period, чтобы влезть в это ограничение и ничего не упустить.

Работа с полученными данными

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

Соберём всё в один dataframe и отобразим получившееся количество строк:

Сводим всё в один dataframe
all_resume = pd.DataFrame()

for i, u in resume_by_universities.iterrows():
json_by_university = json.load(open(u['file_path'], 'r'))
df_by_university = pd.json_normalize(json_by_university)
df_by_university['title_display'] = u['title_display']
all_resume = pd.concat([all_resume, df_by_university], ignore_index=True)

len(all_resume)
17144

Заработные платы выпускников

Самое очевидное это ожидаемая заработная плата соискателей. Просто для примера, выстроим университеты по медиане ожидаемой заработной платы выпускников. Оговорюсь, что реальной ценности такого подхода «в лоб» нет или он даже вреден, т.к. для построения рейтинга нужно учитывать регион, область полученного образования и ещё ряд факторов. Но для обзора возможностей на полученных данных 👌

Выстраиваем университеты по заработной плате
all_resume[
# Используем только заработные платы в рублях (лень было пересчитывать валюты 🤷‍♂️)
(all_resume['salary.currency'] == 'RUR') &
# Исключаем резюме без заработной платы
(~all_resume['salary.amount'].isnull()) &
# Смотрим только резюме с ЗП выше или равно МРОТ (на апрель 2023 г.)
(all_resume['salary.amount'] >= 13890)
][['title_display', 'salary.amount']].groupby(by='title_display').median().sort_values(by='salary.amount', ascending=False).head(10)
salary.amount
title_display
МФТИ150000.0
МГИМО120000.0
РАМ им. Гнесиных111500.0
НИУ МГСУ110000.0
ТГМУ110000.0
УГНТУ100000.0
ВШЭ100000.0
МИРЭА100000.0
МГТУ им. Н.Э. Баумана100000.0
НИУ МЭИ100000.0

Количество образований в резюме

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

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

Рейтинг университетов по средней длине списка
all_resume['edu_count'] = all_resume['education.primary'].apply(lambda x: len(x))
all_resume[['title_display', 'edu_count']].groupby(by='title_display').mean().sort_values(by='edu_count', ascending=False).head(10)
edu_count
title_display
ПИМУ2.555556
ВМА2.500000
МГМСУ2.370968
РНИМУ2.362069
ПСПбГМУ2.310345
МГППУ2.275862
РАНХиГС2.265718
МГИМО2.237500
БГМУ2.185185
ВШЭ2.181338

Резюме обучающихся

Данные позволяют нам отсеять из общей массы резюме обучающихся и изучить их отдельно:

Отсеиваем обучающихся и выводим их количество
all_resume['is_student'] = all_resume['education.primary'].apply(lambda x: len(x) == 1 and x[0]['year'] > datetime.now().year)
all_resume[all_resume['is_student']][['title_display', 'is_student']].groupby(by='title_display').count().sort_values(by='is_student', ascending=False).head(10)
is_student
title_display
ВШЭ57
РАНХиГС51
МГТУ им. Н.Э. Баумана38
СПбПУ34
УРФУ32
КФУ31
РУДН28
ИТМО24
РУТ (МИИТ)22
СПбГУПТД21

Какую работу ищут выпускники

В данных присутствует желаемое кандидатом название должности. Попробуем сгруппировать данные для одного из университетов и посмотрим ТОП 10 самых популярных должностей.

Группируем данные по названию должности
resume_by_etu = all_resume[all_resume['title_display'] == 'СПбГЭТУ «ЛЭТИ»'][['title_display', 'title', 'url']]
resume_by_etu_and_title = resume_by_etu.groupby(by=['title_display', 'title'], as_index=False).count().rename(columns={'url': 'count'}).sort_values(by='count', ascending=False)
resume_by_etu_and_title.head(10)
title_displaytitlecount
13СПбГЭТУ «ЛЭТИ»Frontend-разработчик4
109СПбГЭТУ «ЛЭТИ»Руководитель отдела продаж2
91СПбГЭТУ «ЛЭТИ»Менеджер проекта2
38СПбГЭТУ «ЛЭТИ»Python Developer2
69СПбГЭТУ «ЛЭТИ»Инженер2
98СПбГЭТУ «ЛЭТИ»Начинающий специалист2
28СПбГЭТУ «ЛЭТИ»Junior Python Developer2
8СПбГЭТУ «ЛЭТИ»Data scientist2
82СПбГЭТУ «ЛЭТИ»Копирайтер1
83СПбГЭТУ «ЛЭТИ»Куратор, копирайтер1

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

Конец сгрупированного списка по названию должности
resume_by_etu_and_title.tail(10)
title_displaytitlecount
45СПбГЭТУ «ЛЭТИ»Sales1
44СПбГЭТУ «ЛЭТИ»QA инженер1
43СПбГЭТУ «ЛЭТИ»QA engineer1
42СПбГЭТУ «ЛЭТИ»QA Engineer (Junior)1
41СПбГЭТУ «ЛЭТИ»QA Engineer1
40СПбГЭТУ «ЛЭТИ»Python developer1
39СПбГЭТУ «ЛЭТИ»Python backend разработчик1
37СПбГЭТУ «ЛЭТИ»Project Manager1
36СПбГЭТУ «ЛЭТИ»Product manager1
124СПбГЭТУ «ЛЭТИ»руководитель, инженер по КИПиА1

Из-за того что название — текстовое поле. кандидаты указывают там слишком уникальные значения. Нужно что-то более нормализированное для анализа. Для этого придётся сходить в каждую вакансию и получить более подробную информацию по резюме. Обратите внимание, что API отдаёт более расширенную информацию.

Получаем подробную информацию из резюме
json_with_full_info_by_etu = []

# Тут мы упираемся видимо в бесплатные возможности API, которое отдаёт подробную информацию только по 50 резюме
for i, r in resume_by_etu[:50].iterrows():
resp_full_info = session.get(r['url'])

if resp_full_info.status_code == 200:
json_with_full_info_by_etu.append(resp_full_info.json())

resume_full_by_etu = pd.json_normalize(json_with_full_info_by_etu)
resume_full_by_etu[['title', 'professional_roles']].head(10)
titleprofessional_roles
0DevOps engineer / SRE[{'id': '96', 'name': 'Программист, разработчи...
1(Ведущий) Менеджер по продукту, Менеджер проектов[{'id': '10', 'name': 'Аналитик'}, {'id': '73'...
2Менеджер проекта[{'id': '104', 'name': 'Руководитель группы ра...
3Руководитель отдела разработки[{'id': '107', 'name': 'Руководитель проектов'...
4Начальник отдела, начальник сектора[{'id': '16', 'name': 'Аудитор'}]
5Электрик[{'id': '40', 'name': 'Другое'}, {'id': '143',...
6QA инженер[{'id': '124', 'name': 'Тестировщик'}]
7Product manager[{'id': '107', 'name': 'Руководитель проектов'...
8Java developer[{'id': '96', 'name': 'Программист, разработчи...
9Преподаватель английского языка (Native)[{'id': '132', 'name': 'Teacher, educator'}, {...
Возможен неожиданный эффект

После написании данного материала и сбора данных (в качестве аккаунта работодателя использовался аккаунт моего ИП), мне в Телеграм написал человек, с просьбой рассмотреть его кандидатуру, т.к. я просматривал его резюме. При этом, я пользовался как API, так и обычными UI-ками HH для работодателей (второе использовалось для дебага кода).

Что конкретно сгенерировало уведомление соискателю о просмотре его резюме моим ИП (API или UI-ки HH) я не стал выяснять 🤷‍♂️, но стоит помнить о таком возможном эффекте при работе с API HH.

Ели нет возможности расшить деньгами ограничение в 50 вакансий с подробной информацией... есть обходной, запасной вариант, которым часто приходится пользоваться, работая с HH 👍. Для этого придётся сначала сформировать справочник по имеющимся профессиональным ролям и при начальном сборе информации по университетам обходить API по каждой профессиональной роли указывая её в GET параметре professional_role.

Банальные цифры

В данных указан пол. Можно взглянуть на эти данные по конкретному университету:

Сводная таблица по полу
pd.pivot_table(
all_resume[['title_display', 'gender.id', 'id']],
index='title_display',
columns='gender.id',
values='id',
aggfunc='count',
).sort_values(by='male', ascending=False).head(10)
gender.idfemalemale
title_display
РАНХиГС360.0483.0
МГТУ им. Н.Э. Баумана86.0313.0
РЭУ им. Г.В. Плеханова280.0283.0
СПбПУ147.0269.0
ВШЭ306.0262.0
УРФУ166.0250.0
РУТ (МИИТ)122.0215.0
УГНТУ55.0209.0
МАИ69.0197.0
НИУ МГСУ50.0180.0

Можно посмотреть распределение по возрасту для конкретных университетов:

Смотрим распределение по возрасту в 4-х университетах
pd.DataFrame({
'СПбПУ': all_resume[(all_resume['title_display'] == 'СПбПУ') & (~all_resume['age'].isnull())]['age'],
'ИТМО': all_resume[(all_resume['title_display'] == 'ИТМО') & (~all_resume['age'].isnull())]['age'],
'СПбГЭТУ «ЛЭТИ»': all_resume[(all_resume['title_display'] == 'СПбГЭТУ «ЛЭТИ»') & (~all_resume['age'].isnull())]['age'],
'ГУАП': all_resume[(all_resume['title_display'] == 'ГУАП') & (~all_resume['age'].isnull())]['age']
}).hist(bins=10)

png

Где выпускники учатся после

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

Смотрим где учатся выпускники РАНХиГС
universities_by_id = universities.set_index('educational_institution_id')
university_for_research = universities[universities['title_display'] == 'РАНХиГС'].iloc[0].T
next_edu = pd.DataFrame()

for i, r in all_resume[all_resume['title_display'] == university_for_research['title_display']].iterrows():
is_completed_edu = False

for edu in r['education.primary']:
if edu['name_id'] == str(university_for_research['educational_institution_id']) and edu['year'] <= datetime.now().year:
is_completed_edu = True
elif is_completed_edu and edu['name_id'] != str(university_for_research['educational_institution_id']):
next_edu = pd.concat([next_edu, pd.DataFrame([{
'next_edu_id': edu['name_id'],
'next_edu_title': edu['name']
}])])

next_edu.groupby(by='next_edu_title', as_index=False).count().rename(columns={'next_edu_id': 'count'}).sort_values(by='count', ascending=False).head(10)
next_edu_titlecount
276Национальный исследовательский университет "Вы...10
351Российский экономический университет им. Г.В. ...10
241Московский государственный университет им. М.В...9
342Российский государственный университет нефти и...8
238Московский государственный технологический уни...8
235Московский государственный технический универс...7
468Финансовый университет при Правительстве Росси...7
200Московская Государственная юридическая академи...7
348Российский университет дружбы народов, Москва6
225Московский государственный горный университет,...6

Вместо выводов

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

Объём данных такой, что у меня нет технической возможности постоянно забирать их и строить Dashboard-ы на основании полученного, по аналогии с 📊 вакансиями университетов 🤷‍♂️

Исходный Jupiter Notebook для данной страницы находится по ссылке в Github Получившийся набор данных, на момент написания этого материала, вы можете ⬇️ скачать по ссылке.