Блог об аналитике, визуализации данных, data science и BI

    Valiotti Analytics — построение аналитики для мобильных и digital-стартапов
    DataMarathon.ru — семидневный интенсив в области аналитики для начинающих

Пишем скрипт для автоматизации коммитов GitHub

Время чтения текста – 9 минут

У GitHub есть API, позволяющий делать всё, что можно сделать руками: создавать репозитории, делать коммиты файлов и переключаться между ветками. Не все используют GUI при работе с системой контроля версий, и для коммита файлов приходится вводить не одну команду в терминал, а смена веток нередко приводит к запутанности действий. Сегодня мы поэкспериментируем с GitHub API и напишем скрипт, который сам собирает все файлы текущей директории раз в час и отправляет в отдельную ветку на GitHub при помощи get, post и put-запросов к методу /repos.

Получение access token

Для работы с GitHub API нужно получить токен, который выдаётся каждому пользователю в настройках. Для создания нового токена нажимаем на «Generate new token»:

В процессе работы с GitHub API токен может просрочиться и в ответ придёт ошибка «Bad credentials». Для решения проблемы можно создать новый токен или получить токен приложения

Следом нужно оставить название для токена и отметить области доступа ключу. Для наших целей достаточно дать доступ только к разделу repo.

После в зелёном окошке появится токен:

Отправляем запросы

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

import requests
import base64
import json
import os

username = 'leftjoin'
repo = 'leftjoin'
token = "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"
new_branch_name = 'automatic'

Создадим новый репозиторий post-запросом. В поле data передаем название репозитория, в auth — логин и токен:

r = requests.post('https://api.github.com/user/repos',
                  data=json.dumps({'name':repo}),
                  auth=(username, token),
                  headers={"Content-Type": "application/json"})

Теперь загрузим файл README.md, в котором будет строка “Hello, world!”. Для этого строку нужно перекодировать в base64 — GitHub принимает данные в такой кодировке:

content = "Hello, world!"

b_content = content.encode('utf-8')
base64_content = base64.b64encode(b_content)
base64_content_str = base64_content.decode('utf-8')

Теперь создадим коммит в формате словаря: он состоит из пути (пока его оставим пустым), сообщения коммита и содержания:

f = {'path':'',
     'message': 'Automatic update',
     'content': base64_content_str}

Отправим файл в репозиторий put-запросом, передав путь до README.md прямо в ссылке запроса:

f_resp = requests.put(f'https://api.github.com/repos/{username}/{repo}/contents/README.md',
                auth=(username, token),
                headers={ "Content-Type": "application/json" },
                data=json.dumps(f))

Теперь в репозитории в ветке main есть один файл README.md, который содержит строку «Hello, world!»:

Работа с ветками

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

Начнём с функции, которая определяет, существует ли ветка с таким названием. Она отправит запрос на получение всех веток репозитория, и вернёт False, если ветки не существует:

def branch_exist(branch_name):
    branches = requests.get(f'https://api.github.com/repos/{username}/{repo}/git/refs').json()
    try:
        for branch in branches:
            if branch['ref'] == 'refs/heads/' + branch_name:
                return True
        return False
    except Exception:
        return False

У каждой ветки, файлов и репозиториев на GitHub есть уникальный идентификатор, получаемый путём хэш-суммы алгоритмом SHA. Он необходим как для модификации файлов, так и для смены веток — опишем функцию, которая по названию ветки получает SHA для неё:

def get_branch_sha(branch_name):
    try:
        branches = requests.get(f'https://api.github.com/repos/{username}/{repo}/git/refs').json()
        for branch in branches:
            sha = None
            if branch['ref'] == 'refs/heads/' + branch_name:
                sha = branch['object']['sha']
            return sha
    except Exception:
        return None

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

def create_branch():
    main_branch_sha = get_branch_sha('main')
    requests.post(f'https://api.github.com/repos/{username}/{repo}/git/refs',
             auth=(username, token),
             data=json.dumps({
                 'ref':f'refs/heads/{new_branch_name}',
                 'sha':main_branch_sha
             })).json()

Для модификации файлов тоже необходимо знать его идентификатор:

def get_sha(path):
    r = requests.get(f'https://api.github.com/repos/{username}/{repo}/contents/{path}',
                    auth=(username, token),
                    data=json.dumps({
                        'branch': new_branch_name
                    }))
    sha = r.json()['sha']
    return sha

Наконец, опишем ряд главных функций — первая по аналогии с примером из начала материала принимает содержимое файла и формирует коммит в отдельную ветку, а вторая отправляет файл в указанный репозиторий:

def make_file_and_commit(content, sha=None):
    b_content = content.encode('utf-8')
    base64_content = base64.b64encode(b_content)
    base64_content_str = base64_content.decode('utf-8')
    f = {'path':'',
     'message': 'Automatic update',
     'content': base64_content_str,
     'sha':sha,
     'branch': new_branch_name}
    return f

def send_file(path, data):
    f_resp = requests.put(f'https://api.github.com/repos/{username}/{repo}/contents/{path}',
                    auth=(username, token),
                    headers={ "Content-Type": "application/json" },
                    data=json.dumps(data))
    print(f_resp.json())
    return f_resp

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

def file_exist(path):
    r = requests.get(f'https://api.github.com/repos/{username}/{repo}/contents/{path}',
                    auth=(username, token))
    return r.ok

def file_reader(path):
    lines = ""
    with open(path, 'r') as f:
        for line in f:
            lines += line
    return lines

Соберём всё вместе в функцию main(). Проверяем, существует ли ветка для автоматических коммитов, затем получаем все файлы директории, проверяем их существование в репозитории и отправляем:

def main():
    if not branch_exist(new_branch_name):
        create_branch()
    files = [os.path.join(dp, f) for dp, dn, fn in os.walk(os.path.expanduser(".")) for f in fn]
    for path in files:
        print(path)
        sha = None
        if file_exist(path):
            sha = get_sha(path)
        lines = file_reader(path)
        f = make_file_and_commit(lines, sha)
        r = send_file(path, f)

Перевести скрипт в автоматический режим может помочь библиотека schedule, которую мы уже использовали в материале «Анализ рынка вакансий аналитики и BI: дашборд в Tableau»

import schedule

schedule.every().hour.do(main)

while True:
    schedule.run_pending()

Вот и всё: мы написали скрипт, который самостоятельно каждый час отправляет все файлы текущей директории в ветку на GitHub.

 Нет комментариев    158   7 дн   api   github   python

Эффективное логирование в Python

Время чтения текста – 5 минут

В Python существует встроенный модуль logging, который позволяет журналировать этапы выполнения программы. Логирование полезно когда, например, нужно оставить большой скрипт сбора / обработки данных на длительное время, а в случае возникновения непредвиденных ошибок выяснить, с чем они могут быть связаны. Анализ логов позволяет быстро и эффективно выявлять проблемные места в коде, но для удобного использования модуля следует написать несколько функций по взаимодействию с ним и вынести их в отдельный файл — сегодня мы этим и займёмся.

Пишем логгер

Создадим файл loggers.py. Для начала импортируем модули и задаём пару значений по умолчанию — директорию для файла с логом и наименование конфигурационного файла, содержащего шаблоны логирования. Его мы опишем следом.

import os
import json
import logging
import logging.config

FOLDER_LOG = "log"
LOGGING_CONFIG_FILE = 'loggers.json'

Опишем функцию для создания папки с логом: она принимает наименование для папки, но по умолчанию будет называть её «log». Директорию создаём при помощи модуля os и только в том случае, если такой директории ещё не существует.

def create_log_folder(folder=FOLDER_LOG):
    if not os.path.exists(folder):
        os.mkdir(folder)

Теперь опишем функцию создания нового логгера по заданному шаблону. Функция должна создать директорию для логирования, открыть конфигурационный файл и достать нужный шаблон. Затем по шаблону при помощи модуля logging создаём новый логгер:

def get_logger(name, template='default'):
    create_log_folder()
    with open(LOGGING_CONFIG_FILE, "r") as f:
        dict_config = json.load(f)
        dict_config["loggers"][name] = dict_config["loggers"][template]
    logging.config.dictConfig(dict_config)
    return logging.getLogger(name)

Для удобства опишем ещё одну функцию — получение стандартного лога. Она ничего не принимает и нужна только для инициализации лога с шаблоном default:

def get_default_logger():
    create_log_folder()
    with open(LOGGING_CONFIG_FILE, "r") as f:
        logging.config.dictConfig(json.load(f))

    return logging.getLogger("default")

Описываем конфигурационный файл

Создадим по соседству файл loggers.json — он будет содержать настройки логгера. Внутри указываем такие настройки, как версию логгера, форматы логирования для разных уровней, наименование выходного файла и его максимальный размер:

{
    "version": 1,
    "disable_existing_loggers": false,
    "formatters": {
        "default": {
            "format": "%(asctime)s - %(processName)-10s - %(name)-10s - %(levelname)-8s - %(message)s"
        }
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "level": "INFO",
            "formatter": "default"
        },
        "rotating_file": {
            "class": "logging.handlers.RotatingFileHandler",
            "level": "DEBUG",
            "formatter": "default",
            "filename": "log/main.log",
            "maxBytes": 10485760,
            "backupCount": 20
        }
    },
    "loggers": {
        "default": {
            "handlers": ["console", "rotating_file"],
            "level": "DEBUG"
        }
    }
}

Использование логгера

Теперь давайте представим, что вы выгружаете данные по API и складываете их в базу данных на примере нашего материала про транзакции в SQLAlchemy. Рассмотрим заключительную часть кода: добавим строку с инициализацией стандартного логгера и изменим код так, чтобы сначала в лог выводился offset, затем в случае успеха предложение «Successfully inserted data», а в случае ошибки выводилась сама ошибка и предложение: «Error: tried to insert data but got an error».

logger = get_logger('main')

offset = 0
subs_count = get_subs_count(group_id)

while offset < subs_count:
    with engine.connect() as conn:
        transaction = conn.begin()
        try:
            logger.info(f"{offset} / {subs_count}")
            df = get_subs_info(group_id, offset)
            df.to_sql('subscribers', con=conn, if_exists='append', index=False)
            if offset == 10:
                raise(ValueError("This is a test errror"))
            transaction.commit()
            logger.info(f"Successfully inserted data")
        except Exception as E:
            transaction.rollback()
            logger.error(f"Error: tried to insert {df} but got an error: {E}")
    time.sleep(1)
    offset += 10

Теперь во время работы программы будет отображаться такой вывод, который также будет записан в файл main.log папки log в директории проекта. После завершения работы программы можно исследовать логи, посмотреть, на каких offset возникли проблемы, какие данные не удалось вставить и прочитать текст ошибки:

Обнаружение статистических выбросов в Python

Время чтения текста – 7 минут

Параллельно с выходом материала «Обнаружение выбросов в R» предлагаем посмотреть, как те же методы обнаружения выбросов реализовать в Python.

Данные

Для наглядности эксперимента возьмём тот же пакет данный mpg — скачать его в виде csv-таблицы можно с GitHub. Импортируем библиотеки и читаем таблицу в DataFrame:

import pandas as pd
import matplotlib.pyplot as plt
import numpy as np

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

Минимальные и максимальные значения

Тут всё просто. Выводим описание всего датасета методом describe():

df.describe()

Гистограмма

Такой график тоже можно построить в одну строку, используя внутренние средства библиотеки pandas:

df.hwy.plot(kind='hist', density=1, bins=20, stacked=False, alpha=.5, color='grey')

Box plot

В случае ящика с усами далеко идти тоже не приходится — в pandas есть метод и для этого:

_, bp = df.hwy.plot.box(return_type='both')

Получим точки с графика и выведем их в таблице, используя объект bp:

outliers = [flier.get_ydata() for flier in bp["fliers"]][0]
df[df.hwy.isin(outliers)]

Процентили

При помощи метода quantile получаем соответствующую нижнюю и верхнюю границы, а затем выводим всё, что выходит за их рамки:

lower_bound = df.hwy.quantile(q=0.025)
upper_bound = df.hwy.quantile(q=0.975)
df[(df.hwy < lower_bound) | (df.hwy > upper_bound)]

Фильтр Хэмпеля

Мы используем реализацию фильтра Хэмпеля, найденную на StackOverflow

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

def hampel(vals_orig):
    vals = vals_orig.copy()    
    difference = np.abs(vals.median()-vals)
    median_abs_deviation = difference.median()
    threshold = 3 * median_abs_deviation
    outlier_idx = difference > threshold
    vals[outlier_idx] = np.nan
    return(vals)

И применим к нашему набору данных:

hampel(df.hwy)

0      29.0
1      29.0
2      31.0
3      30.0
4      26.0
       ... 
229    28.0
230    29.0
231    26.0
232    26.0
233    26.0
Name: hwy, Length: 234, dtype: float64

В выводе нет nan-значений, а значит и выбросов фильтр Хэмпеля не обнаружил.

Тест Граббса

Автор реализации теста Граббса и теста Рознера для Python

Опишем три функции: первая находит значение критерия Граббса и максимальное значение в наборе данных, вторая — критическое значение с учётом объёма выборки и уровня значимости, а третья проверяет, является ли значение с максимальным индексом выбросом:

import numpy as np
from scipy import stats

def grubbs_stat(y):
    std_dev = np.std(y)
    avg_y = np.mean(y)
    abs_val_minus_avg = abs(y - avg_y)
    max_of_deviations = max(abs_val_minus_avg)
    max_ind = np.argmax(abs_val_minus_avg)
    Gcal = max_of_deviations / std_dev
    print(f"Grubbs Statistics Value: {Gcal}")
    return Gcal, max_ind

def calculate_critical_value(size, alpha):
    t_dist = stats.t.ppf(1 - alpha / (2 * size), size - 2)
    numerator = (size - 1) * np.sqrt(np.square(t_dist))
    denominator = np.sqrt(size) * np.sqrt(size - 2 + np.square(t_dist))
    critical_value = numerator / denominator
    print(f"Grubbs Critical Value: {critical_value}")
    return critical_value

def check_G_values(Gs, Gc, inp, max_index):
    if Gs > Gc:
        print(f"{inp[max_index]} is an outlier")
    else:
        print(f"{inp[max_index]} is not an outlier")

Заменим значение в 34 строке на 212:

df.hwy[34] = 212

И выполним три функции:

Gcritical = calculate_critical_value(len(df.hwy), 0.05)
Gstat, max_index = grubbs_stat(df.hwy)
check_G_values(Gstat, Gcritical, df.hwy, max_index)

Grubbs Critical Value: 3.652090929984981
Grubbs Statistics Value: 13.745808761040397
212 is an outlier

Тест Рознера

Для теста Рознера достаточно дописать одну функцию, которая принимает набор данных, уровень значимости и число потенциальных выбросов:

def ESD_test(input_series, alpha, max_outliers):
    for iteration in range(max_outliers):
        Gcritical = calculate_critical_value(len(input_series), alpha)
        Gstat, max_index = grubbs_stat(input_series)
        check_G_values(Gstat, Gcritical, input_series, max_index)
        input_series = np.delete(input_series, max_index)

Используя функцию на нашем наборе данных получаем, что значение 212 является выбросом, а 44 — нет:

ESD_test(np.array(df.hwy), 0.05, 3)

Grubbs Critical Value: 3.652090929984981
Grubbs Statistics Value: 13.745808761040408
212 is an outlier
Grubbs Critical Value: 3.6508358337727187
Grubbs Statistics Value: 3.455960616168714
44 is not an outlier
Grubbs Critical Value: 3.649574509044683
Grubbs Statistics Value: 3.5561478280392245
44 is not an outlier

Обнаружение статистических выбросов в R

Время чтения текста – 27 минут

Этот материал — перевод статьи «Outliers detection in R». А ещё у нас есть материал про обнаружение выбросов в Python.

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

Выбросы могут быть вызваны изменчивостью, присущей наблюдаемому явлению. Например, при сборе данных о заработной плате часто возникают выбросы, поскольку некоторые люди зарабатывают гораздо больше остальных. Выбросы также могут возникать из-за экспериментальной ошибки, ошибки измерения или кодирования. Например, вес человека 786 кг явно является ошибкой при кодировании веса объекта. Её или его вес, скорее всего, составляет 78,6 кг или 7,86 кг в зависимости от того, был измерен вес взрослого человека или ребёнка.

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

В этой статье я представлю несколько подходов к обнаружению выбросов в R от простых методов, таких как описательная статистика (включая минимальные, максимальные значения, гистограмму, прямоугольную диаграмму и процентили), до более формальных методов, таких как фильтр Хэмпеля, тесты Граббса, Диксона и Рознера.

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

Эта статья поможет обнаружить и проверить выбросы, но вы не узнаете, следует ли удалять, изменять или оставлять такие значения. После проверки вы можете исключить их или включить в свой анализ (а это обычно требует вдумчивого размышления со стороны исследователя). Удаление или сохранение выбросов, в основном, зависит от трех факторов:

  1. Область / контекст вашего анализа и вопрос исследования. В некоторых областях обычно удаляют посторонние значения, поскольку они часто возникают из-за сбоев в процессе. В других областях отклонения сохраняются, потому что они содержат ценную информацию. Также бывает, что анализ выполняется дважды, один раз с посторонними значениями и один раз без них, чтобы оценить их влияние на результаты. Если результаты резко изменятся из-за некоторых определяющих значений, это должно предостеречь исследователя от чрезмерно амбициозных утверждений.
  2. Устойчивость тестов. Например, наклон простой линейной регрессии может значительно варьироваться даже с одним выбросом, тогда как непараметрические тесты, такие как тест Уилкоксона, обычно устойчивы к ним.
  3. Дальность выбросов от других наблюдений. Некоторые наблюдения, рассматриваемые как выбросы, на самом деле не являются экстремальными значениями по сравнению со всеми другими наблюдениями, в то время как другие потенциальные выбросы могут быть действительно отстающими от остальных наблюдений.

Мы будем использовать набор данных mpg из библиотеки ggplot2, чтобы проиллюстрировать различные подходы к обнаружению выбросов в R, и в частности, мы сосредоточимся на работе с переменной hwy (пробег в милях на галлон израсходованного топлива).

Минимальные и максимальные значения

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

В R это легко сделать с помощью функции summary():

dat <- ggplot2::mpg
summary(dat$hwy)

##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##   12.00   18.00   24.00   23.44   27.00   44.00

Минимум и максимум — первое и последнее значения в выходных данных выше. В качестве альтернативы, их также можно вычислить с помощью функций min() и max():

min(dat$hwy)

## [1] 12

max(dat$hwy)

## [1] 44

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

Гистограмма

Другой базовый способ обнаружения выбросов — построение гистограммы данных.

При помощи внутренних инструментов R:

hist(dat$hwy,
  xlab = "hwy",
  main = "Histogram of hwy",
  breaks = sqrt(nrow(dat))
) # set number of bins

При помощи ggplot2:

library(ggplot2)

ggplot(dat) +
  aes(x = hwy) +
  geom_histogram(bins = 30L, fill = "#0c4c8a") +
  theme_minimal()

Пара полосок справа в отрыве от основного графика — значения, которые больше остальных.

#Box plot
Помимо гистограмм, box plot (ящик с усами) также полезен для обнаружения потенциальных выбросов.

Используя R:

boxplot(dat$hwy,
  ylab = "hwy"
)

или используя ggplot2:

ggplot(dat) +
  aes(x = "", y = hwy) +
  geom_boxplot(fill = "#0c4c8a") +
  theme_minimal()

Box plot помогает визуализировать количественную переменную, отображая пять общих сводных данных (минимальное значение, среднее значение, первый и третий квартили и максимальное значение) и любое значение, которое было классифицировано как предполагаемый выброс с использованием критерия межквартильного размаха (IQR). Критерий межквартильного размаха означает, что все единицы значения больше q₀,₇₅+ 1.5 ⋅ IQR или меньше q₀,₂₅ — 1,5⋅ IQR рассматриваются R, как потенциальные выбросы. Другими словами, все наблюдения за пределами следующего интервала будут рассматриваться как потенциальные выбросы:

I = [q₀,₂₅ — 1.5 * IQR; q₀,₇₅ + 1.5 * IQR]

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

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

Также возможно извлечь потенциальные выбросы на основе критерия IQR благодаря функции boxplot.stats()$out:

boxplot.stats(dat$hwy)$out

## [1] 44 44 41

Как видите, на самом деле есть 3 точки, которые считаются потенциальными выбросами: две со значением 44 и одна со значением 41.

Благодаря функции which() можно извлечь номер строки, соответствующий этим посторонним значениям:

out <- boxplot.stats(dat$hwy)$out
out_ind <- which(dat$hwy %in% c(out))
out_ind

## [1] 213 222 223

Имея эту информацию, вы теперь можете легко вернуться к определенным строкам в наборе данных, чтобы проверить их, или напечатать все переменные для этих выбросов:

dat[out_ind, ]

## # A tibble: 3 x 11
##   manufacturer model   displ  year   cyl trans   drv     cty   hwy fl    class  
##   <chr>        <chr>   <dbl> <int> <int> <chr>   <chr> <int> <int> <chr> <chr>  
## 1 volkswagen   jetta     1.9  1999     4 manual… f        33    44 d     compact
## 2 volkswagen   new be…   1.9  1999     4 manual… f        35    44 d     subcom…
## 3 volkswagen   new be…   1.9  1999     4 auto(l… f        29    41 d     subcom…

Ещё можно напечатать выбросы прямо на диаграмме размаха с помощью функции mtext():

boxplot(dat$hwy,
  ylab = "hwy",
  main = "Boxplot of highway miles per gallon"
)
mtext(paste("Outliers: ", paste(out, collapse = ", ")))

Процентили

Этот метод обнаружения посторонних значений основан на процентилях. При использовании метода процентилей все наблюдения, выходящие за пределы интервала, образованного 2,5 и 97,5 процентилями будут рассматриваться как потенциальные выбросы. Другие процентили, такие как 1 и 99 или 5 и 95 процентили, тоже могут быть рассмотрены для построения интервала.

Значения нижнего и верхнего процентилей можно вычислить с помощью функции quantile():

lower_bound <- quantile(dat$hwy, 0.025)
lower_bound

## 2.5% 
##   14

upper_bound <- quantile(dat$hwy, 0.975)
upper_bound

##  97.5% 
## 35.175

В соответствии с этим методом, все наблюдения ниже 14 и выше 35,175 будут рассматриваться как потенциальные выбросы. Номера рядов наблюдений за пределами интервала затем могут быть извлечены с помощью функции which():

outlier_ind <- which(dat$hwy < lower_bound | dat$hwy > upper_bound)
outlier_ind

##  [1]  55  60  66  70 106 107 127 197 213 222 223

Можно вывести значение пробега в милях на галлон израсходованного топлива для таких значений:

dat[outlier_ind, "hwy"]


## # A tibble: 11 x 1
##      hwy
##    <int>
##  1    12
##  2    12
##  3    12
##  4    12
##  5    36
##  6    36
##  7    12
##  8    37
##  9    44
## 10    44
## 11    41

В качестве альтернативы можно вывести все переменные для этих выбросов:

dat[outlier_ind, ]

## # A tibble: 11 x 11
##    manufacturer model    displ  year   cyl trans  drv     cty   hwy fl    class 
##    <chr>        <chr>    <dbl> <int> <int> <chr>  <chr> <int> <int> <chr> <chr> 
##  1 dodge        dakota …   4.7  2008     8 auto(… 4         9    12 e     pickup
##  2 dodge        durango…   4.7  2008     8 auto(… 4         9    12 e     suv   
##  3 dodge        ram 150…   4.7  2008     8 auto(… 4         9    12 e     pickup
##  4 dodge        ram 150…   4.7  2008     8 manua… 4         9    12 e     pickup
##  5 honda        civic      1.8  2008     4 auto(… f        25    36 r     subco…
##  6 honda        civic      1.8  2008     4 auto(… f        24    36 c     subco…
##  7 jeep         grand c…   4.7  2008     8 auto(… 4         9    12 e     suv   
##  8 toyota       corolla    1.8  2008     4 manua… f        28    37 r     compa…
##  9 volkswagen   jetta      1.9  1999     4 manua… f        33    44 d     compa…
## 10 volkswagen   new bee…   1.9  1999     4 manua… f        35    44 d     subco…
## 11 volkswagen   new bee…   1.9  1999     4 auto(… f        29    41 d     subco…

Согласно методу процентилей, существует 11 потенциальных выбросов. Чтобы уменьшить это число, вы можете установить процентили от 1 до 99:

lower_bound <- quantile(dat$hwy, 0.01)
upper_bound <- quantile(dat$hwy, 0.99)

outlier_ind <- which(dat$hwy < lower_bound | dat$hwy > upper_bound)

dat[outlier_ind, ]

## # A tibble: 3 x 11
##   manufacturer model   displ  year   cyl trans   drv     cty   hwy fl    class  
##   <chr>        <chr>   <dbl> <int> <int> <chr>   <chr> <int> <int> <chr> <chr>  
## 1 volkswagen   jetta     1.9  1999     4 manual… f        33    44 d     compact
## 2 volkswagen   new be…   1.9  1999     4 manual… f        35    44 d     subcom…
## 3 volkswagen   new be…   1.9  1999     4 auto(l… f        29    41 d     subcom…

Установка процентилей на 1 и 99 дает те же потенциальные выбросы, что и для критерия IQR.

Фильтр Хэмпеля

Другой метод, известный как фильтр Хэмпеля, заключается в том, чтобы рассматривать как выбросы значения вне интервала, которые формируются медианным значением плюс-минус 3 медианы абсолютных отклонений (MAD):

I = [median - 3 * MAD; median + 3 * MAD]

в которых MAD — это медианное абсолютное отклонение и определяется как медиана абсолютных отклонений от медианы данных:

Для этого метода мы сначала устанавливаем пределы интервала с помощью функций median() и mad():

lower_bound <- median(dat$hwy) - 3 * mad(dat$hwy, constant=1)
lower_bound

## [1] 9

upper_bound <- median(dat$hwy) + 3 * mad(dat$hwy, constant=1)
upper_bound

## [1] 39

Все наблюдения меньше 9 и больше 39 будут рассматриваться как потенциальные выбросы. Номера строк наблюдений за пределами интервала затем могут быть извлечены с помощью функции which():

outlier_ind <- which(dat$hwy < lower_bound | dat$hwy > upper_bound)
outlier_ind

## 213 222 223

Согласно фильтру Хэмпеля, для переменной hwy есть 3 потенциальных выброса.

Статистические тесты

В этом разделе мы представим еще 3 формальных метода обнаружения отклонений:

  1. Тест Граббса (Grubbs’s test)
  2. Тест Диксона (Dixon’s test)
  3. Тест Рознера (Rosner’s test)

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

Обратите внимание, что эти тесты подходят только тогда, когда данные распределены нормально. Таким образом, предположение о соответствии нормальности должно быть проверено перед применением этих тестов для выбросов (Как проверить предположение о соответствии нормальному распределению в R).

Тест Граббса (Grubbs’s test)

Тест Граббса позволяет определить, является ли наибольшее или наименьшее значение в наборе данных выбросом. Он обнаруживает по одному выбросу за раз (максимальное или минимальное значение), поэтому нулевая и альтернативная гипотезы проверки максимального значения выглядит так:

  • H₀: Наивысшее значение не является выбросом
  • H₁: Наивысшее значение является выбросом

А минимального — так:

  • H₀: Наименьшее значение не является выбросом
  • H₁: Наименьшее значение является выбросом

Как и в любом статистическом тесте, если значение P меньше порогового уровня статистической значимости (обычно α = 0.05), то нулевая гипотеза отвергается, и мы приходим к выводу, что наименьшее/наибольшее значение является отклонением. Напротив, если значение P больше или равно пороговому уровню значимости, нулевая гипотеза не отвергается, и мы делаем вывод, что на основе данных о том, что наименьшее / наибольшее значение не является выбросом. Обратите внимание на то, что тест Граббса не подходит для выборки объемом 6 или меньше (n <= 6).

Чтобы выполнить тест Граббса в R, используем функцию grubbs.test() из пакетов outliers:

# install.packages("outliers")
library(outliers)
test <- grubbs.test(dat$hwy)
test 

## 
##  Grubbs test for one outlier
## 
## data:  dat$hwy
## G = 3.45274, U = 0.94862, p-value = 0.05555
## alternative hypothesis: highest value 44 is an outlier

Значение P равняется 0,056. На уровне значимости 5% мы не отвергаем гипотезу о том, что наибольшее значение 44 не является выбросом.

По умолчанию тест выполняется на наибольшем значении (как показано в выходных данных R: alternative hypothesis: highest value). Если вы хотите провести тест для наименьшего значения, просто добавьте аргумент opposite = TRUE в функцию grubbs.test():

test <- grubbs.test(dat$hwy, opposite = TRUE)
test

## 
##  Grubbs test for one outlier
## 
## data:  dat$hwy
## G = 1.92122, U = 0.98409, p-value = 1
## alternative hypothesis: lowest value 12 is an outlier

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

Значение P равно 1. На уровне значимости 5% мы не отвергаем гипотезу о том, что наименьшее значение 12 не является выбросом.

Для иллюстрации этого заменим наблюдения более экстремальным значением и выполним тест Граббса для нового набора данных. Давайте заменим 34-ую строку со значением 212:

dat[34, "hwy"] <- 212

Применяем тест Граббса, чтобы проверить, является ли наибольшее значение выбросом:

test <- grubbs.test(dat$hwy)
test

## 
##  Grubbs test for one outlier
## 
## data:  dat$hwy
## G = 13.72240, U = 0.18836, p-value < 2.2e-16
## alternative hypothesis: highest value 212 is an outlier

Значение p < 0,001. На уровне значимости 5% мы делаем вывод, что наивысшее значение 212 является выбросом.

Тест Диксона (Dixon’s test)

Подобно тесту Граббса, тест Диксона используется для того, чтобы проверить, является ли самое высокое или самое низкое значение выбросом. Таким образом, если под сомнением находятся более одного выброса, тест необходимо проводить индивидуально для этих предполагаемых значений.

Обратите внимание на то, что тест Диксона наиболее полезен для выборки небольшого объема (обычно когда n <= 25).

Чтобы выполнить тест Диксона в R, мы используем функцию dixon.test() из пакета outliers. Однако мы ограничиваем наш набор данных 20 первыми наблюдениями, поскольку тест Диксона может быть выполнен только на небольшом размере выборки:

subdat <- dat[1:20, ]
test <- dixon.test(subdat$hwy)
test

## 
##  Dixon test for outliers
## 
## data:  subdat$hwy
## Q = 0.57143, p-value = 0.006508
## alternative hypothesis: lowest value 15 is an outlier

Результаты показывают, что самое наименьшее значение 15 является выбросом (p-значение = 0,007).

Чтобы проверить максимальное значение, просто добавьте аргумент opposite = TRUE к функции dixon.test():

test <- dixon.test(subdat$hwy,
  opposite = TRUE
)
test

## 
##  Dixon test for outliers
## 
## data:  subdat$hwy
## Q = 0.25, p-value = 0.8582
## alternative hypothesis: highest value 31 is an outlier

Результаты показывают, что максимальное значение 31 не является выбросом (p-значение = 0,858).

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

out <- boxplot.stats(subdat$hwy)$out
boxplot(subdat$hwy,
  ylab = "hwy"
)
mtext(paste("Outliers: ", paste(out, collapse = ", ")))

По box plot заметно, что мы можем применить тест Диксона к значению 20 в дополнение к значению 15, выполненному ранее. Это можно сделать, найдя номер строки минимального значения, исключив этот номер строки из набора данных, а затем применив тест Диксона к этому новому набору данных:

# find and exclude lowest value
remove_ind <- which.min(subdat$hwy)
subsubdat <- subdat[-remove_ind, ]

# Dixon test on dataset without the minimum
test <- dixon.test(subsubdat$hwy)
test

## 
##  Dixon test for outliers
## 
## data:  subsubdat$hwy
## Q = 0.44444, p-value = 0.1297
## alternative hypothesis: lowest value 20 is an outlier

Результаты показывают, что второе наименьшее значение 20 не является выбросом (p-значение = 0,13).

Тест Рознера (Rosner’s test)

  1. Тест Рознера на выбросы имеет следующие преимущества:
  2. Он используется для одновременного обнаружения нескольких выбросов (в отличие от теста Граббса и Диксона, которые должны выполняться итеративно для выявления нескольких выбросов)
  3. Он разработан чтобы избежать проблемы, когда выброс, близкий по значению к другому выбросу, может остаться незамеченным.

Обратите внимание, что в отличие от теста Диксона, тест Рознера подходит к большому объему выборки (n≥20). Поэтому мы снова используем исходный набор данных dat, который включает 234 наблюдения.

Для выполнения теста Рознера мы используем функцию rosnerTest() из пакета EnvStats. Для этой функции требуется как минимум 2 аргумента: данные и количество предполагаемых выбросов k.

library(EnvStats)
test <- rosnerTest(dat$hwy,
  k = 3
)
test

## $distribution
## [1] "Normal"
## 
## $statistic
##       R.1       R.2       R.3 
## 13.722399  3.459098  3.559936 
## 
## $sample.size
## [1] 234
## 
## $parameters
## k 
## 3 
## 
## $alpha
## [1] 0.05
## 
## $crit.value
## lambda.1 lambda.2 lambda.3 
## 3.652091 3.650836 3.649575 
## 
## $n.outliers
## [1] 1
## 
## $alternative
## [1] "Up to 3 observations are not\n                                 from the same Distribution."
## 
## $method
## [1] "Rosner's Test for Outliers"
## 
## $data
##   [1]  29  29  31  30  26  26  27  26  25  28  27  25  25  25  25  24  25  23
##  [19]  20  15  20  17  17  26  23  26  25  24  19  14  15  17  27 212  26  29
##  [37]  26  24  24  22  22  24  24  17  22  21  23  23  19  18  17  17  19  19
##  [55]  12  17  15  17  17  12  17  16  18  15  16  12  17  17  16  12  15  16
##  [73]  17  15  17  17  18  17  19  17  19  19  17  17  17  16  16  17  15  17
##  [91]  26  25  26  24  21  22  23  22  20  33  32  32  29  32  34  36  36  29
## [109]  26  27  30  31  26  26  28  26  29  28  27  24  24  24  22  19  20  17
## [127]  12  19  18  14  15  18  18  15  17  16  18  17  19  19  17  29  27  31
## [145]  32  27  26  26  25  25  17  17  20  18  26  26  27  28  25  25  24  27
## [163]  25  26  23  26  26  26  26  25  27  25  27  20  20  19  17  20  17  29
## [181]  27  31  31  26  26  28  27  29  31  31  26  26  27  30  33  35  37  35
## [199]  15  18  20  20  22  17  19  18  20  29  26  29  29  24  44  29  26  29
## [217]  29  29  29  23  24  44  41  29  26  28  29  29  29  28  29  26  26  26
## 
## $data.name
## [1] "dat$hwy"
## 
## $bad.obs
## [1] 0
## 
## $all.stats
##   i   Mean.i      SD.i Value Obs.Num     R.i+1 lambda.i+1 Outlier
## 1 0 24.21795 13.684345   212      34 13.722399   3.652091    TRUE
## 2 1 23.41202  5.951835    44     213  3.459098   3.650836   FALSE
## 3 2 23.32328  5.808172    44     222  3.559936   3.649575   FALSE
## 
## attr(,"class")
## [1] "gofOutlier"

Результаты представлены в таблице $all.stats:

test$all.stats

##   i   Mean.i      SD.i Value Obs.Num     R.i+1 lambda.i+1 Outlier
## 1 0 24.21795 13.684345   212      34 13.722399   3.652091    TRUE
## 2 1 23.41202  5.951835    44     213  3.459098   3.650836   FALSE
## 3 2 23.32328  5.808172    44     222  3.559936   3.649575   FALSE

Основываясь на тесте Рознера, мы видим, что существует только один выброс (см. Столбец Outlier), и что это наблюдение 34 (см. Obs.Num) со значением 212 (см. Value).

Итоги

Обратите внимание, что некоторые преобразования могут «естественным образом» устранить выбросы. Например, если взять натуральный логарифм или квадратный корень из значения, отклонение станет меньше. Я надеюсь, статья помогла вам обнаружить выбросы в R с помощью нескольких методов описательной статистики (включая минимум, максимум, гистограмму, диаграмму размаха и процентили) или благодаря более формальным методам обнаружения выбросов (включая фильтр Хампеля, тест Граббса, Диксона и Рознера). Следующим этапом проверьте эти значения, и если они действительно являются выбросами — решите, как с ними поступить (сохранить, удалить или изменить), прежде чем проводить анализ.

Обзор дашборда Yandex DataLens

Время чтения текста – 2 минуты

Два года назад Яндекс выпустил собственный инструмент для визуализации данных — Yandex DataLens, работающий на базе Yandex Cloud. В блоге уже выходил обзор инструмента — но тогда сервис был на стадии Preview, и за два года функционал инструмента расширили. Сервис тарифицируемый и без привязки платёжного аккаунта поработать в нём не получится, но помимо платного тарифа есть и бесплатный.

Подробнее о тарифах Yandex DataLens можно почитать в документации

В сегодняшнем обзоре BI-систем мы посмотрим, как зарегистрировать аккаунт в DataLens, подключить датасет и создать дополнительные таблицы на основе SQL-запросов, построить визуализации, связать их с фильтрами и добавить на дашборд согласно макету, а затем опубликовать результат.

Внутри команды мы оценили дашборд в DataLens и получили следующие средние оценки (1 — худшая оценка, 10 — лучшая):

Отвечает ли заданным вопросам — 7,0
Порог входа в инструмент — 8,0
Функциональность инструмента — 7,0
Удобство пользования — 8,3
Соответствие результата макету — 7,5
Визуальная составляющая — 8,5
Итог: дашборд получает 8 баллов из 10. Посмотрите на полученный результат.

Ранее Ctrl + ↓