{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "
\n", "\n", "## Открытый курс по машинному обучению\n", "Авторы материала: программист-исследователь Mail.ru Group, старший преподаватель Факультета Компьютерных Наук ВШЭ Юрий Кашницкий и Data Scientist в Segmento Екатерина Демидова. Материал распространяется на условиях лицензии [Creative Commons CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/). Можно использовать в любых целях (редактировать, поправлять и брать за основу), кроме коммерческих, но с обязательным упоминанием автора материала." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#
Тема 1. Первичный анализ данных с Pandas
" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**[Pandas](http://pandas.pydata.org)** — это библиотека Python, предоставляющая широкие возможности для анализа данных. С ее помощью очень удобно загружать, обрабатывать и анализировать табличные данные с помощью SQL-подобных запросов. В связке с библиотеками `Matplotlib` и `Seaborn` появляется возможность удобного визуального анализа табличных данных." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import pandas as pd" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Данные, с которыми работают дата саентисты и аналитики, обычно хранятся в виде табличек — например, в форматах `.csv`, `.tsv` или `.xlsx`. Для того, чтобы считать нужные данные из такого файла, отлично подходит библиотека Pandas.\n", "\n", "Основными структурами данных в Pandas являются классы `Series` и `DataFrame`. Первый из них представляет собой одномерный индексированный массив данных некоторого фиксированного типа. Второй - это двухмерная структура данных, представляющая собой таблицу, каждый столбец которой содержит данные одного типа. Можно представлять её как словарь объектов типа `Series`. Структура `DataFrame` отлично подходит для представления реальных данных: строки соответствуют признаковым описаниям отдельных объектов, а столбцы соответствуют признакам." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "---------\n", "\n", "## Демонстрация основных методов Pandas \n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Чтение из файла и первичный анализ" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Прочитаем данные и посмотрим на первые 5 строк с помощью метода `head`:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df = pd.read_csv(\"../../data/telecom_churn.csv\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df.head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "В Jupyter-ноутбуках датафреймы `Pandas` выводятся в виде вот таких красивых табличек, и `print(df.head())` выглядит хуже.\n", "\n", "Кстати, по умолчанию `Pandas` выводит всего 20 столбцов и 60 строк, поэтому если ваш датафрейм больше, воспользуйтесь функцией `set_option`:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "pd.set_option(\"display.max_columns\", 100)\n", "pd.set_option(\"display.max_rows\", 100)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "А также укажем значение параметра `presicion` равным 2, чтобы отображать два знака после запятой (а не 6, как установлено по умолчанию." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "pd.set_option(\"precision\", 2)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Посмотрим на размер данных, названия признаков и их типы**" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(df.shape)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Видим, что в таблице 3333 строки и 20 столбцов. Выведем названия столбцов:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(df.columns)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Чтобы посмотреть общую информацию по датафрейму и всем признакам, воспользуемся методом **`info`**:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "print(df.info())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "`bool`, `int64`, `float64` и `object` — это типы признаков. Видим, что 1 признак — логический (`bool`), 3 признака имеют тип `object` и 16 признаков — числовые.\n", "\n", "**Изменить тип колонки** можно с помощью метода `astype`. Применим этот метод к признаку `Churn` и переведём его в `int64`:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df[\"Churn\"] = df[\"Churn\"].astype(\"int64\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Метод **`describe`** показывает основные статистические характеристики данных по каждому числовому признаку (типы `int64` и `float64`): число непропущенных значений, среднее, стандартное отклонение, диапазон, медиану, 0.25 и 0.75 квартили." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df.describe()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Чтобы посмотреть статистику по нечисловым признакам, нужно явно указать интересующие нас типы в параметре `include`. Можно также задать `include`='all', чтоб вывести статистику по всем имеющимся признакам." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df.describe(include=[\"object\", \"bool\"])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Для категориальных (тип `object`) и булевых (тип `bool`) признаков можно воспользоваться методом **`value_counts`**. Посмотрим на распределение нашей целевой переменной — `Churn`:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df[\"Churn\"].value_counts()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "2850 пользователей из 3333 — лояльные, значение переменной `Churn` у них — `0`.\n", "\n", "Посмотрим на распределение пользователей по переменной `Area code`. Укажем значение параметра `normalize=True`, чтобы посмотреть не абсолютные частоты, а относительные." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df[\"Area code\"].value_counts(normalize=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Сортировка\n", "\n", "`DataFrame` можно отсортировать по значению какого-нибудь из признаков. В нашем случае, например, по `Total day charge` (`ascending=False` для сортировки по убыванию):" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df.sort_values(by=\"Total day charge\", ascending=False).head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Сортировать можно и по группе столбцов:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df.sort_values(by=[\"Churn\", \"Total day charge\"], ascending=[True, False]).head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Индексация и извлечение данных" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "`DataFrame` можно индексировать по-разному. В связи с этим рассмотрим различные способы индексации и извлечения нужных нам данных из датафрейма на примере простых вопросов.\n", "\n", "Для извлечения отдельного столбца можно использовать конструкцию вида `DataFrame['Name']`. Воспользуемся этим для ответа на вопрос: **какова доля нелояльных пользователей в нашем датафрейме?**" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df[\"Churn\"].mean()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "14,5% — довольно плохой показатель для компании, с таким процентом оттока можно и разориться." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Очень удобной является логическая индексация `DataFrame` по одному столбцу. Выглядит она следующим образом: `df[P(df['Name'])]`, где `P` - это некоторое логическое условие, проверяемое для каждого элемента столбца `Name`. Итогом такой индексации является `DataFrame`, состоящий только из строк, удовлетворяющих условию `P` по столбцу `Name`. \n", "\n", "Воспользуемся этим для ответа на вопрос: **каковы средние значения числовых признаков среди нелояльных пользователей?**" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df[df[\"Churn\"] == 1].mean()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Скомбинировав предыдущие два вида индексации, ответим на вопрос: **сколько в среднем в течение дня разговаривают по телефону нелояльные пользователи**?" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df[df[\"Churn\"] == 1][\"Total day minutes\"].mean()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Какова максимальная длина международных звонков среди лояльных пользователей (`Churn == 0`), не пользующихся услугой международного роуминга (`'International plan' == 'No'`)?**" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df[(df[\"Churn\"] == 0) & (df[\"International plan\"] == \"No\")][\"Total intl minutes\"].max()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Датафреймы можно индексировать как по названию столбца или строки, так и по порядковому номеру. Для индексации **по названию** используется метод **`loc`**, **по номеру** — **`iloc`**.\n", "\n", "В первом случае мы говорим _«передай нам значения для id строк от 0 до 5 и для столбцов от State до Area code»_, а во втором — _«передай нам значения первых пяти строк в первых трёх столбцах»_. \n", "\n", "В случае `iloc` срез работает как обычно, однако в случае `loc` учитываются и начало, и конец среза. Да, неудобно, да, вызывает путаницу." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df.loc[0:5, \"State\":\"Area code\"]" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df.iloc[0:5, 0:3]" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Метод `ix` индексирует и по названию, и по номеру, но он вызывает путаницу, и поэтому был объявлен устаревшим (deprecated)." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Если нам нужна первая или последняя строчка датафрейма, пользуемся конструкцией `df[:1]` или `df[-1:]`:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df[-1:]" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Применение функций: `apply`, `map` и др." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Применение функции к каждому столбцу:**" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df.apply(np.max)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Метод `apply` можно использовать и для того, чтобы применить функцию к каждой строке. Для этого нужно указать `axis=1`." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Применение функции к каждой ячейке столбца**\n", "\n", "Допустим, по какой-то причине нас интересуют все люди из штатов, названия которых начинаются на 'W'. В данному случае это можно сделать по-разному, но наибольшую свободу дает связка `apply`-`lambda` – применение функции ко всем значениям в столбце." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df[df[\"State\"].apply(lambda state: state[0] == \"W\")].head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Метод `map` можно использовать и для **замены значений в колонке**, передав ему в качестве аргумента словарь вида `{old_value: new_value}`:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "d = {\"No\": False, \"Yes\": True}\n", "df[\"International plan\"] = df[\"International plan\"].map(d)\n", "df.head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Аналогичную операцию можно провернуть с помощью метода `replace`:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df = df.replace({\"Voice mail plan\": d})\n", "df.head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Группировка данных\n", "\n", "В общем случае группировка данных в Pandas выглядит следующим образом:\n", "\n", "```\n", "df.groupby(by=grouping_columns)[columns_to_show].function()\n", "```\n", "\n", "1. К датафрейму применяется метод **`groupby`**, который разделяет данные по `grouping_columns` – признаку или набору признаков.\n", "3. Индексируем по нужным нам столбцам (`columns_to_show`). \n", "2. К полученным группам применяется функция или несколько функций." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Группирование данных в зависимости от значения признака `Churn` и вывод статистик по трём столбцам в каждой группе.**" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "columns_to_show = [\"Total day minutes\", \"Total eve minutes\", \"Total night minutes\"]\n", "\n", "df.groupby([\"Churn\"])[columns_to_show].describe(percentiles=[])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Сделаем то же самое, но немного по-другому, передав в `agg` список функций:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "columns_to_show = [\"Total day minutes\", \"Total eve minutes\", \"Total night minutes\"]\n", "\n", "df.groupby([\"Churn\"])[columns_to_show].agg([np.mean, np.std, np.min, np.max])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Сводные таблицы" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Допустим, мы хотим посмотреть, как наблюдения в нашей выборке распределены в контексте двух признаков — `Churn` и `Customer service calls`. Для этого мы можем построить **таблицу сопряженности**, воспользовавшись методом **`crosstab`**:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "pd.crosstab(df[\"Churn\"], df[\"International plan\"])" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "pd.crosstab(df[\"Churn\"], df[\"Voice mail plan\"], normalize=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Мы видим, что большинство пользователей — лояльные и пользуются дополнительными услугами (международного роуминга / голосовой почты)." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Продвинутые пользователи `Excel` наверняка вспомнят о такой фиче, как **сводные таблицы** (`pivot tables`). В `Pandas` за сводные таблицы отвечает метод **`pivot_table`**, который принимает в качестве параметров:\n", "\n", "* `values` – список переменных, по которым требуется рассчитать нужные статистики,\n", "* `index` – список переменных, по которым нужно сгруппировать данные,\n", "* `aggfunc` — то, что нам, собственно, нужно посчитать по группам — сумму, среднее, максимум, минимум или что-то ещё.\n", "\n", "Давайте посмотрим среднее число дневных, вечерних и ночных звонков для разных `Area code`:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df.pivot_table(\n", " [\"Total day calls\", \"Total eve calls\", \"Total night calls\"],\n", " [\"Area code\"],\n", " aggfunc=\"mean\",\n", ").head(10)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Преобразование датафреймов\n", "\n", "Как и многие другие вещи, добавлять столбцы в `DataFrame` можно несколькими способами." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Например, мы хотим посчитать общее количество звонков для всех пользователей. Создадим объект `total_calls` типа `Series` и вставим его в датафрейм:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "total_calls = (\n", " df[\"Total day calls\"]\n", " + df[\"Total eve calls\"]\n", " + df[\"Total night calls\"]\n", " + df[\"Total intl calls\"]\n", ")\n", "df.insert(loc=len(df.columns), column=\"Total calls\", value=total_calls)\n", "# loc - номер столбца, после которого нужно вставить данный Series\n", "# мы указали len(df.columns), чтобы вставить его в самом конце\n", "df.head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Добавить столбец из имеющихся можно и проще, не создавая промежуточных `Series`:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df[\"Total charge\"] = (\n", " df[\"Total day charge\"]\n", " + df[\"Total eve charge\"]\n", " + df[\"Total night charge\"]\n", " + df[\"Total intl charge\"]\n", ")\n", "\n", "df.head()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Чтобы удалить столбцы или строки, воспользуйтесь методом `drop`, передавая в качестве аргумента нужные индексы и требуемое значение параметра `axis` (`1`, если удаляете столбцы, и ничего или `0`, если удаляете строки):" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# избавляемся от созданных только что столбцов\n", "df = df.drop([\"Total charge\", \"Total calls\"], axis=1)\n", "\n", "df.drop([1, 2]).head() # а вот так можно удалить строчки" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "--------\n", "\n", "\n", "\n", "## Первые попытки прогнозирования оттока\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Посмотрим, как отток связан с признаком *\"Подключение международного роуминга\"* (`International plan`). Сделаем это с помощью сводной таблички `crosstab`, а также путем иллюстрации с `Seaborn` (как именно строить такие картинки и анализировать с их помощью графики – материал следующей статьи.)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# надо дополнительно установить (команда в терминале)\n", "# чтоб картинки рисовались в тетрадке\n", "# !conda install seaborn\n", "%matplotlib inline\n", "import matplotlib.pyplot as plt\n", "import seaborn as sns\n", "\n", "plt.rcParams[\"figure.figsize\"] = (8, 6)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "pd.crosstab(df[\"Churn\"], df[\"International plan\"], margins=True)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "sns.countplot(x=\"International plan\", hue=\"Churn\", data=df)\n", "plt.savefig(\"int_plan_and_churn.png\", dpi=300);" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Видим, что когда роуминг подключен, доля оттока намного выше – интересное наблюдение! Возможно, большие и плохо контролируемые траты в роуминге очень конфликтогенны и приводят к недовольству клиентов телеком-оператора и, соответственно, к их оттоку. " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Далее посмотрим на еще один важный признак – *\"Число обращений в сервисный центр\"* (`Customer service calls`). Также построим сводную таблицу и картинку." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "pd.crosstab(df[\"Churn\"], df[\"Customer service calls\"], margins=True)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "sns.countplot(x=\"Customer service calls\", hue=\"Churn\", data=df)\n", "plt.savefig(\"serv_calls__and_churn.png\", dpi=300);" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Может быть, по сводной табличке это не так хорошо видно (или скучно ползать взглядом по строчкам с цифрами), а вот картинка красноречиво свидетельствует о том, что доля оттока сильно возрастает начиная с 4 звонков в сервисный центр. " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Добавим теперь в наш DataFrame бинарный признак — результат сравнения `Customer service calls > 3`. И еще раз посмотрим, как он связан с оттоком. " ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "df[\"Many_service_calls\"] = (df[\"Customer service calls\"] > 3).astype(\"int\")\n", "\n", "pd.crosstab(df[\"Many_service_calls\"], df[\"Churn\"], margins=True)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "sns.countplot(x=\"Many_service_calls\", hue=\"Churn\", data=df)\n", "plt.savefig(\"many_serv_calls__and_churn.png\", dpi=300);" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Объединим рассмотренные выше условия и построим сводную табличку для этого объединения и оттока." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "pd.crosstab(df[\"Many_service_calls\"] & df[\"International plan\"], df[\"Churn\"])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Значит, прогнозируя отток клиента в случае, когда число звонков в сервисный центр больше 3 и подключен роуминг (и прогнозируя лояльность – в противном случае), можно ожидать около 85.8% правильных попаданий (ошибаемся всего 464 + 9 раз). Эти 85.8%, которые мы получили с помощью очень простых рассуждений – это неплохая отправная точка (*baseline*) для дальнейших моделей машинного обучения, которые мы будем строить. " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "В целом до появления машинного обучения процесс анализа данных выглядел примерно так. Прорезюмируем:\n", " \n", "- Доля лояльных клиентов в выборке – 85.5%. Самая наивная модель, ответ которой \"Клиент всегда лоялен\" на подобных данных будет угадывать примерно в 85.5% случаев. То есть доли правильных ответов (*accuracy*) последующих моделей должны быть как минимум не меньше, а лучше, значительно выше этой цифры;\n", "- С помощью простого прогноза , который условно можно выразить такой формулой: \"International plan = True & Customer Service calls > 3 => Churn = 1, else Churn = 0\", можно ожидать долю угадываний 85.8%, что еще чуть выше 85.5%\n", "- Эти два бейзлайна мы получили без всякого машинного обучения, и они служат отправной точной для наших последующих моделей. Если окажется, что мы громадными усилиями увеличиваем долю правильных ответов всего, скажем, на 0.5%, то возможно, мы что-то делаем не так, и достаточно ограничиться простой моделью из двух условий. \n", "- Перед обучением сложных моделей рекомендуется немного покрутить данные и проверить простые предположения. Более того, в бизнес-приложениях машинного обучения чаще всего начинают именно с простых решений, а потом экспериментируют с их усложнением. " ] } ], "metadata": { "anaconda-cloud": {}, "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.6.1" }, "name": "seminar02_part2_pandas.ipynb" }, "nbformat": 4, "nbformat_minor": 1 }