Основы программирования в Python

Семинар 8: решения

Импортируем необходимые библиотеки:

In [1]:
from bs4 import BeautifulSoup
import requests

Загрузим страницу с проходными баллами с сайта Вышки:

In [2]:
page = requests.get('https://ma.hse.ru/passingrade')

Создадим объект soup и сохраним в него html-код страницы:

In [3]:
soup = BeautifulSoup(page.text, 'lxml')

Проходные баллы и названия программ хранятся в таблице, причём не просто в таблице, а в таблице с атрибутом class, равным data table bordered (см. исходный код страницы):

In [4]:
table = soup.findAll('table', {'class' : 'bordered'})[0]

Баллы и названия курсов нужно искать в пределах этой таблицы. Обратим внимание, что все баллы заключены в тэги <strong>:

In [5]:
table.findAll('strong')[0:5]  # для примера первые 5 элементов
Out[5]:
[<strong> 35</strong>,
 <strong> 21</strong>,
 <strong> 33</strong>,
 <strong> 21</strong>,
 <strong> 30</strong>]

Сохраним все баллы в список, вытащив из тэгов текст. Воспользуемся списковым включением:

In [64]:
sc = table.findAll('strong')
scores = [score.text for score in sc]
scores[0:5]  # первые несколько примеров
Out[64]:
['\xa035', '\xa021', '\xa033', '\xa021', '\xa030']

Значения сохранились с пробелами вначале, которые в текущей кодировке страницы записываются как \xa0. Пока их трогать не будем, лучше преобразуем, когда созраним все значения в датафрейм. Лучше обратим внимание на такую проблему: в списке не хватает одного значения: проходной балл для коммерческих мест на программе «Физика». Соответствующая ячейка не заполнена (таких нет), но это сбивает последующую нумерацию элементов, а нам она пригодится. Добавим пропущенное значение (дефис) сами на нужное место:

In [65]:
print(scores.index('110'))
22
In [66]:
scores.insert(23, '-')
In [68]:
scores[22:25]
Out[68]:
['110', '-', '\xa0-']

Похожая история с магистратурой «Церковь, общество, государство». Добавим недостающее «пустое» значение:

In [70]:
scores.insert(153, '-')

Как отделить баллы, соответствующие бюджетным местам, от баллов на коммерческие места? Можно заметить, что все баллы на бюджетные места имеют чётные индексы в списке scores, а на коммерческие – нечётные. Воспользуемся этим и рассортируем значения:

In [74]:
budget = []
comm = []
for i in range(0, len(scores)):
    if i % 2 == 0:
        budget.append(scores[i])
    else:
        comm.append(scores[i])

Списки с баллами сформированы, осталось выгрузить названия образовательных программ. Заметим, что названия сохранены в тех же тэгах, что и ссылки на них (тэг a). Воспользуемся этим:

In [71]:
links = table.findAll('a')
masters = [link.text for link in links]
masters[0:5]  # несколько примеров из списка 
Out[71]:
['Математика',
 'Mathematics',
 'Математика и математическая физика',
 'Совместная магистратура НИУ ВШЭ и Центра педагогического мастерства',
 'Анализ данных в биологии и медицине']

Теперь попробуем объединить полученные списки в датафрейм, предварительно создав словарь, где ключами являются названия столбцов, а значениями – списки значений:

In [72]:
import pandas as pd
In [13]:
df = pd.DataFrame({'programme' : masters, 
                  'budget' : budget,
                  'commerce' : comm})
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-13-95449f4717d4> in <module>()
      1 df = pd.DataFrame({'programme' : masters, 
      2                   'budget' : budget,
----> 3                   'commerce' : comm})

/anaconda3/lib/python3.6/site-packages/pandas/core/frame.py in __init__(self, data, index, columns, dtype, copy)
    346                                  dtype=dtype, copy=copy)
    347         elif isinstance(data, dict):
--> 348             mgr = self._init_dict(data, index, columns, dtype=dtype)
    349         elif isinstance(data, ma.MaskedArray):
    350             import numpy.ma.mrecords as mrecords

/anaconda3/lib/python3.6/site-packages/pandas/core/frame.py in _init_dict(self, data, index, columns, dtype)
    457             arrays = [data[k] for k in keys]
    458 
--> 459         return _arrays_to_mgr(arrays, data_names, index, columns, dtype=dtype)
    460 
    461     def _init_ndarray(self, values, index, columns, dtype=None, copy=False):

/anaconda3/lib/python3.6/site-packages/pandas/core/frame.py in _arrays_to_mgr(arrays, arr_names, index, columns, dtype)
   7313     # figure out the index, if necessary
   7314     if index is None:
-> 7315         index = extract_index(arrays)
   7316 
   7317     # don't force copy because getting jammed in an ndarray anyway

/anaconda3/lib/python3.6/site-packages/pandas/core/frame.py in extract_index(data)
   7359             lengths = list(set(raw_lengths))
   7360             if len(lengths) > 1:
-> 7361                 raise ValueError('arrays must all be same length')
   7362 
   7363             if have_dicts:

ValueError: arrays must all be same length

Не получилось! Почему? Потому что списки имеют разную длину. Проверим!

In [75]:
print(len(budget), len(comm), len(masters))
110 110 144

Действительно, длины разные. Посмотрим, в чём проблема:

In [16]:
masters[0:5]
Out[16]:
['Математика',
 'Mathematics',
 'Математика и математическая физика',
 'Совместная магистратура НИУ ВШЭ и Центра педагогического мастерства',
 'Анализ данных в биологии и медицине']

Англоязычные названия программ, реализуемых на английском языке, тоже сохраняются в списке! Уберём дубликаты с помощью регулярных выражений: отфильтруем только те названия, которые на кириллице.

In [76]:
import re  # импортируем модуль для регулярных выражений
re.findall('[А-Я|а-я]+', masters[0])  # для первого элемента
Out[76]:
['Математика']
In [77]:
# поместим всё в цикл
progs = []
for p in masters:
    res = re.findall('([А-Я]|[а-я])', p)
    if len(res) != 0:
        progs.append(p)
progs[0:10]  # готово!
Out[77]:
['Математика',
 'Математика и математическая физика',
 'Совместная магистратура НИУ ВШЭ и Центра педагогического мастерства',
 'Анализ данных в биологии и медицине',
 'Математические методы моделирования и компьютерные технологии',
 'Науки о данных',
 'Прикладная статистика с методами сетевого анализа',
 'Статистическая теория обучения',
 'Финансовые технологии и анализ данных',
 'Системы управления и обработки информации в инженерии']

Проверим длины списков теперь:

In [78]:
print(len(budget), len(comm), len(progs))
110 110 118

Опять много! Уберём баллы для очно-заочной формы обучения (в списках баллов их нет, если что, сгрузим отдельно):

In [79]:
progs = progs[0:-7]
In [80]:
print(len(budget), len(comm), len(progs)) # и снова неудача!
110 110 111

Из-за того, что в случае совместного конкурса в ячейке с программами по экономике стоят сразу две программы («Прикладная экономика» и «Экономика: исследовательская программа»), у нас образовалось лишнее значение. Давайте одно удалим, а второе честно поправим на более общее:

In [81]:
progs.index("Экономика: исследовательская программа")
Out[81]:
32
In [82]:
progs.remove("Экономика: исследовательская программа")
progs[31] = 'Единый конкурс на образовательные программы "Прикладная экономика" и "Экономика: исследовательская программа"'
In [83]:
print(len(budget), len(comm), len(progs))  # ура!
110 110 110
In [85]:
df = pd.DataFrame({'programme' : progs, 
                  'budget' : budget,
                  'commerce' : comm})
df.head()
Out[85]:
programme budget commerce
0 Математика 35 21
1 Математика и математическая физика 33 21
2 Совместная магистратура НИУ ВШЭ и Центра педаг... 30 21
3 Анализ данных в биологии и медицине 53 32
4 Математические методы моделирования и компьюте... 71 69

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

In [86]:
def to_number(x):
    n = x.strip()
    try:
        res = int(n)
    except:
        res = None
    return res
In [91]:
df['commerce'] = df.commerce.apply(to_number)
In [92]:
df.info()  # готово!
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 110 entries, 0 to 109
Data columns (total 3 columns):
programme    110 non-null object
budget       91 non-null float64
commerce     105 non-null float64
dtypes: float64(2), object(1)
memory usage: 2.7+ KB

Сохраним таблицу в Excel-файл:

In [93]:
df.to_excel('master-hse.xlsx')