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

Я уже около двух лет программирую на Python, и сейчас наступил момент осознанно подойти к освоению новых навыков. Для этого я решил проанализировать вакансии и представить востребованные навыки в виде графа. Я ожидал увидеть, что навыки будут образовывать кластеры, соответствующие разным специальностям: backend разработке, data science и др. А как же обстоят дела на самом деле? Обо всём по порядку.

Сбор данных


Сначала нужно было определиться с источником данных. Я рассмотрел несколько вариантов: Хабр Карьеру, Яндекс Работу, HeadHunter и другие. Наиболее удобным показался HeadHunter, так как здась в вакансиях присутствует список ключевых навыков и есть удобный открытый API.

Изучив API HeadHunter-a, я решил сначала парсить список id вакансий по заданному ключевому слову (в данном случае это “python”), а затем у каждой вакансии парсить список соответствующих тэгов.

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

Для этого был использован модуль requests. В поле “user-agent”, в соответствии с API, было вписано имя виртуального браузера, чтобы HH понимал, что к нему обращается скрипт. Делал небольшую задержку между запросами, чтобы не перегружать сервер.

ses = requests.Session()
ses.headers = {'HH-User-Agent': "Mozilla/5.0 (X11; Linux x86_64; rv:10.0) Gecko/20100101 Firefox/10.0"}

phrase_to_search = 'python'
url = f'https://api.hh.ru/vacancies?text={phrase_to_search}&per_page=100'
res = ses.get(url)

# getting a list of all pesponses
res_all = []
for p in range(res.json()['pages']):
    print(f'scraping page {p}')
    url_p = url + f'&page={p}'
    res = ses.get(url_p)
    res_all.append(res.json())
    time.sleep(0.2)

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

Как оказалось, API hh.ru ограничивает максимальное количество отдаваемых вакансий двумя тысячами, то есть при 100 вакансиях на страницу, максимальное количество страниц может быть 20. По ключевому слову Python было возвращено 20 страниц вакансий, и это значит, что реальных вакансий по Python скорее всего больше.

Чтобы получить списко тэгов я делал следующее:
  • итерировался по каждой странице поисковой выдачи,
  • итерировался по каждой вакансии на странице и получал id вакансии,
  • запрашивал подробности вакансии через API,
  • если в вакансии был указан хотя бы один тэг, то список тэгов добавлялся в список.

# parcing vacancies ids, getting vacancy page and scraping tags from each vacancy
tags_list = []
for page_res_json in res_all:
    for item in page_res_json['items']:
        vac_id = item['id']
        vac_res = ses.get(f'https://api.hh.ru/vacancies/{vac_id}')
        if len(vac_res.json()["key_skills"]) > 0:  # at least one skill present
            print(vac_id)
            tags = [v for v_dict in vac_res.json()["key_skills"] for _, v in v_dict.items()]
            print(' '.join(tags))
            tags_list.append(tags)
            print()
        time.sleep(0.1)

Списки тэгов сохранялись в виде словаря

res = {'phrase': phrase_to_search, 'items_number': len(tags_list), 'items': tags_list}
with open(f'./data/raw-tags_{phrase_to_search}.json', 'w') as fp:  # Serializing
    json.dump(res, fp)

Интересно, что из 2000 просмотренных вакансий, тэги имелись только у 1579 вакансий.

Форматирование данных


Теперь нужно обработать теги и перевести их в удобный для отображения в виде графа формат, а именно:
  • привести все тэги к единому регистру, так «machine learning», «Machine learning» и «Machine Learning» означают одно и то же,
  • вычислить величину нода как частоту встречаемости каждого тэга,
  • вычислить величину связи как частоту совместного встречания тэгов друг с другом.

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

tags_list['items'] = [[i.lower() for i in line] for line in tags_list['items']]

# counting words occurrences
flattened_list = [i for line in tags_list for i in line]
nodes_dict_all = {i: flattened_list.count(i) for i in set(flattened_list)}
nodes_dict = {k:v for k, v in nodes_dict_all.items() if v > del_nodes_count}

Попарную встречаемость вычислял так. Сначала создавал словарь, в котором ключами были все все возможные пары тэгов в виде tuple, а значения равнялись нулям. Затем проходился по спискам тэгов и увеличивал счётчики для каждой встречаемой пары. Затем я удалял все те элементы, значения которых равнялись нулю.

# tags connection dict initialization
formatted_tags = {(tag1, tag2): 0 for tag1, tag2 in itertools.permutations(set(nodes_dict.keys()), 2)}

# count tags connection
for line in tags_list:
    for tag1, tag2 in itertools.permutations(line, 2):
        if (tag1, tag2) in formatted_tags.keys():
            formatted_tags[(tag1, tag2)] += 1

# filtering pairs with zero count
for k, v in formatted_tags.copy().items():
    if v == 0:
        del formatted_tags[k]

На выходе формировал словарь вида

{
'phrase': phrase searched,
'items_number': number of vacancies parced, 
'items': {
 	"nodes": [
			{
			"id": tag name, 
		 	"group": group id, 
		 	"popularity": tag count
			},
		… 
		] 
	"links": [
			{
			"source": pair[0], 
			"target": pair[1], 
			"value": pair count
			},
		…
		]
	}
}

nodes = []
links = []
for pair, count in formatted_tags.items():
    links.append({"source": pair[0], "target": pair[1], "value": count})

max_count = max(list(nodes_dict.values()))
count_step = max_count // 7
for node, count in nodes_dict.items():
    nodes.append({"id": node, "group": count // count_step, "popularity": count})

data_to_dump = in_json.copy()
data_to_dump['items'] = {"nodes": nodes, "links": links}

Визуализация на Python


Для визуализации графа я использовал модуль networkx. Вот, что получилось с первого раза без фильтрации нодов.



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

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



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

Визуализация на JavaScript


Я бы наверно продолжил ковырять этот код, если бы в этот момент у меня не появилась подмога в виде брата. Он активно включился в работу и сделал красивое динамическое отображение на основе JavaScript модуля D3.

Получилось вот так.


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

Анализ результатов


Как мы видим, граф получился сильно переплетённым и чётко обозначенных кластеров с первого взгляда обнаружить не удаётся. Сразу можно заметить несколько больших нодов, которые востребованы больше всего: linux, sql, git, postgresql и django. Также есть навыки средней популярности и редко встречаемые навыки.

Кроме этого, можно обратить внимание на то, что навыки всё-таки формируют кластеры по профессиям располагаясь по разные стороны от центра:

  • слева внизу – анализ данных,
  • внизу – базы данных,
  • справа внизу – front-end разработка,
  • справа – тестирование,
  • справа вверху – web разработка,
  • слева вверху – machine learning.

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

На основе полученных результатов можно сделать следующие выводы:
  • нужно осваивать навыки, соответствующие большим нодам, они пригодятся всегда,
  • нужно осваивать навыки соответствующего вашим интересам кластера.

Надеюсь вам понравилось, данный анализ будет для вас полезен.

Взглянуть на код или поучаствовать в его развитии можно по ссылкам: GitHub проект, Observable ноутбук с визуализацией

Успехов в освоении новых горизонтов!