Datalytics

Блог Алексея Макарова о веб-аналитике, анализе данных и не только.
Группа в Фейсбук
Канал в Телеграм

Головоломка про рандомный сэмпл

Введение

Иногда чтение чужого кода бывает крайне интересным и увлекательным, будто читаешь отличный нон-фикшн. Чаще бывает, что читаешь и понимаешь, что автор вообще не думал о том, что кто-то будет читать его творение. Будто намеренно обфусцировал. Еще бы переменные называл a,b,c,d и тогда можно было бы даже не пробовать разбираться.

И вот читая один ноутбук, я наткнулся на конструкцию, которая по началу ввела в ступор:

df.loc[df.index.isin(df.sample(int(len(df)*0.1)).index), 'some_column'] = 1

Немного подумав, я сообразил что таким образом делает автор и это решение показалось достаточно интересным, хотя и не достаточно прозрачным. Задача этой строчки кода — задать значение 1 в колонке some_column для 10% случайных строк в датафрейм.

У многих может возникнуть вопрос зачем вообще может понадобится такая операция. В изначальном ноутбуке этот кусок кода использовался, чтобы создать тестовый набор данных, обладающий заданными характеристиками: 10% должны были быть единицами, еще 20% двойками и т. д.

В этой статье мы детально разберем как работает эта отдельно взятая строчка кода (да, целая статья ради строчки кода, так она меня вдохновила). На самом деле, в этой строчке заложен целый алгоритм. Мы поэтапно рассмотрим каждый этап работы этого алгоритма. Это может быть полезно для начинающих изучать Pandas (и Python в целом) в качестве примера того, что даже странные вещи при декомпозиции кажутся простыми и понятными.

Создание тестового датафрейма

Первое, что мы сделаем — это создадим тестовый датафрейм.

Сначала создаем массив numpy (numpy.array) с помощью функции np.random.randint. Эта функция позволяет создать массив numpy заданной размерности (size) и заполнить его случайными целыми числами из определенного диапазона. Нижняя граница диапазона задается в параметре low, а верхняя — в high. Создадим двумерный массив размерностью 100 на 5 (или 100 массивов по 5 элементов в каждом) со случайными числами от 0 до 9 включительно и запишем его в переменную random_array:

import numpy as np
import pandas as pd
random_array = np.random.randint(low=0,high=10,size=(100,5))
display(random_array)

Out:

Из получившегося numpy-массива random_array сделаем датафрейм df:

df = pd.DataFrame(random_array,columns=['a','b','c','d','e'])
display(df.head())

Out:

Декомпозируй это

А теперь приступим по кусочкам разбирать исходную строку кода. Немного изменим её и вместо имени колонки some_column зададим имя колонки, которая уже присутствует в нашем датафрейме df, например, колонку e:

df.loc[df.index.isin(df.sample(int(len(df)*0.1)).index), 'e'] = 1

Мы будем записывать каждый этап алгоритма в отдельную переменную, чтобы повысить читабельность кода.

1. Определение длины датафрейма

Начнем с конструкции len(df):

Тут всё просто — стандартная питоновская функция len при передаче в неё датафрейма возвращает его длину. Запишем результат в переменную count_of_rows. Ожидаемо, count_of_rows у нас будет равняться 100:

count_of_rows = len(df)
print(count_of_rows)

Out:

2. Определение размера случайной выборки

Посмотрим что происходит на следующем этапе:

Тут полученное количество строк умножается на 0.1 и затем приводится к целому числу. То есть мы получаем 1/10 от длины датафрейма df. Запишем результат в переменную sample_size. В нашем случае это будет число 10:

sample_size = int(count_of_rows*0.1)
print (sample_size)

Out:

3. Формирование случайной выборки

Дальше нас встречает функция sample:

Эта функция нужна для получения случайной выборки из датафрейма. Мы передаем в функцию sample значение sample_size, тем самым указывая, что нам нужна выборка заданного размера (10 строк) и записываем выборку в переменную sample_from_df:

sample_from_df = df.sample(sample_size)
display(sample_from_df)

Out:

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

4. Получение индекса из случайной выборки

Следующий шаг — получение индекса строк из образовавшейся выборки:

Полученные индексы запишем в переменную index_of_sample_from_df:

index_of_sample_from_df = sample_from_df.index
display(index_of_sample_from_df)

Out:

5. Создание маски для выборки

Дальше нас встречает конструкция с оператором //isin:

На самом деле, isin тут лишний, но как оптимизировать этот код, убрав из него эту конструкцию, я расскажу в конце статьи.

Итак, что же делает функция isin? Когда она применяется к индексу, то она возвращает массив булевых (True/False) значений, где True будет соответствовать тем строкам датафрейма df, индексы которых находятся в массиве index_of_sample_from_df, а False — всем остальным строкам. Запишем такой массив в переменную binary_mask:

binary_mask = df.index.isin(index_of_sample_from_df)
display(binary_mask)

Out:

6. Присвоение значения в соответствии с маской

И последний этап нашего алгоритма — присвоить значение 1 в колонке e строкам датафрейма df, которые попали в случайную 10ти-процентную выборку:

Такие строки как раз можно выделить из датафрейма df, передав битовую маску binary_mask в функцию loc.

Таким образом, функция loc позволяет нам выбрать строки в соответствии с порядковыми номерами элементов массива binary_mask, которые равняются True, а также задать колонку e, значение которой надо изменить. На словах это звучит сложно, но всё станет ясно, когда мы применим функцию и посмотрим на результат:

df.loc[binary_mask, 'e'] = 1
display(df.loc[binary_mask])

Out:

Обратите внимание на индекс этой выборки. Он совпадает с индексом случайной выборки index_of_sample_from_df. Так и задумывалось! :)

df.loc[binary_mask].index == sorted(sample_from_df.index)

Out:

Заключение

Таким образом, рассмотрев одну строчку кода мы рассмотрели небольшой алгоритм преобразования данных и углубились внутрь работы множества функций Pandas: sample, index, isin, loc.

Можно ли было проще?

В завершение рассмотрим как можно было бы сделать этот код чуть более легким. На самом деле, нет нужды в использовании конструкции df.index.isin. Хотя в некоторых случаях, от этой функции есть польза, например, при работе с мультииндексами, но об этом как-нибудь в другой раз.

Переменная index_of_sample_from_df уже содержит индексы строк, передав которые в loc вместо binary_mask мы получим выборку строк в соответствии с индексами:

df.loc[index_of_sample_from_df, 'e'] = 1
display(df.loc[index_of_sample_from_df])

Out:

Учитывая это, наш код можно было бы сократить:

df.loc[df.sample(int(len(df)*0.1)).index, 'e'] = 1

Или в более читабельном, но более длинном, виде:

count_of_rows = len(df)
sample_size = int(count_of_rows*0.1)
sample_from_df = df.sample(sample_size)
index_of_sample_from_df = sample_from_df.index
df.loc[index_of_sample_from_df, 'e'] = 1

Я уверен, что у этой задачи есть и другие решения. Если вам придёт в голову своё решение — не стесняйтесь писать в комментариях.

На этом всё. Спасибо, что читаете!

Успехов!

22 апреля   pandas   python   задачки

Работаем с API Google Drive с помощью Python

Решил написать достаточно подробную инструкцию о том как работать с API Google Drive v3 с помощью клиентской библиотеки Google API для Python. Статья будет полезна тем, кому приходится часто работать с документами в Google Drive: скачивать и загружать новые документы, удалять файлы, создавать папки.

Также я покажу пример того как можно с помощью API скачивать файлы Google Sheets в формате Excel, или наоборот: заливать в Google Drive файл Excel в виде документа Google Sheets.

Использование API Google Drive может быть полезным для автоматизации различной рутины, связанной с отчетностью. Например, я использую его для того, чтобы по расписанию загружать заранее подготовленные отчеты в папку Google Drive, к которой есть доступ у конечных потребителей отчетов.

Все примеры на Python 3.

Создание сервисного аккаунта и получение ключа

Прежде всего создаем сервисный аккаунт в консоли Google Cloud и для email сервисного аккаунта открываем доступ на редактирование необходимых папок. Не забудьте добавить в папку файлы, если их там нет, потому что файл нам понадобится, когда мы будем выполнять первый пример — скачивание файлов из Google Drive.

Я записал небольшой скринкаст, чтобы показать как получить ключ для сервисного аккаунта в формате JSON.

Установка клиентской библиотеки Google API и получение доступа к API

Сначала устанавливаем клиентскую библиотеку Google API для Python

pip install --upgrade google-api-python-client

Дальше импортируем нужные модули или отдельные функции из библиотек.

Ниже будет небольшое описание импортируемых модулей. Это для тех кто хочет понимать, что импортирует, но большинство просто может скопировать импорты и вставить в ноутбук :)

  • Модуль service_account из google.oauth2 понадобится нам для авторизации с помощью сервисного аккаунта.
  • Классы MediaIoBaseDownload и MediaFileUpload, как ясно из названий, пригодятся, чтобы скачать или загрузить файлы. Эти классы импортируются из googleapiclient.http
  • Функция build из googleapiclient.discovery позволяет создать ресурс для обращения к API, то есть это некая абстракция над REST API Drive, чтобы удобнее обращаться к методам API.
from google.oauth2 import service_account
from googleapiclient.http import MediaIoBaseDownload,MediaFileUpload
from googleapiclient.discovery import build
import pprint
import io

pp = pprint.PrettyPrinter(indent=4)

Указываем Scopes. Scopes — это перечень возможностей, которыми будет обладать сервис, созданный в скрипте. Ниже приведены Scopes, которые относятся к API Google Drive (из официальной документации):

Как видно, разные Scope предоставляют разный уровень доступа к данным. Нас интересует Scope «https://www.googleapis.com/auth/drive», который позволяет просматривать, редактировать, удалять или создавать файлы на Google Диске.

Также указываем в переменной SERVICE_ACCOUNT_FILE путь к файлу с ключами сервисного аккаунта.

SCOPES = ['https://www.googleapis.com/auth/drive']
SERVICE_ACCOUNT_FILE = '/home/makarov/Google Drive Test-fc4f3aea4d98.json'

Создаем Credentials (учетные данные), указав путь к сервисному аккаунту, а также заданные Scopes. А затем создаем сервис, который будет использовать 3ю версию REST API Google Drive, отправляя запросы из-под учетных данных credentials.

credentials = service_account.Credentials.from_service_account_file(
        SERVICE_ACCOUNT_FILE, scopes=SCOPES)
service = build('drive', 'v3', credentials=credentials)

Получение списка файлов

Теперь можно получить список файлов и папок, к которым имеет доступ сервис. Для этого выполним запрос list, выдающий список файлов, со следующими параметрами:

  • pageSize — количество результатов выдачи. Можете смело ставить максимальное значение 1000. У меня стоит 10 результатов, чтобы показать как быть, когда нужно получить результаты по следующей страницы результатов
  • параметр files() в fields — параметр, указывающий, что нужно возвращать список файлов, где в скобках указан список полей для файлов, которые нужно показывать в результатах выдачи. Со всеми возможными полями можно познакомиться в документации (https://developers.google.com/drive/api/v3/reference/files) в разделе «Valid fields for files.list». У меня указаны поля для файлов: id (идентификатор файла в Drive), name (имя) и mimeType (тип файла). Чуть дальше мы рассмотрим пример запроса с большим количеством полей
  • nextPageToken в fields — это токен следующей страницы, если все результаты не помещаются в один ответ
results = service.files().list(pageSize=10,
                               fields="nextPageToken, files(id, name, mimeType)").execute()

Получили вот такие результаты:

pp.pprint(results)
print(len(results.get('files')))

10

Получив из результатов nextPageToken мы можем передать его в следущий запрос в параметре pageToken, чтобы получить результаты следующей страницы. Если в результатах будет nextPageToken, это значит, что есть ещё одна или несколько страниц с результатами

nextPageToken = results.get('nextPageToken')
results_for_next_page = service.files().list(pageSize=10,
                               fields="nextPageToken, files(id, name, mimeType)",
                               pageToken=nextPageToken).execute()
print (results_for_next_page.get('nextPageToken'))

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

results = service.files().list(pageSize=10,
                               fields="nextPageToken, files(id, name, mimeType)").execute()
nextPageToken = results.get('nextPageToken')
while nextPageToken:
        nextPage = service.files().list(pageSize=10,
                                        fields="nextPageToken, files(id, name, mimeType, parents)",
                                        pageToken=nextPageToken).execute()
        nextPageToken = nextPage.get('nextPageToken')
        results['files'] = results['files'] + nextPage['files']
print(len(results.get('files')))

24

Дальше давайте рассмотрим какие ещё поля можно использовать для списка возвращаемых файлов. Как я уже писал выше, со всеми полями можно ознакомиться по ссылке. Давайте рассмотрим самые полезные из них:

  • parents — ID папки, в которой расположен файл/подпапка
  • createdTime — дата создания файла/папки
  • permissions — перечень прав доступа к файлу
  • quotaBytesUsed — сколько места от квоты хранилища занимает файл (в байтах)
results = service.files().list(
        pageSize=10, fields="nextPageToken, files(id, name, mimeType, parents, createdTime, permissions, quotaBytesUsed)").execute()

Отобразим один файл из результатов с расширенным списком полей. Как видно permissions содержит информацию о двух юзерах, один из которых имеет role = owner, то есть владелец файла, а другой с role = writer, то есть имеет право записи.

pp.pprint(results.get('files')[0])

Очень удобная штука, позволяющая сократить количество результатов в запросе, чтобы получать только то, что действительно нужно — это возможность задать параметры поиска для файлов. Например, мы можем задать в какой папке искать файлы, зная её id:

results = service.files().list(
    pageSize=5, 
    fields="nextPageToken, files(id, name, mimeType, parents, createdTime)",
    q="'1mCCK9QGQxLDED8_pgq2dyvkmGRXhWEtJ' in parents").execute()
pp.pprint(results['files'])

С синтаксисом поисковых запросов можно ознакомиться в документации. Ещё один удобный способ поиска нужных файлов — по имени. Вот пример запроса, где мы ищем все файлы, содержащие в названии «data»:

results = service.files().list(
    pageSize=10, 
    fields="nextPageToken, files(id, name, mimeType, parents, createdTime)",
    q="name contains 'data'").execute()
pp.pprint(results['files'])

Условия поиска можно комбинировать. Возьмем условие поиска в папке и совместим с условием поиска по названию:

results = service.files().list(
    pageSize=10, 
    fields="nextPageToken, files(id, name, mimeType, parents, createdTime)",
    q="'1uuecd6ndiZlj3d9dSVeZeKyEmEkC7qyr' in parents and name contains 'data'").execute()
pp.pprint(results['files'])

Скачивание файлов из Google Drive

Теперь рассмотрим как скачивать файлы из Google Drive. Для этого нам понадобится создать запрос request для получения файла. После этого задаем интерфейс fh для записи в файл с помощью библиотеки io, указав в filename название файла (таким образом, можно сохранять файлы из Google Drive сразу с другим названием). Затем создаем экземпляр класса MediaIoBaseDownload, передав наш интерфейс для записи файла fh и запрос для скачивания файла request. Следующим шагом скачиваем файл по небольшим кусочкам (чанкам) с помощью метода next_chunk.

Если из предыдущего описания вам мало что понятно, не запаривайтесь, просто укажите свой file_id и filename, и всё у вас будет в порядке.

file_id = '1HKC4U1BMJTsonlYJhUKzM-ygrIVGzdBr'
request = service.files().get_media(fileId=file_id)
filename = '/home/makarov/File.csv'
fh = io.FileIO(filename, 'wb')
downloader = MediaIoBaseDownload(fh, request)
done = False
while done is False:
    status, done = downloader.next_chunk()
    print ("Download %d%%." % int(status.progress() * 100))

Файлы Google Sheets или Google Docs можно конвертировать в другие форматы, указав параметр mimeType в функции export_media (обратите внимание, что в предыдущем примере скачивания файла мы использоали другую функцию get_media). Например, файл Google Sheets можно конвертировать и скачать в виде файла Excel.

file_id = '10MM2f3V98wTu7GsoZSxzr9hkTGYvq_Jfb2HACvB9KjE'
request = service.files().export_media(fileId=file_id,
                                             mimeType='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
filename = '/home/makarov/Sheet.xlsx'
fh = io.FileIO(filename, 'wb')
downloader = MediaIoBaseDownload(fh, request)
done = False
while done is False:
    status, done = downloader.next_chunk()
    print ("Download %d%%." % int(status.progress() * 100))

Затем скачанный файл можно загнать в датафрейм. Это достаточно простой способ получить данные из Google Sheet в pandas-dataframe, но есть и другие способы, например, воспользоваться библиотекой gspread.

import pandas as pd
df = pd.read_excel('/home/makarov/Sheet.xlsx')
df.head(5)

Загрузка файлов и удаление в Google Drive

Рассмотрим простой пример загрузки файла в папку. Во-первых, нужно указать folder_id — id папки (его можно получить в адресной строке браузера, зайдя в папку, либо получив все файлы и папки методом list). Также нужно указать название name, с которым файл загрузится на Google Drive. Это название может быть отличным от исходного названия файла. Параметры folder_id и name передаем в словарь file_metadata, в котором задаются метаданные загружаемого файла. В переменной file_path указываем путь к файлу. Создаем объект media, в котором будет указание по какому пути находится загружаемый файл, а также указание, что мы будем использовать возобновляемую загрузку, что позволит нам загружать большие файлы. Google рекомендует использовать этот тип загрузки для файлов больше 5 мегабайт. Затем выполняем функцию create, которая позволит загрузить файл на Google Drive.

folder_id = '1mCCK9QGQxLDED8_pgq2dyvkmGRXhWEtJ'
name = 'Script_2.py'
file_path = '/home/makarov/Script.py'
file_metadata = {
                'name': name,
                'parents': [folder_id]
            }
media = MediaFileUpload(file_path, resumable=True)
r = service.files().create(body=file_metadata, media_body=media, fields='id').execute()
pp.pprint(r)

Как видно выше, при вызове функции create возвращается id созданного файла. Можно удалить файл, вызвав функцию delete. Но мы этого делать не будет так как файл понадобится в следующем примере

service.files().delete(fileId='18Wwvuye8dOjCZfJzGf45yQvB87Lazbzu').execute()

Сервисный аккаунт может удалить ли те файлы, которые были с помощью него созданы. Таким образом, даже если у сервисного аккаунта есть доступ на редактирование папки, то он не может удалить файлы, созданные другими пользователями. Понять что файл был создан помощью сервисного аккаунта можно задав поисковое условие с указанием email нашего сервисного аккаунта. Узнать email сервисного аккаунта можно вызвав атрибут signer_email у объекта credentials

print (credentials.signer_email)
results = service.files().list(
    pageSize=10, 
    fields="nextPageToken, files(id, name, mimeType, parents, createdTime)",
    q="'namby-pamby@tensile-verve-232214.iam.gserviceaccount.com' in owners").execute()
pp.pprint(results['files'][0:3])

Дальше — больше. С помощью API Google Drive мы можем загрузить файл с определенным mimeType, чтобы Drive понял к какому типу относится файл и предложил соответствующее приложение для его открытия.

folder_id = '1mCCK9QGQxLDED8_pgq2dyvkmGRXhWEtJ'
name = 'Sample data.csv'
file_path = '/home/makarov/sample_data_1.csv'
file_metadata = {
                'name': name,
                'mimeType': 'text/csv',
                'parents': [folder_id]
            }
media = MediaFileUpload(file_path, mimetype='text/csv', resumable=True)
r = service.files().create(body=file_metadata, media_body=media, fields='id').execute()
pp.pprint(r)

Но ещё более классная возможность — это загрузить файл одного типа с конвертацией в другой тип. Таким образом, мы можем залить csv файл из примера выше, указав для него тип Google Sheets. Это позволит сразу же конвертировать файл для открытия в Гугл Таблицах. Для этого надо в словаре file_metadata указать mimeType «application/vnd.google-apps.spreadsheet».

folder_id = '1mCCK9QGQxLDED8_pgq2dyvkmGRXhWEtJ'
name = 'Sheet from csv'
file_path = '/home/makarov/notebooks/sample_data_1.csv'
file_metadata = {
                'name': name,
                'mimeType': 'application/vnd.google-apps.spreadsheet',
                'parents': [folder_id]
            }
media = MediaFileUpload(file_path, mimetype='text/csv', resumable=True)
r = service.files().create(body=file_metadata, media_body=media, fields='id').execute()
pp.pprint(r)

Таким образом, загруженный нами CSV-файл будет доступен как Гугл Таблица:

Ещё одна часто необходимая функция — это создание папок. Тут всё просто, создание папки также делается с помощью метода create, надо только в file_metadata указать mimeType «application/vnd.google-apps.folder»

folder_id = '1uuecd6ndiZlj3d9dSVeZeKyEmEkC7qyr'
name = 'New Folder'
file_metadata = {
    'name': name,
    'mimeType': 'application/vnd.google-apps.folder',
    'parents': [folder_id]
}
r = service.files().create(body=file_metadata,
                                    fields='id').execute()
pp.pprint(r)

Заключение
Все содержимое этой статьи также представлено в виде ноутбука для Jupyter Notebook.

В этой статье мы рассмотрели лишь немногие возможности API Google Drive, но одни из самых необходимых:

  • Просмотр списка файлов
  • Скачивание документов из Google Drive (в том числе, скачивание с конвертацией, например, документов Google Sheets в формате Excel)
  • Загрузка документов в Google Drive (также как и в случае со скачиванием, с возможностью конвертации в нативные форматы Google Drive)
  • Удаление файлов
  • Создание папок

Вступайте в группу на Facebook и подписывайтесь на мой канал в Telegram, там публикуются интересные статьи про анализ данных и не только.

Успехов!

20 февраля   api   python   автоматизация

Большая подборка полезных ссылок про Pandas

Я достаточно долго веду группу в Facebook и канал в Telegram, посвященные анализу данных на Python и за это время накопилось множество полезных ссылок на материалы о библиотеке Pandas. Решил, что будет здорово, если все они будут в одном месте, а не разбросаны по каналу/группе. Поэтому завёл табличку в Notion, где все ссылки протегированы и разбиты по языку (ru, eng). Также в документе прикреплена ссылка, с помощью которой можно порекомендовать материал, будь то ваша авторская статья или просто понравившийся материал с просторов интернета. Материал по pandas появится в подборке и с высокой вероятностью может быть опубликован в канале и группе.

Встречайте: подборка полезных материалов про библиотеку Pandas

Лайк, шер, пускай больше людей приобщится к использованию Pandas!

Как в Pandas разбить одну колонку на несколько

Введение в задачу

Решил начать рассматривать нетривиальные кейсы в Pandas, с которыми иногда сталкиваюсь при работе с данными. Опять же, нетривиальные они только на мой взгляд, потому что какие-то вещи я решаю впервые и они заставляют немного подумать :) Возможно, такие небольшие кейсы помогут аналитикам, если они увидят в своих «затыках» что-то похожее. Также, я не претендую на абсолютную правильность или универсальность решения. Таким образом, у подобной задачи может быть несколько правильных решений. Я буду рад, если в комментариях вы будете предлагать свои решения. Касательно универсальности решения, тут я имею в виду, что решение может быть применимо к конкретному датасету, но при этом может не работать, если датасет будет иметь какие-то существенные видоизменения.

Первой из задач, которой я бы хотел поделиться, будет разбивка колонки датафрейма на несколько отдельных колонок с добавление к существующему датафрейму. Казалось бы просто. Давайте посмотрим решение.

Итак, у меня есть вот такой dataframe:

Скачать данные для датафрейма в csv можно тут.

Выведем первое значение из колонки new_values, чтобы лучше понять что же нам надо сделать:

Как видно, значения разделены знаком переноса строки (\n), а также каждое значение представлено в виде ключ=значение (например, ключом выступает date_start, а значением 2018-12-04).

Задача состоит в том, чтобы привести датафрейм к вот такому виду:

Решение №1

Итак, первое решение будет простым и достаточно коротким.

Сначала, нам понадобится пандосовская функция str.split (https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.str.split.html). Она просто разбивает столбец на список на основании разделителя

df['new_values'].str.split('\n')

Вот как выглядит отдельное значение:

df['new_values'].str.split('\n')[0]

Но у функции str.split есть замечательный параметр expand=True, позволяющий сразу сделать разбиение на колонки и получить датафрейм

new_df = df['new_values'].str.split('\n',expand=True)
new_df

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

new_df.columns=['date_start','component','customer']
new_df

Затем нам нужно избавиться в колонках от названия ключа и знака «равно». Сделаем это простым циклом с функцией str.replace (https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.str.replace.html):

for column in new_df.columns:
    new_df[column] = new_df[column].str.replace(column+'=','')
new_df

Осталось только соединить два датафрейма (исходный df и new_df) с помощью функции pd.concat (https://pandas.pydata.org/pandas-docs/version/0.23.4/generated/pandas.concat.html). Обратите внимание на параметр axis=1, который позволяет соединить датафреймы по столбцам, а не по строкам

final_df = pd.concat([df,new_df],axis=1)
final_df

Ну и выкинем из получившегося датафрейма ненужный нам теперь столбец new_values с помощью drop (https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.drop.html):

final_df = final_df.drop('new_values',axis=1)
final_df

Итоговый код для решения задачи выглядит так:

df = pd.read_csv('sample_data_1.csv')
new_df = df['new_values'].str.split('\n',expand=True)
new_df.columns=['date_start','component','customer']
for column in new_df.columns:
    new_df[column] = new_df[column].str.replace(column+'=','')
final_df = pd.concat([df,new_df],axis=1).drop('new_values',axis=1)

Решение №2

Другой способ, который я покажу, является более универсальным.

Представим, что исходный датафрейм отличается от того, что я показывал выше.

Скачать данные для датафрейма в csv можно тут.

Этот датафрейм отличается тем, что в некоторых строках колонки new_values отсутствует параметр date_start. Из-за этого при попытке сделать str.split с параметром expand=True мы получим вот такой датафрейм:

df['new_values'].str.split('\n',expand=True)

В этом случае, мы не сможем просто пройтись циклом по колонкам и получить нужные значения, так как значения по ключу не всегда однозначно находятся в одной колонке, а могут быть разбросаны по нескольким. Такая же ситуация могла бы быть, если бы перечисление значений шло не в одном порядке, а по-разному. Например в одной строке date_start=2018-12-28\ncomponent=abc\ncustomer=59352, а в другой component=abc\ncustomer=22080\ndate_start=2018-12-18

Чтобы обойти эту проблему нам нужно преобразовать эти данные в такую структуру, которую было бы удобно запихнуть в датафрейм и pandas сам бы смог разделить данные по нужным колонкам, опираясь на структуру данных. Одной из таких структур может быть список словарей (list of dicts). В виде списка словарей данные должны выглядеть вот так:

Когда мы получим такую структуру, то потом сможем преобразовать её в датафрейм, а полученный датафрейм соединить с исходным (как мы уже это делали выше с помощью функции pd.concat).

Итак, первое что мы сделаем, это разобъем колонку с помощью уже знакомой функции str.split, но без параметра expand=True. Это позволит нам сделать отдельный series, содержащий списки:

s = df['new_values'].str.split('\n')
s

Затем каждый из списков нам нужно преобразовать к словарю. То есть совершить вот такое преобразование:

Чтобы лучше понять суть преобразования покажу на примере одного списка s[0], а затем сделаем функцию, которую применим к каждому элементу в series.

Сначала сделаем внутри списка s[0] вложенные списки, сделав split каждого из элемента списка по знаку «равно» через list comprehension (подробнее про list comprehension можно прочитать в статье, скажу только, что это очень удобно):

splited_items = [a.split('=') for a in s[0]]
splited_items

Затем полученный список splited_items надо переделать в словарь. Для этого сделаем несложное преобразование — создадим словарь, после чего пройдемся циклом по каждому вложенному списку и назначим нулевой элемент вложенного списка ключом словаря, а первый элемент — значением по ключу:

dictionary = {}
for item in splited_items:
    key = item[0]
    value = item[1]
    dictionary[key] = value
dictionary

Сделаем функцию, которая делает вышеописанное преобразование:

def convertToDict(x):
    splited_items = [a.split('=') for a in x]
    dictionary = {}
    for item in splited_items:
        key = item[0]
        value = item[1]
        dictionary[key] = value
    return dictionary

После этого можно применить функцию к серии s с помощью apply (https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.apply.html):

s = s.apply(lambda x: convertToDict(x))
s

Затем применим функцию pd.Series к каждому из словарей, таким образом преобразовав каждый словарь в series, а вместе последовательность series образует датафрейм:

new_df = s.apply(pd.Series)
new_df

Следующим шагом соединим датафрейм new_df с исходным df и уберем столбец с помощью функции drop:

final_df = pd.concat([df,new_df],axis=1).drop('new_values',axis=1)
final_df

Итоговый код для обработки данных выглядит вот так:

def convertToDict(x):
    splited_items = [a.split('=') for a in x]
    dictionary = {}
    for item in splited_items:
        key = item[0]
        value = item[1]
        dictionary[key] = value
    return dictionary

df = pd.read_csv('sample_data_2.csv')
s = df['new_values'].str.split('\n')
s = s.apply(lambda x: convertToDict(x))
new_df = s.apply(pd.Series)
final_df = pd.concat([df,new_df],axis=1).drop('new_values',axis=1)

И в качестве бонуса, можно сделать код более удобочитаемым с помощью method chaining:

df = pd.read_csv('sample_data_2.csv')
new_df = df['new_values'].str.split('\n').\
    apply(lambda x: convertToDict(x)).\
    apply(pd.Series)
final_df = pd.concat([df,new_df],axis=1).drop('new_values',axis=1)

Решение Романа Шапкова

UPD от 30.01.2019:
Читатель Роман решил эту задачу с помощью регулярных выражений

import pandas as pd
import re

df = pd.read_csv('sample_data_2.csv')

pattern_date = '(\d{4}-\d{2}-\d{2})'
pattern_ncomp = 'component=(\w*)'
pattern_ncust = 'customer=(\d*)'

def find_pattern(string, pattern):

    """
    аргументы: string - текстовая строка для поиска
    pattern - шаблон регулярного выражения

    функция осуществляет поиск шаблона "pattern" в строке "string" используя правила регулярных выражений(RegExp).
    Если шаблон найден - возвращает значение, иначе - возвращает None
    """
    if re.search(pattern, string):
        return re.search(pattern, string).group(1)

df['start_date'] = df['new_values'].apply(lambda x: find_pattern(x,pattern_date))
df['component'] = df['new_values'].apply(lambda x: find_pattern(x,pattern_ncomp))
df['customer'] = df['new_values'].apply(lambda x: find_pattern(x,pattern_ncust))

df

Заключение

На этом всё. Надеюсь, этот пример решения задачи кто-то найдет интересным и научится из него каким-то новым приёмам в своей работе. Это причина, по который я решил выкладывать такие примеры — не просто рассказывать про то, как работают функции в pandas или предлагать готовые решения, а показать последовательность действий и методологию решения задачи, чтобы читатели могли перенести части этого решения в свои проекты.

Если пост оказался полезным, то буду рад отзывам в комментариях, это будет для меня сигналом, что подобные вещи надо продолжать делать. А ещё лучше пишите как бы вы решили такую задачу, скидывайте примеры решений.

Вступайте в группу на Facebook и подписывайтесь на мой канал в Telegram, там публикуются интересные статьи про анализ данных и не только.

Как использовать Google BigQuery с помощью Python

Что такое Google BigQuery

Google BigQuery — это безсерверное масштабируемое хранилище данных. Использование безсерверного (облачного) решения — хорошая идея, если у вас нет серьезного бэкграунда в администрировании баз данных. Такой подход позволяет сосредоточиться только на анализе данных, и не думать об инфраструктуре хранения данных (шардировании, индексации, компрессии). BigQuery поддерживает стандартный диалект SQL, так что любой, кто когда-либо пользовался SQLными СУБД, с легкостью может начать им пользоваться.

Начало работы с Google BigQuery и создание ключа для сервисного аккаунта

Я не буду подробно объяснять о том как начать работу с Google Cloud Platform и завести первый проект, об этом хорошо написано в статье Алексея Селезнева в блоге Netpeak. Когда у нас уже есть проект в Cloud Platform с подключенным API BigQuery, следующим шагом нужно добавить учетные данные.

  1. Переходим в раздел «API и сервисы > Учетные данные»:
  1. Нажимаем «Создать учетные данные > Ключ сервисного аккаунта»
  1. Заполняем параметры: пишем название сервисного аккаунта; выбираем роль (как показано на скриншоте ниже, но роль может зависеть от уровня доступов, которые вы хотите предоставить сервисному аккаунту); выбираем тип ключа JSON; нажимаем «Создать»
  1. Переходим в раздел «IAM и администрирование > Сервисные аккаунты»
  1. В колонке «Действия» для созданного нами сервисного аккаунта выбираем «Создать ключ»
  1. Выбираем формат ключа «JSON» и нажимаем создать, после чего будет скачан JSON-файл, содержащий авторизационные данные для аккаунта

Полученный JSON с ключом нам понадобится в дальнейшем. Так что не теряем.

Использование pandas-gbq для импорта данных из Google BiqQuery

Первый способ, с помощью которого можно загружать данные из BigQuery в Pandas-датафрейм — библиотека pandas-gbq. Эта библиотека представляет собой обертку над API Google BigQuery, упрощающая работу с данными BigQuery через датафреймы.
Сначала нужно поставить библиотку pandas-gbq. Это можно сделать через pip или conda:

pip install pandas-gbq
conda install pandas-gbq -c conda-forge

Я решил рассмотреть основы работы с Google BigQuery с помощью Python на примере публичных датасетов. В качестве интересного примера возьмем датасет с данными о вопросах на сервисе Stackoverflow.

import pandas as pd
from google.oauth2 import service_account

# Прописываем адрес к файлу с данными по сервисному аккаунту и получаем credentials для доступа к данным
credentials = service_account.Credentials.from_service_account_file(
    'my-bq-project-225910-6e534ba48078.json')

# Формируем запрос и получаем количество вопросов с тегом "pandas", сгруппированные по дате создания
query = '''
SELECT DATE(creation_date) as date, COUNT(id) as questions
FROM
  [bigquery-public-data:stackoverflow.posts_questions]
WHERE tags LIKE '%pandas%'
GROUP BY
  date
'''

# Указываем идентификатор проекта
project_id = 'my-bq-project-225910'

# Выполняем запрос с помощью функции ((https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.read_gbq.html read_gbq)) в pandas, записывая результат в dataframe
df = pd.read_gbq(query, project_id=project_id, credentials=credentials)

display(df.head(5))

Важное примечание: По умолчанию функция read_gbq в pandas использует диалект legacy SQL. Для того, чтобы задать диалект standart SQL нужно воспользоваться параметром dialect:

df = pd.read_gbq(query, project_id=project_id, credentials=credentials, dialect='standard')

Дальше немного поиграем с обработкой данных. Выделим из даты месяц и год.

df['month'] =  df['date'].values.astype('datetime64[M]') # Создаем новый столбец с месяцем
df['year'] =  df['date'].values.astype('datetime64[Y]') # Создаем новый столбец с годом

# Отображаем один день с максимальным количеством вопросов
display(df.sort_values('questions',ascending=False).head(1))

Cгруппируем данные по годам и месяцам и запишем полученные данные в датафрейм stats

stats = df.groupby(['year','month'],as_index=False).agg({'questions':'sum'}) # Группируем данные по году и месяцу, используя в качестве агрегирующей функции сумму количества вопросов

display(stats.sort_values('questions',ascending=False).head(5))

Посчитаем суммарное количество вопросов в год, а также среднее количество запросов в месяц для каждого года, начиная с января 2013 и по август 2018 (последний полный месяц, который был в датасете на момент написания статьи). Запишем полученные данные в новый датафрейм year_stats

year_stats = stats[(stats.month >= '2013-01-01') & (stats.month < '2018-09-01')].groupby(['year'],as_index=False).agg({'questions':['mean','sum']})

display(year_stats)

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

year_stats['estimate'] = year_stats[('questions','mean')]*12

display(year_stats)

На основе данных от StackOverflow, можно сказать, что популярность pandas из года в год растёт хорошими темпами :)

Запись данных из dataframe в Google BigQuery

Следующим шагом, я хотел бы показать как записывать свои данные в BigQuery из датафрейма с помощью pandas_gbq.

В датафрейме year_stats получился multiindex из-за того, что мы применили две агрегирующие функции (mean и sum). Чтобы нормально записать такой датафрейм в BQ надо убрать multiindex. Для этого просто присвоим dataframe новые колонки.

year_stats.columns = ['year','mean_questions','sum_questions','estimate']

После этого применим к датафрейму year_stats функцию to_gbq. Параметр if_exists = ’fail’ означает, что при существовании таблицы с таким именем передача не выполнится. Также в значении этого параметра можно указать append и тогда к существующим данным в таблице будут добавлены новые. В параметре private_key указываем путь к ключу сервисного аккаунта.

year_stats.to_gbq('my_dataset.my_table', project_id=project_id, if_exists='fail', private_key='my-bq-project-225910-6e534ba48078.json')

После выполнения функции в BigQuery появятся наши данные:

Итак, мы рассмотрели импорт и экспорт данных в BiqQuery из Pandas’овского датафрейма с помощью pandas-gbq. Но pandas-gbq разрабатывается сообществом энтузиастов, в то время как существует официальная библиотека для работы с Google BigQuery с помощью Python. Основные сравнения pandas-gbq и официальной библиотеки можно посмотреть тут.

Использование официальной библиотеки для импорта данных из Google BiqQuery

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

from google.cloud import bigquery

client = bigquery.Client.from_service_account_json(
    '/home/makarov/notebooks/my-bq-project-225910-6e534ba48078.json')

sql = '''
    SELECT DATE(creation_date) as date, DATE_TRUNC(DATE(creation_date), MONTH) as month, DATE_TRUNC(DATE(creation_date), YEAR) as year, COUNT(id) as questions
FROM
  `bigquery-public-data.stackoverflow.posts_questions`
WHERE tags LIKE '%pandas%'
GROUP BY
  date, month, year
    '''

project_id = 'my-bq-project-225910'

df2 = client.query(sql, project=project_id).to_dataframe()

display(df2.head(5))

Как видно, по простоте синтаксиса, официальная библиотека мало чем отличается от использования pandas-gbq. При этом я заметил, что некоторые функции, (например, date_trunc), не работают через pandas-gbq. Так что я предпочитаю использовать официальное Python SDK для Google BigQuery.

По умолчанию в официальном SDK используется диалект Standard SQL, но можно использовать Legacy SQL. С примером использования Legacy SQL можно ознакомиться по ссылке.

Чтобы импортировать данные из датафрейма в BigQuery нужно установить pyarrow. Эта библиотека обеспечит унификацию данных в памяти, чтобы dataframe соответствовал структуре данных, нужных для загрузки в BigQuery.

# Создаем тестовый dataframe
df = pd.DataFrame(
    {
        'my_string': ['a', 'b', 'c'],
        'my_int64': [1, 2, 3],
        'my_float64': [4.0, 5.0, 6.0],
    }
)
dataset_ref = client.dataset('my_dataset_2') # Определяем датасет
dataset = bigquery.Dataset(dataset_ref)
dataset = client.create_dataset(dataset) # Создаем датасет

table_ref = dataset_ref.table('new_table') # Определяем таблицу (при этом не создавая её)

result = client.load_table_from_dataframe(df, table_ref).result() # Тут данные из датафрейма передаются в таблицу BQ, при этом таблица создается автоматически из определенной в предыдущей строке

Проверим, что наш датафрейм загрузился в BigQuery:

Прелесть использования нативного SDK вместо pandas_gbq в том, что можно управлять сущностями в BigQuery, например, создавать датасеты, редактировать таблицы (схемы, описания), создавать новые view и т. д. В общем, если pandas_gbq — это скорее про чтение и запись dataframe, то нативное SDK позволяет управлять всей внутренней кухней

Ниже привожу простой пример как можно изменить описание таблицы

table = client.get_table(table_ref) # Получаем данные о таблице

table.description = 'Моя таблица' # Задаем новый дескрипшн для таблицы

table = client.update_table(table, ['description'])  # Обновляем таблицу, передав новый дескрипшн через API

print(table.description)

Также с помощью нативного Python-SDK можно вывести все поля из схеме таблицы, отобразить количество строк в таблице

for schema_field in table.schema: # Для каждого поля в схеме
    print (schema_field) # Печатаем поле схемы

print(table.num_rows) # Отображаем количество строк в таблице

Если таблица уже создана, то в результате новой передачи датафрейма в существующую таблицу будут добавлены строки

result = client.load_table_from_dataframe(df, table_ref).result() # Передаем данные из dataframe в BQ-table
table = client.get_table(table_ref) # Заново получаем данные о таблице
print(table.num_rows) # Отображаем новое количество строк

Заключение

Вот так, с помощью несложных скриптов, можно передавать и получать данные из Google BigQuery, а также управлять различными сущностями (датасетами, таблицами) внутри BigQuery.

Успехов!

Вступайте в группу на Facebook и подписывайтесь на мой канал в Telegram, там публикуются интересные статьи про анализ данных и не только.

С чего аналитику начать изучение Python

Оригинал поста подготовлен для Телеграм-канала «Интернет-аналитика»

В первую очередь, тем кто не знаком с Python я бы рекомендовал установить дистрибутив Anaconda. Это удобнее, чем устанавливать чистый Python, т. к. Anaconda содержит большинство пакетов, необходимых для анализа данных.

Следующий шаг — выбор среды разработки. Для анализа данных лучше всего подойдет Jupyter Notebook. Эта среда разработки устанавливается вместе с Anaconda. Вот простой туториал по работе с Jupyter Notebook.

Многие аналитики начинают учить Python, но быстро бросают. Чаще всего это происходит потому, что люди начинают изучение с синтаксиса и простых абстрактных примеров. По началу это может быть интересным, но потом надоедает. Лучше всего начинать с решения легких практических задач, автоматизируя рутину и сразу же ощущая как Python улучшает вашу жизнь?

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

На мой взгляд, самая главная библиотека для аналитика — Pandas. Если вы хотите быстро очищать, трансформировать, агрегировать, объединять и вообще всячески манипулировать табличными данными, то Pandas будет в этом надежным помощником. Про Pandas есть хорошая статья в блоге khashtamov.com (и весь блог годный!). Также советую почитать более хардкорную статью ребят из ODS.

Начните использовать Python с решения какой-то простой практической задачи, например, выгрузки данных через API Яндекс.Метрики и сохранения полученных данных в Excel. Узнать как начать работать с API Яндекс.Метрики можно из моей статьи.

Данные из Яндекс.Метрики в Python можно получить с помощью вот такого простого сниппета. Начните с получения токена для API Яндекс.Метрики и выполните этот код в Jupyter Notebook. Вы удивитесь как это просто!

Дальше можно усложнять скрипт, например, сделать несколько различных запросов и выгрузить данные на несколько вкладок в одном Excel-файле. Или выгрузить из Метрики данные с множеством dimensions и попробовать на их основе сделать в Pandas несколько таблиц с группировкой с помощью функции groupby, а также сводные таблицы с помощью функции pivot_table.

Успехов в автоматизированной борьбе с рутиной!

Вступайте в группу на Facebook и подписывайтесь на мой канал в Telegram, там публикуются интересные статьи про анализ данных и не только.

Группа в Facebook про анализ данных с помощью Python

Создал группу, посвященную анализу данных с помощью Python. Не столько про машинное обучение, сколько про подготовку/очистку/предобработку данных, использование Python для получения данных из API, парсинга веб-сайтов, автоматизации различной рутины. Группа предназначена для обмена опытом, взаимопомощи, поиска единомышленников для проектов. Приветствуются вопросы, обсуждения, ссылки на полезные материалы и инструменты. Постараюсь кидать максимум полезностей как для новичков, так и для проскилленных ребят.

https://www.facebook.com/groups/pydata/

Делаем сессии из лога событий с помощью Pandas

Предыстория

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

Структура данных лога представляет собой:

  • id — порядковый номер события в логе
  • user_id — уникальный идентификатор пользователя, совершившего событие (при решении реальной задачи анализа лога в качестве user_id может выступать IP-адрес пользователя или, например, уникальный идентификатор cookie-файла)
  • date_time — время совершения события
  • page — страница, на которую перешел пользователь (для решения задачи эта колонка не несет никакой пользы, я привожу её для наглядности)

Задача состоит в том, чтобы разбить последовательность событий (просмотров страниц) на вот такие блоки, которые будут сессиями:

Говоря «разбить», я не имею в виду разделить и сохранить в виде разных массивов данных или ещё что-то подобное. Тут речь идёт о том, чтобы каждому событию сопоставить номер сессии, в которую это событие входит.

Критерий сессии в моем случае — она живет полчаса после предыдущего совершенного события. Например, в строке 6 пользователь перешел на страницу /catalog в 8:21, а следующую страницу /index (строка 7) посмотрел в 9:22. Разница между просмотром страниц составляет 1 час 1 минуту, а значит эти просмотры относятся к разным сессиям этого пользователя.

Все это дело я буду делать на Питоне при помощи Pandas в Jupyter Notebook. Вот ссылка на ноутбук.

Алгоритм

Итак, у нас есть ’event_df’ — это датафрейм, в котором содержатся данные о событиях в привязке к пользователям:

1 События сгенерированные разными пользователями идут в хронологическом порядке. Для удобства отсортируем их по user_id, тогда события каждого пользователя будут идти последовательно:

event_df = event_df.sort_values('user_id')

2 В колонке ’diff’ для каждого события отдельного пользователя посчитаем разницу между временем посещения страницы и временем посещения предыдущей страницы. Если страница была первой для пользователя, то значение в колонке ’diff’ будет NaT, т. к. нет предыдущего значения

Обратите внимание, что совместно с функцией diff я использую для группировки пользователей groupby, чтобы считать разницу между временными метками отдельных пользователей. Без использования groupby мы бы просто брали все временные метки и считали бы между ними разницу, что было бы неправильно, так как события относятся к разным пользователям.

event_df['diff'] = event_df.groupby('user_id')['date_time'].diff(1)

Кое-что уже проклевывается. Мы нашли такие события, которые будут начальными точками для сессий:

3 Из основного датафрейма ’event_df’ создадим вспомогательный датафрейм ’session_start_df’. Этот датафрейм будет содержать события, которые будут считаться первыми событиями сессий. К таким событиям относятся все события, которые произошли спустя более чем 30 минут после предыдущего, либо события, которые были первыми для пользователя (NaT в колонке ’diff’).

Также создадим во вспомогательном датафрейме колонку ’session_id’, которая будет содержать в себе id первого события сессии. Она пригодится, чтобы корректно отобразить идентификатор сессии, когда будем объединять данные из основного и вспомогательного датафреймов.

sessions_start_df = event_df[(event_df['diff'].isnull()) | (event_df['diff'] > '1800 seconds')]
sessions_start_df['session_id'] = sessions_start_df['id']

Вспомогательный датафрейм ’session_start_df’ выглядит так:

4 С помощью функции merge_asof объединим между собой данные основного и вспомогательного датафреймов. Эта функция позволяет объединить данные двух датафреймов схожим образом с левым join’ом, но не по точному соответствию ключей, а по ближайшему. Примеры и подробности в документации.

Для корректной работы функции merge_asof оба датафрейма должны быть отсортированы по ключу, на основе которого будет происходить объединение. В нашем случае это колонка ’id’.

Обратите внимание, что из датафрейма ’session_start_df’ я выбираю только колонки ’id’, ’user_id’ и ’session_id’, так как остальные колонки особо не нужны.

event_df = event_df.sort_values('id')
sessions_start_df = sessions_start_df.sort_values('id')
event_df = pd.merge_asof(event_df,sessions_start_df[['id','user_id','session_id']],on='id',by='user_id')

В итоге получаем вот такой распрекрасный объединенный датафрейм, в котором в колонке ’session_id’ указан уникальный идентификатор сессии:

Дополнительные манипуляции

1 Найдем события, которые были первыми в сессиях. Это будет полезно, если мы захотим определить страницы входа.

Обнаружить эти события предельно просто: их идентификаторы будут равны идентификаторам сессии. Для этого создадим колонку ’is_first_event_in_session’, в которой сравним между собой значения колонок ’id’ и ’session_id’.

event_df['is_first_event_in_session'] = event_df['id'] == event_df['session_id']

2 Вычислим время, проведенное на странице, руководствуясь временем посещения следующей страницы

Для этого сначала считаем разницу между предыдущей и следующей страницей внутри сессии. Мы уже делали такое вычисление, когда считали разницу между временем посещения страниц пользователем. Только тогда мы группировали по ’user_id’, а теперь будем по ’session_id’.

event_df['time_on_page'] = event_df.groupby(['session_id'])['date_time'].diff(1)

Но diff со смещением в 1 строку считает разницу между посещением последующей страницы относительно предыдущей, поэтому время пребывания на предыдущей странице будет записано в строку следующего события:

Нам нужно сдвинуть значение столбца ’time_on_page’ на одну строку вверх внутри отдельно взятой сессии. Для этого нам пригодится функция shift.

event_df['time_on_page'] = event_df.groupby(['session_id'])['time_on_page'].shift(-1)

Получили то, что нужно:

Значения в столбце ’time_on_page’ имеют специфический тип datetime64, который не всегда удобен для арифметических операций, поэтому преобразуем ’time_on_page’ в секунды.

event_df['time_on_page'] = event_df['time_on_page'] / np.timedelta64(1, 's')

Вуаля:

3 На основе полученных данных очень просто посчитать различные агрегаты

event_df['user_id'].nunique() # Количество пользователей
event_df['session_id'].nunique() # Количество сессий
event_df['id'].count() # Количество просмотров страниц (событий)
event_df['time_on_page'].mean() # Среднее время просмотра страниц

Заключение

Таким образом, используя несколько не самых очевидных функций в Pandas (например, merge_asof мне довелось применять впервые), можно формировать сессии на основе лога событий. Логом событий могут выступать логи сервера, какой-нибудь клик-стрим в SaaS-сервисах, сырые данные систем веб-аналитики.

Удачи и новых аналитических достижений!

Вступайте в группу на Facebook и подписывайтесь на мой канал в Telegram, там публикуются интересные статьи про анализ данных и не только.

Становясь падаваном Logs API Яндекс.Метрики

API Яндекс.Метрики предоставляет мощный функционал для построения гибкой отчетности и автоматизации. Достаточно посмотреть список группировок и метрик в документации, чтобы придумать множество вариантов использования. Кто-то делает очень крутые дэшборды, кто-то строит системы аналитики контекстной рекламы, оптимизаторы ставок и прочие занятные вещи, например, коррелятор промежуточных целей.

Но Метрика не остановилась в своем развитии на обычной API, которая позволяет вытащить данные только по заданному списку группировок, ограниченному 10 группировками в запросе. Разработчики Яндекса предоставили возможность получать «сырые» данные из хранилища данных Яндекс.Метрики — Logs API.

Агрегированные, или обобщённые данные, которые вы видите в интерфейсе Метрики или выгружаете через АПИ отчетов, рассчитываются для определённой группы визитов. Например, метрика «время на сайте» вычисляется для всех переходов из какого-либо источника трафика, всех визитов от посетителей мужского пола или всех визитов с планшетов.

А основой для этих расчётов служат сырые данные — записи об отдельных визитах или просмотрах. Таблица с этими записями и передаётся через Logs API, при этом каждая запись дополнена полезными сведениями из Метрики. Это подробные данные по Директу и по электронной коммерции, страна и город посетителя, а ещё — различная техническая информация о визите: например, браузер и модель мобильного телефона.

Если вы новичок в работе с АПИ Метрики, то я рекомендую сначала ознакомиться со статьей «Становясь гуру API Яндекс.Метрики». Она даст понимание того как работает API и как выгружать данные из Метрики с помощью API отчетов. Информация в ней нам ещё будет полезна, чтобы получить авторизационный токен.

В этой статье я хочу поделиться своим рецептом получения данных из Logs API Яндекс.Метрики, а также о нескольких приемах обработки этих данных.

Приступим!

Первое: Получить авторизационный токен

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

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

Второе: Запрос на создание лога

После того как мы получили авторизационный токен, например, AQAAAAAHrQEBAADn-FX3DPJUn04fkptrzvFv8nE, мы должны сформировать запрос к Logs API, который создает лог. Лог формируется на стороне Яндекс.Метрики в течении определенного времени, которое зависит от того сколько параметров визита или просмотра вы хотите получить, а также от диапазона времени, за который нужен лог.

Метод «Создание лога запросов» создает запрос на подготовку отчета, в котором будут нужные нам данные.

Это POST-запрос, следующей структуры:

POST https://api-metrika.yandex.ru/management/v1/counter/{counterId}/logrequests ? 
date1=<string>
 & date2=<string>
 & fields=<string>
 & source=<log_request_source>

Параметры запроса на создание лога

Рассмотрим все параметры, которые нужно передать в запросе:
{counterId} — идентификатор счетчика Метрики
date1 — дата начала отчетного периода в формате YYYY-MM-DD (например, 2015-08-31).
date2 — дата конца отчетного периода в формате YYYY-MM-DD (не может быть текущим днем)
fields — список полей, которые надо получить. Поля разделяются запятыми.

Давайте параметр fields подробнее.

Поля — это те параметры визитов или просмотров, которые Яндекс выгрузит из своей базы данных и предоставит нам в виде файла в формате CSV. Существует две категории полей, которые можно использовать в Logs API:

Предположим, мы хотим получить детально каждый визит с указанием:

  • даты визита;
  • идентификатора пользователя, совершившего визит;
  • количеством просмотров страниц за визит;
  • страницы входа, с которой начался этот визит.

Смотрим таблицу полей для визитов и определяем, что нам нужны следующие поля:

  • ym:s:visitID — идентификатор визита;
  • ym:s:date — дата визита;
  • ym:s:clientID — идентификатор пользователя на сайте;
  • ym:s:pageViews — глубина просмотра;
  • ym:s:ym:s:startURL — страница входа.

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

Следующий параметр source, который задает источник логов. Тут всё просто: если вы хотите получить данные по визитам, то нужно указать visits; если нужны данные по просмотрам — указываем hits.

Последний параметр oauth_token — это авторизационный токен, который мы получили в предыдущем пункте.

Сформируем тестовый запрос:
https://api-metrika.yandex.ru/management/v1/counter/30177909/logrequests?date1=2017-03-01&date2=2017-03-06&fields=ym:s:visitID,ym:s:date,ym:s:clientID,ym:s:pageViews,ym:s:startURL&source=visits&oauth_token=AQAAAAAHrQEBAADn-FX3DPJUn04fkptrzvFv8nE

Дальше нужно сделать POST-запрос. Один из самых простых способов сделать POST-запрос, не прибегая к программированию — воспользоваться расширением Postman для Chrome.

Как сделать POST-запрос к API Яндекс.Метрики?

1 — Устанавливаем и запускаем расширение Postman:

2 — Выбираем тип HTTP-запроса «POST», а в поле ввода запроса вставляем сформированный выше запрос:

3 — Нажимаем синюю кнопку «Send»

4 — Получаем ответ от API:

5 — В ответе нас интересует идентификатор request_id. Это идентификатор, созданного запроса на получение данных из Logs API:

Этот идентификатор копируем, он понадобится нам на следующем шаге.

Третье: Получение информации о запросе логов

После того как мы отправили в АПИ заявку на формирование лога, нужно получить статус лога: узнать готов ли он для скачивания. Для этой цели существует метод «Информация о запросе логов».

Вызывается этот метод с помощью следующего GET-запроса:

GET https://api-metrika.yandex.ru/management/v1/counter/{counterId}/logrequest/{requestId}

Вместо counterId подставляем идентификатор счетчика, для которого мы делали запрос на создание, а вместо requestId ставим идентификатор request_id, полученный в ответе на предыдущий запрос. После этого через знак «?» указываем параметр oauth_token.

Таким образом, наша сформированная ссылка для получения информации о запросе лога выглядит так:
https://api-metrika.yandex.ru/management/v1/counter/30177909/logrequest/45264?oauth_token=AQAAAAAHrQEBAADn-FX3DPJUn04fkptrzvFv8nE

По сути, это обычный GET-запрос, поэтому выполнить его можно и в обычном браузере, но удобнее будет в Postman, потому что в него встроен pretty-вывод JSON и смотреть на результат будет приятнее.

Вставляем запрос в Postman, выбираем тип HTTP-запроса «GET» и нажимаем «Send»:

В ответе нас интересует параметр status. Этот параметр может принимать несколько значений, которые описаны тут. В нашем случае он принимает значение «processed», которое говорит о том, что запрос лога обработан и лог готов к скачиванию. Это то, что нужно!

Обратите внимание на параметр parts. Может так получится, что полученный лог окажется слишком большим и будет разбит на несколько частей, которые придется скачивать по отдельности.

Перейдем к скачиванию лога.

Четвертое: Загрузка лога

Чтобы скачать подготовленный лог нам понадобится метод «Загрузка части подготовленных логов обработанного запроса».

Этот метод, также как и предыдущий, вызывается с помощью GET-запроса:

GET https://api-metrika.yandex.ru/management/v1/counter/{counterId}/logrequest/{requestId}/part/{partNumber}/download

Аналогично тому как мы делали это в предыдущем запросе, вместо counterId подставляем идентификатор счетчика, вместо requestId указываем уже известный нам идентификатор request_id, а на место partNumber ставим порядковый номер той части лога, которую мы хотим скачать. В нашем примере всего одна часть, поэтому ставим 0. После этого через знак «?» указываем параметр oauth_token.

Сформированная ссылка для скачивания лога будет такой:
https://api-metrika.yandex.ru/management/v1/counter/30177909/logrequest/45264/part/0/download?oauth_token=AQAAAAAHrQEBAADn-FX3DPJUn04fkptrzvFv8nE

Этот GET-запрос можно выполнять как в браузере, так и через Postman, но в случае с Postman’ом вместо «Send» надо выбрать вариант «Send and Download»:

А затем сохранить полученный файл в формате CSV:

Всё! Мы получили лог и сохранили его себе на диск, поэтому можно избавить Яндекс от хранения лишней информации и очистить лог с помощью метода «Очистка подготовленных для загрузки логов обработанного запроса». Делается это с помощью уже привычного нам POST-запроса. Расписывать не буду, надеюсь разберетесь сами :)

Заключительное: Обработка лога

Честно говоря, обработка логов с помощью Excel — это то ещё извращение, я бы рекомендовал использовать для таких задач что-то более подходящее, например, Pandas или R. Но Excel — базовый инструмент анализа данных, поэтому рассмотрю на его примере.

Открываем csv-файл в Excel. Для этого создаем новую книгу, открываем вкладку «Данные» и выбираем «Из текста»:

Выбираем файл и указываем в мастере текстов, что наш файл содержит разделители и записан в кодировке UTF-8:

На следующем шаге выбираем в качестве символа-разделителя знак табуляции, а затем устанавливаем форматы колонок, лучше всего для идентификаторов задать текстовый формат колонок.

Итак, мы загрузили наши данные в таблицу:

Дальше давайте решим две простые задачи:

  • Определим пользователя с наибольшим числом визитов, т. е. того кто чаще всего посещал наш сайт;
  • Определим пользователя с наибольшим суммарным числом просмотров страниц, т. е. того кто больше всех лазил по нашему сайту.

Построим простейшую сводную таблицу:

В строки вынесем ID пользователей (ym:s:clientID), а в значения — ID визита (ym:s:visitID) и глубину просмотра (ym:s:pageViews). Из-за того, что для поля ym:s:visitID мы указали как текстовый столбец, сводная таблица автоматически посчитает не сумму айдишников, а их количество. Это и будет количеством визитов на пользователя.

Отсортировав сводную таблицу по второму столбцу мы увидим, что больше всего визитов у пользователя 1487091524934294616, а отсортировав по третьему столбцу обнаружим пользователя с наибольшим числом просмотров — это пользователь 1488553373564844012.

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

Видно, что первый пользователь заходил на протяжении 3 дней с одной и той же страницы, скорее всего, он добавил её в закладки и это были прямые переходы, чтобы подтвердить эту гипотезу нужно бы сделать ещё одну выгрузку из Logs API с указанием параметра ym:s:referer (реферер) или ym:s:lastTrafficSource (источник трафика):

Второй пользователь вел себя по-другому. Он совершил в ходе одного из визитов 16 просмотров, благодаря чему и стал рекордсменом по числу просмотров. Для этого пользователя было бы интересно посмотреть какие страницы он просматривал. Это можно сделать, добавив в параметры выгрузки параметр ym:s:watchIDs (идентификаторы просмотров, которые были в визите):

Естественно, применение Logs API не ограничено этими простенькими ситуациями, можно делать намного более сложные вещи, вроде когортного анализа, сложных моделей аттрибуции, анализа посещаемости страниц (например, определить на какую страницу чаще всего переходят после просмотра определенной страницы, или даже построить вероятностную модель переходов). Но все эти задачи в Excel не так просто решаются. Здесь на помощь приходят более мощные инструменты анализа данных: Python и Pandas, R, SQL. Об этом как-нибудь в другой раз :)

Да и получать данные из Logs API с помощью запросов в Postman’е — это не совсем удобный способ. Если вам захочется делать эти выгрузки постоянно, всё складировать у себя и периодически анализировать — лучший вариант складывать данные в БД ClickHouse. Благо, что для интеграции Logs API с ClickHouse в Яндексе уже разработали простой в использовании Python-скрипт. Конечно, это сложнее, придется поднять ClickHouse, научиться запускать скрипты на Python’е, а еще лучше не ручками, а по расписанию, но, поверьте, всё это намного интереснее и открывает кучу новых возможностей!

Успехов!

Вступайте в группу на Facebook и подписывайтесь на мой канал в Telegram, там публикуются интересные статьи про анализ данных и не только.

Как получить список станций Московского метрополитена по API

Станции Кольцевой линии Московского метрополитена

Существует большое количество способов получить список станций Московского метро. Их можно разделить на 2 категории: ручные и автоматизированные.

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

В этой статье я собрал несколько автоматизированных способов получения всех станций Московского метро. Некоторые из них предельно простые и используют один запрос к API, другие чуть посложнее и предполагают парсинг веб—страниц.

API Superjob

Сервис поиска работы Superjob предлагает метод API для получения списка станций:
https://api.superjob.ru/2.0/suggest/town/4/metro/all/

Цифра 4 после town в запросе — это идентификатор города. Так что этот метод годится не только для метро в Москве, но и для других городов, где есть метро. Узнать идентификатор города можно с помощью запроса:
https://api.superjob.ru/2.0/towns/?all=1&genitive=1

Ответ приходит вот в JSON с вот такой простой структурой:

Хозяйке на заметку: если нужны склонения городов, то можно взять из этого JSON.

Запрос возвращает станции метро в JSON:

На момент написания статьи (январь 2017) метод отдавал 220 станций. В списке отсутствуют станции Московского центрального кольца. Еще один очевидный минус этого метода — отсутствие координат станций.
Полная документация API Superjob доступна по ссылке.

API HeadHunter

Еще один сервис для поиска работы HeadHunter предоставляет API, в котором есть удобный метод для получения справочника по станциям метро:
https://api.hh.ru/metro/1

Цифра 1 указывает на идентификатор города в HeadHunter. В документации можно ознакомиться с методом, позволяющим получить дерево всех регионов, если вдруг понадобится найти станции метро для другого города.

Метод возвращает структуру в JSON—формате, в которой на верхнем уровне иерархии находится список линий метро lines:

Отдельный плюс — это указание цвета ветки в атрибуте hex_color.
Внутри каждой ветки в массиве station лежат станции, принадлежащие этой ветке:
И здесь несказанно радует наличие географических координат, а также атрибут order, содержащий порядковый номер станции в линии.

По состоянию на январь 2017 метод отдаёт 240 станций метро, включая станции Московского центрального кольца.

Еще одна интересная особенность метода — можно не указывать город и сделать запрос https://api.hh.ru/metro/. В этом случае ответом будет список всех городов, которые добавлены в HeadHunter и в которых есть метрополитен. А уже внутри каждого города будет массив lines. Удобно!

Портал открытых данных Правительства Москвы

На портале открытых данных Москвы есть справочник под названием «Станции Московского метрополитена»:
https://data.mos.ru/classifier/7704786030-stantsii-moskovskogo-metropolitena

Эти же данные можно получить, обратившись к API портала data.mos.ru:
http://api.data.mos.ru/v1/datasets/1488/rows

В данных отсутствуют географические координаты станции, но эту проблему можно решить подружив данные с геокодером Яндекса (рассказ о его использовании чуть ниже). В результатах, которые отдает data.mos.ru есть указание административных округов и районов, в которых расположены станции. Еще одна любопытная деталь, которой нет в других источниках — наличие строящихся станций Московского метрополитена.

Всего список насчитывает 296 станций, из которых 236 — действующие станции метро.

API 2Гис

API 2Гис не доступно публично. Официально, чтобы им пользоваться необходим ключ доступа, который предоставляется по запросу. Но дело в том, что веб—интерфейс 2Гис сам обращается к тому же API, поэтому в запросах от браузера можно найти GET-запросы вот такого вида:
https://catalog.api.2gis.ru/2.0/suggest/list?key=ruczoy1743®ion_id=36&lang=ru&q=foobar
Здесь key — ключ доступа к API, который судя по всему имеет достаточно длительный срок жизни. Поэтому пользуйтесь на здоровье.

Я не нашел в документации API 2Гис метода, который позволял бы найти все станции метро одним запросом. Но есть замечательный метод, позволяющий найти транспортный маршрут:
https://catalog.api.2gis.ru/2.0/transport/route/search?key=ruczoy1743&q=Сокольническая&subtype=metro,monorail®ion_id=32
В параметре subtype задается тип маршрута, который нужно выводить в результатах, а именно метро и монорельс; в region_id указываем идентификатор города.

В ответ приходит результат поиска по маршрутам:

Зная ID маршрута, можно получить список остановок, выполнив запрос:
https://catalog.api.2gis.ru/2.0/transport/route/get?id=4504205217760068&fields=items.region_id&key=ruczoy1743

Собственно, это то, что нужно:

Замечу, что станции тут располагаются в массиве platforms в порядке их следования.

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

В базе 2Гис на январь 2017 года содержится 236 станции Московского метро.

Сайт mosmetro + геокодер Яндекса

Этот способ самый «костыльный» из всех способов и его я рассмотрю только для того, чтобы показать как можно работать с геокодером Яндекса для поиска станций метро.

Во—первых, нам понадобится список станций метро, который можно взять со страницы «Расписание поездов» на официальном сайте Московского метрополитена.

Названия всех станций — это тексты ссылок в блоке с классом «schedulestations». Их можно достать, написав простейший парсер HTML—страницы.

Вторым шагом работы нашего «костыля» будет обращение с названием каждой из станций к геокодеру Яндекса:
https://geocode-maps.yandex.ru/1.x/?geocode=метро%20Третьяковская&results=100&format=json

(Обратите внимание на приписку «метро» к названию станции, это нужно, чтобы отсеять лишние результаты, например, «улица Третьяковская»)

Результаты поиска — объекты GeoObject, в которых содержатся данные о найденных по запросу станциях метро. Атрибут kind указывает на тип объекта. Станции метро имеют тип metro, кроме станций монорельса, у них тип station. Нужно помнить, что для одной станции может быть несколько линий, поэтому объектов GeoObject может быть больше одного.

XML от Циан

Классифайд по недвижимости Циан предоставляет список в виде XML:
https://www.cian.ru/metros.php

К сожалению, не указаны ветки.

XML от Авито

Сайт объявлений Авито использует вот такой XML-справочник с местоположениями:
http://autoload.avito.ru/format/Locations.xml

Этот справочник включает сущности типа subway — это станции метро.

API Яндекс.Метро

У Яндекс.Метро есть слабодокументированное API — https://metro.yandex.ru/api/. Подозреваю, что API скорее для внутреннего пользования. Оно выгодно отличается от других способов получения станций тем, что можно получить не просто станции, но всю схему Московского метро в SVG с помощью метода get-scheme-geometry: https://metro.yandex.ru/api/get-scheme-geometry?id=1&lang=ru. Отдельно станции можно получить с помощью метода get-stations: https://metro.yandex.ru/api/get-stations?id=1&lang=ru. Еще можно получить метаданные методом get-scheme-metadata: https://metro.yandex.ru/api/get-scheme-metadata?id=1&lang=ru. В метаданных можно найти информацию о пересадках и различные примечания, например, информацию о вестибюлях, закрытых на ремонт.

Файлы

Если вам не нужно API, а достаточно JSON, CSV или XLSX — их есть у меня:

Файлы актуальны на январь 2017 года, данные получены с помощью API HeadHunter. Постараюсь обновлять регулярно. Об ошибках, недочетах, или если вдруг файлы станут недоступны сообщайте в комментах к посту.

Знаете еще какие—то способы получения станций Московского (и не только Московского) метро? Делитесь в комментариях.

Вступайте в группу на Facebook и подписывайтесь на мой канал в Telegram, там публикуются интересные статьи про анализ данных и не только.

Ранее Ctrl + ↓