#!/usr/bin/env python
# coding: utf-8
# # Программирование на языке Python для сбора и анализа данных
#
# *Текст лекции: Щуров И.В., НИУ ВШЭ*
#
# Данный notebook является конспектом лекции по курсу «Программирование на языке Python для сбора и анализа данных» (НИУ ВШЭ, 2015-16). Он распространяется на условиях лицензии [Creative Commons Attribution-Share Alike 4.0](http://creativecommons.org/licenses/by-sa/4.0/). При использовании обязательно упоминание автора курса и аффилиации. При наличии технической возможности необходимо также указать активную гиперссылку на [страницу курса](http://math-info.hse.ru/s15/m). Фрагменты кода, включенные в этот notebook, публикуются как [общественное достояние](http://creativecommons.org/publicdomain/zero/1.0/).
#
# Другие материалы курса, включая конспекты и видеозаписи лекций, а также наборы задач, можно найти на [странице курса](http://math-info.hse.ru/s15/m).
# ## Лекция №12: Библиотека pandas
# Мы обсуждали разные структуры данных — списки, словари, массивы, сложные структуры, составленные из списков и словарей, XML-файлы и т.д. Однако самый распространённый вид, в котором обычно представляют данные для их анализа — это таблицы. Проще всего сохранить таблицу в Python в списке, элементами которого являются списки — строки таблицы. Например, вот такая табличка:
#
#
#
# будет записана вот в таком виде:
# In[1]:
table = [[1, 2, 3], [4, 5, 6]]
# In[2]:
table
# Например, можно получить третий элемент второй строки вот так:
# In[3]:
table[1][2]
# Или целиком первую строку:
# In[4]:
table[0]
# Эта структура простая и понятная, однако не слишком удобная. Например, получить первый *столбец* уже не так просто. (Хорошее упражнение — написать для этого правильный код.)
#
# Есть ещё одна проблема: такая структура очень медленная. К счастью, хорошие люди уже написали для нас библиотеку по работе с табличными данными. Она называется *pandas*.
# ### Датафреймы
# In[5]:
import pandas as pd
# В pandas реализованы типы данных с разным числом измерений: одномерный тип (просто ряд) называется `Series`, двумерный (табличка) — `DataFrame`, трёхмерный — `Panel`. Мы будем обсуждать преимущественно `DataFrame`. Про `Series` скажем пока только то, что эта штука очень похожа на `np.array`.
#
# Давайте сделаем датафрейм из нашей таблички.
# In[6]:
df = pd.DataFrame(table)
# In[7]:
df
# У такой таблицы можно обращаться к строкам, а можно и к столбцам:
# In[8]:
df[0] # первый столбец
# Номера слева соответствуют номерам (а точнее индексам) строк. Перед нами объект `pd.Series`, представляющий собой нечто среднее между `np.array` и словарём (ниже мы столкнёмся с этим более явно, когда индексы будут не последовательными целыми числами, а строками).
# In[9]:
df.loc[0] # первая строка
# In[10]:
df.loc[1] # вторая строка
# ### Загрузка датафрейма из CSV-файла
# Для дальнейшего рассмотрим простой CSV-файл с данными, изображающими результаты нескольких виртуальных студентов по некоторым домашкам. Я подготовил этот файл и сохранил его на сервере. Давайте для начала посмотрим на него «глазами»:
# In[11]:
import requests
r = requests.get("http://math-info.hse.ru/f/2015-16/all-py/data/simple.csv")
print(r.text)
# Здесь `Ann`, `Bob` и т.д. — имена студентов, `Limits`, `Derivatives` и т.д. — названия домашних работ. Загрузим наш файл в `Pandas` — для этого его даже не нужно скачивать — можно передать функции `pd.read_csv()` URL (сетевой адрес) нашего файла (хотя, конечно, можно было бы открыть и локальный файл с тем же успехом):
# In[12]:
df = pd.read_csv("http://math-info.hse.ru/f/2015-16/all-py/data/simple.csv")
# In[13]:
df
# Заметим, что верхняя строчка выделена полужирным — это потому, что она рассматривается не как строчка с данными, а как строчка с именами столбцов. К столбцам можно обращаться по имени (примерно как к элементам словарей).
# In[14]:
df['Bob']
# У строк по умолчанию нет имён, и им присваивются номера (левая полужирная колонка). Можно было бы загрузить файл таким образом, чтобы первый столбец рассматривался как столбец с именами строк (индексам), для этого надо передать `pd.read_csv()` параметр `index_col` с номером нужного столбца:
# In[15]:
df = pd.read_csv(
"http://math-info.hse.ru/f/2015-16/all-py/data/simple.csv",
index_col=0
)
# In[16]:
df
# Теперь можно посмотреть на строчку, обратившись к ней по имени (то есть по названию домашки):
# In[17]:
df.loc['Limits']
# Заметим, что теперь столбец слева состоит не из последовательных чисел, а из строк, являющихся именами соответствующих столбцов в исходной таблице.
#
# > В статистике, строчка датафрейма называется «наблюдением» (*observation*), а столбец — «переменной» (*variable*). Данные в столбце должны быть однородны (например, может быть столбец, состоящий только из чисел или только из строк, но не может быть столбца, в котором перемешаны строки и числа), а по строкам могут быть разнородны.
# Слово `Assignment` при отображении `df` — не имя какой-то строчки, а имя столбца с индексами.
# In[18]:
df.index
# Его можно убрать вот так:
# In[19]:
df.index.name = None
# In[20]:
df
# Вот так можно обратиться к конкретному элементы таблицы:
# In[21]:
df.at['Limits', 'Bob']
# Попробуем то же самое с `Ann`:
# In[22]:
df.at['Limits', 'Ann']
# Что-то не сработало, хотя `Ann`, очевидно, присутствует в нашем датафрейме. Посмотрим поближе на список имён столбцов:
# In[23]:
df.columns
# Вот в чём дело! Перед именем `Ann` вкрался лишний пробел. Это потому, что я делал CSV-файл ручками и решил, что так будет более красиво. Ну что же, давайте переименуем первый столбец, убрав оттуда пробел. Сделать это, напрямую манипулируя элементами `df.columns` не получится (там неизменяемый объект), но зато можно присвоить этой переменной новый список, в котором имя первого столбца будет правильным.
# In[24]:
my_columns = list(df.columns)
print(my_columns)
my_columns[0] = 'Ann'
print(my_columns)
# In[25]:
df.columns = my_columns
# In[26]:
df['Ann']
# ### Срезы
# Несмотря на то, что просто квадратные скобки (безо всяких `.loc` перед ними) позволяют обращаться к столбцам таблицы, те же самые квадратные скобки, используемые вместе со срезами, работают «по строчкам». Например, вот так можно получить вторую и третью строчки таблицы:
# In[27]:
df[1:3]
# Срезы можно делать не только по номерам строк, но и по их именам:
# In[28]:
df['Limits':'Vectors']
# Важная разница состоит в том, что теперь срез *включает* последний элемент (в отличие от всего, что вы знали раньше о срезах). Срезы с номерами строк ведут себя как обычно (последний элемент не включается):
# In[29]:
df[0:1]
# Ещё можно обратиться к столбцу по его имени вот так:
# In[30]:
df.Ann
# Но это не самый безопасный способ. Во-первых, чтобы он сработал, имя столбца должно быть валидным идентификатором Python (то есть таким словом, каким можно назвать переменную), а это не всегда верно — например, в имени столбца могут встречаться пробелы, и в этом случае способ обращения через точку не сработает. Во-вторых, использовать такое обращение при присваивании небезопасно. Например, редактирование конкретной ячейки в уже созданном столбце сработает:
# In[31]:
df.Ann['Limits'] = 2.0 # Ann пересдала домашку по пределам
# In[32]:
df
# А создание нового столбца таким образом не сработает:
# In[33]:
df.Julia = [1, 2, 3, 4, 5]
# In[34]:
df
# Если бы мы хотели добавить новый столбец, нужно было бы использовать синтаксис с квадратными скобками:
# In[35]:
df['Julia'] = [1, 2, 3, 4, 5]
# In[36]:
df
# ### Ещё о способах обращения к элементам датафреймов
# Как мы уже видели выше, есть несколько способов обращаться к элементам датафрейма. Во-первых, просто квадратные скобки. Если передать им один элемент, то вернётся столбец с соответствующим именем. Кстати, если передать список из нескольких элементов, то вернётся датафрейм с соответствующими столбцами:
# In[37]:
df[['Ann', 'Bob']]
# А если передать срез (что-то через двоеточие), то вернётся датафрейм с соответствующими строчками. (Это всё не так-то просто запомнить, увы.)
#
# #### `loc[]`
# Второй способ — это метод `loc`. Его надо вызывать также с квадратными скобками, передавая имена. Если передать одно имя, то оно рассматривается как имя строки, а если два, то возвращается ячейка с соответствующей строкой и столбцом:
# In[38]:
df.loc['Limits']
# In[39]:
df.loc['Limits', 'Ann']
# А вот так можно с помощью `loc[]` получить столбец:
# In[40]:
df.loc[:, 'Ann']
# Здесь в качестве первого аргумента передаётся «тривиальный срез», то есть такой срез, у которого начало совпадает с началом всего массива, а конец с концом всего. Аналогично было со списками, если помните:
# In[41]:
my_list = [4, 3, 2, 1]
# In[42]:
other_list = my_list[:]
other_list
# #### `iloc[]`
# Метод `loc[]` работает с именами строк и столбцов, а если вы хотите использовать их номера, то вам нужен метод `iloc[]`. Работает он примерно так:
# In[43]:
df.iloc[1] # вторая строка
# In[44]:
df.iloc[:, 2] # третий столбец
# In[45]:
df.iloc[1, 2] # ячейка во второй строке, третьем столбце
# #### `at[]` и `iat[]`
# Обычные квадратные скобки, а также методы `loc[]` и `iloc[]` должны обрабатывать разные случаи, связанные со срезами и т.д. Это делает их медленными. Если вы хотите обратиться к конкретной ячейке, можете использовать для этого методы `at[]` или `iat[]`.
# In[46]:
df.at['Derivatives', 'Ann']
# In[47]:
df.iat[1, 2]
# ### Выбор по условию
# Предположим, что нам нужно получить все строки, соответствующие домашкам, по которым *Ann* получила оценку выше тройки. Это делается так:
# In[48]:
df[df['Ann']>3]
# Этот синтаксис работает примерно так же, как аналогичный синтаксис в `np.array`, обсуждавшийся на прошлой лекции. Результатом сравнения `df['Ann']` с числом 3 является массив (точкее, `pd.Series`), содержащий булевские элементы, являющиеся результатами попарного сравнения элементов `df['Ann']` с числом 3:
# In[49]:
df['Ann']>3
# Если передать такой объект квадратным скобкам, то они выберут те элементы, напротив которых стоит `True`.
#
# Аналогично можно выбрать столбцы по условию, с помощью `loc`.
# In[50]:
df.loc[:, df.loc['Derivatives']>2]
# выбрать все столбцы, у которых в строке `Derivatives` оценка больше двух
# ### Операции со строками и столбцами
# Допустим, мы хотим посчитать среднюю успеваемость каждого студента. Нет проблем:
# In[51]:
df.mean()
# А может быть нас интересует средняя решаемость домашек? Можно предложить два способа её посчитать. Во-первых, можно транспонировать нашу табличку, записав строки по столбцам, и применить к ней тот же метод `mean()`.
# In[52]:
df.T
# In[53]:
df.T.mean()
# Во-вторых (и это, наверное, проще), можно передать методу `mean()` параметр `axis`, указывающий, вдоль какой оси нужно считать. (Такое свойство есть у разных функций, работающих со строками или столбцами.)
# In[54]:
df.mean(axis=1)
# In[55]:
df.mean(axis='columns')
# можно ещё так
# Конечно, есть не только среднее, но и другие распространённые функции дескриптивной статистики.
# In[56]:
df.min()
# In[57]:
df.max()
# ### Более сложный датафрейм
# Рассмотрим реальный датафрейм, полученный с портала открытых данных г. Москвы. Я его скачал [отсюда](http://data.mos.ru/opendata/7704219722-tarify-na-kommunalnye-uslugi-dlya-naseleniya-goroda-moskvy-na-2012-god?pageNumber=1&versionNumber=2&releaseNumber=1), но там он лежит в zip-файле, который не так-то просто разархивировать из-за кириллицы в имени файла (мне на Маке пришлось повозиться), так что я скопировал CSV'шку на наш сервер. Если попробовать её просто открыть, то появится ошибка, потому что в качестве разделителя там используется не запятая, а точка с запятой — такое часто бывает и у функции `pd.read_csv()` на эту тему есть специальный параметр `sep`:
# In[58]:
df = pd.read_csv(
"http://math-info.hse.ru/f/2015-16/all-py/data/tariff2012.csv",
sep=';'
)
# Таблица большая, выведем только несколько первых строк, чтобы понять, с чем мы имеем дело:
# In[59]:
df.head()
# Кажется, в таблице есть столбец `ROWNUM`, который можно было бы сделать индексом для строк, но тут не всё просто. Например:
# In[60]:
df['ROWNUM']
# Почему-то эта штука считает, что там нет столбца `ROWNUM`, хотя мы его видим своими глазами! Давайте посмотрим повнимательнее.
# In[61]:
df.columns
# …Вроде бы есть `ROWNUM`…
# In[62]:
list(df.columns)
# Вот оно! Оказывается, перед `ROWNUM` есть какой-то невидимый символ, кодирующийся как `\ufeff`. Это так называемый BOM — Byte Order Mark — и он вылезает иногда при чтении файлов в кодировках UTF. Он показывает, в каком порядке идут байты. Чтобы он не попал в наши данные, нужно при чтении использовать опцию `encoding='utf-8-sig'`:
# In[63]:
df = pd.read_csv(
"http://math-info.hse.ru/f/2015-16/all-py/data/tariff2012.csv",
sep=';',
encoding = 'utf-8-sig'
)
# Теперь `df['ROWNUM']` работает:
# In[64]:
df['ROWNUM'].head()
# Впрочем, этот столбец нам всё равно в явном виде не понадобится — мы сделаем его индексом строк.
# In[65]:
df = pd.read_csv(
"http://math-info.hse.ru/f/2015-16/all-py/data/tariff2012.csv",
sep=';',
encoding='utf-8-sig',
index_col=0
)
# In[66]:
df.head()
# Обратите внимание на `NaN` — это такой специальный элемент, который указыает, что в данной ячейке нет данных (например, в исходном файле в эту ячейку ничего не было записано).
#
# ### Выбор и группировка
#
# Видно, что в таблице собраны разнородные данные — тарифы на газ, электроэнергию и т.д. Прежде, чем с ними можно будет сделать что-то осмысленное, их надо как-то разделить. Это можно делать, например, с помощью условного выбора:
# In[67]:
df_gas = df[df['TariffItem'] == 'Газ']
df_gas.head()
# Теперь у нас есть табличка, в которой приведены данные только по тарифам на газ.
#
# Другой подход состоит в том, чтобы создать сразу много табличек в зависимости от того, что записано в графе `TariffItem`. Для этого в *pandas* есть метод `groupby()`.
# In[68]:
groups = df.groupby('TariffItem')
groups.get_group('Газ').head()
# In[69]:
groups.get_group("Водоотведение")
# Список всех групп можно посмотреть так:
# In[70]:
groups.groups.keys()
# Допустим, нас интересует средний тариф по каждому из типов расходов (как мы увидим ниже, для этой таблицы считать его довольно бессмысленно, но мы всё равно попробуем — просто чтобы показать, как работает *pandas*).
# In[71]:
df.groupby('TariffItem').mean()
# ### Приводим данные в порядок
# Давайте рассмотрим повнимательнее данные, относящиеся к тарифам на газ. Здесь есть ещё один занятный столбец: `UnitOfMeasure` — единица измерения. Посмотрим, какие значения и как часто он принимает.
# In[72]:
df_gas['UnitOfMeasure'].value_counts()
# эта функция считает, сколько раз какое значение встретилось
# Как видимо, единицы измерения самые разные и вряд ли мы можем как-то легко перевести «руб/кв.м» (видимо, в квадратных метрах меряется всё-таки площадь квартиры, а не количество потребляемого газа) в «руб/куб.м», но по крайней мере «руб/1000 куб.м» и «руб/куб.м» — это что-то похожее. Давайте преобразуем первое во второе: для этого нам надо найти те строки, в которых единица измерения указана как «руб/1000 куб.м», взять для них столбец `TariffValue` и умножить все его элементы на 1000.
#
# Вот нужные нам строки:
# In[73]:
unit1000 = df_gas['UnitOfMeasure'] == 'руб/1000 куб.м'
# записали в переменную unit1000 результат проверки условия о том,
# что UnitOfMeasure == 'руб/1000 куб.м'
df_gas[unit1000]
# Можно было бы теперь взять от этой таблицы столбец `TariffValue` и присвоить ему значение его же, разделенное на 1000. Но результат может оказаться неожиданным.
# In[74]:
df_gas[unit1000]['TariffValue']
# In[75]:
df_gas[unit1000]['TariffValue'] = df_gas[unit1000]['TariffValue']/1000
# In[76]:
df_gas[unit1000]['TariffValue']
# Ничего не изменилось, да ещё и какая-то страшна красная штука вылезла. Почему так произошло? Дело в том, что взяв подмножество строк с помощью `df_gas[unit1000]`, мы могли создать либо копию исходной таблички, либо её вид (то есть просто интерфейс). Если это был вид, то приравнивание могло сработать как надо. А если копия, то приравнивание произошло к копии, а оригинальная таблица осталась неизменной. К сожалению, заранее неизвестно, что будет возвращено квадратными скобками — вид или копия — это зависит от внутренней структуры данных. Так что нужно рассчитывать на худшее.
#
# Чтобы избежать таких проблем, вместо применения несколько квадратных скобок подряд, нужно писать одни скобки, в них указывая, какие элементы нам нужны, указывая наборы строк и столбцов через запятую, как это обсуждалось выше. Например, вот так это сработает:
# In[77]:
df_gas.loc[unit1000, 'TariffValue'] = df_gas.loc[unit1000, 'TariffValue']/1000
# Тоже предупреждение, но по крайней мере результат удовлетворительный:
# In[78]:
df_gas.loc[unit1000, 'TariffValue']
# Предупреждение, кстати, возникло из-за того, что `df_gas` сам является такой копией, полученной из `df` путём выделения подмножества строк по условию. Заметим, что исходный датафрейм `df` в результате не изменился — именно об этом предупреждает нас система выше.
# In[79]:
df.loc[df['UnitOfMeasure'] == 'руб/1000 куб.м', 'TariffValue']
# Чтобы избежать дальнейших предпреждением, отделим `df_gas` от `df` окончательно:
# In[80]:
df_gas = df_gas.copy()
# Теперь нужно в тех строчках, в которых мы поменяли значение тарифа, изменить и единицу измерения, чтобы она соответствовала.
# In[81]:
df_gas.loc[unit1000, 'UnitOfMeasure'] = "руб/куб.м"
df_gas['UnitOfMeasure'].value_counts()
# Ну вот, по крайней мере 12 значений тарифа, которые можно сравнивать между собой, у нас есть. Давайте посмотрим на них повнимательнее.
# In[82]:
df_gas_kubm = df_gas[df_gas['UnitOfMeasure'] == 'руб/куб.м']
# In[83]:
df_gas_kubm.describe()
# Конечно, осмысленный здесь только один столбец, можно его запросить отдельно:
# In[84]:
df_gas_kubm.describe()['TariffValue']
# Можно даже картинку нарисовать какую-нибудь.
# In[85]:
get_ipython().run_line_magic('matplotlib', 'inline')
# In[86]:
df_gas_kubm['TariffValue'].plot.hist()
# ### Более сложные запросы и метод `query()`
# Допустим, мы с самого начала хотели выбрать из исходной таблицы те строки, в которых указан тариф на газ и в качестве единицы измерения указаны `руб/кв.м`. Это можно сделать разными способами. Например, вот так:
# In[87]:
df[ (df['TariffItem'] == "Газ") & (df['UnitOfMeasure'] == "руб/кв.м") ]
# Обратите внимание на амперсанд `&` (логическое И) и на скобки — они обязательны.
#
# Как работает эта штука? Очень просто: `df['TariffItem'] == "Газ"` — один ряд с булевскими значениями, `df['UnitOfMeasure'] == "руб/кв.м"` — другой, амперсанд `&` делает поэлементное «И» с этими двумя рядами — в результате получается ряд, в котором стоит `True` только если выполнялись оба условия — и именно по этому ряду мы и производим выборку элементов.
#
# Этот синтаксис не назовёшь лаконичным и разработчики *pandas* предложили другой — впрочем, в документации сказано, что он носит экспериментальный характер.
# In[88]:
df.query('TariffItem == "Газ" and UnitOfMeasure == "руб/куб.м"')
# ### И немного о JSON
#
# Портал открытых данных г. Москвы предоставляет доступ к своим материалам через [API](http://api.data.mos.ru/Docs) — с его помощью можно получить доступ к различным наборам данных в более автоматическом режиме (не надо ничего скачивать и разархивировать). Правда, данные возвращаются в JSON:
# In[89]:
r = requests.get("http://api.data.mos.ru/v1/datasets/1130/rows")
# In[90]:
r.json()[:3]
# Как сделать из такого JSON датафрейм? Считайте это домашним заданием!