Описательная статистика с Pandas, SQL и R
В этой работе вам предстоит выполнить два домашних задания с открытого курса по машинному обучению от OpenDataScience. Каждое задание нужно выполнить отдельно на Pandas, R и SQL. Первая тема курса посвящена первичному анализу данных с Pandas. Мы рассмотрим этот же пример с использованием SQL и R (решение на R можно найти в нашем репозитории). Чтобы не дублировать исходный текст статьи я оставил только ключевые фразы, поэтому за выводами по полученным результатам следует обращаться именно к нему.
Запускаем PostgreSQL в Docker
В качестве СУБД будем использовать PostgreSQL, которая будет запущена в докер-контейнере. Все данные будут храниться в отдельном дата-контейнере. Подробное описание команд можно найти в статье «Dockerized Postgresql Development Environment».
# Создание дата-контейнера, в котором будут хранится все базы данных постгреса
$ docker create -v /var/lib/postgresql/data --name mypostgres-data busybox
# Создание контейнера с PostgreSQL
$ docker run --name local-mypostgres -e POSTGRES_PASSWORD=secret -d --volumes-from mypostgres-data postgres:latest
Подключимся к контейнеру и создадим новую БД:
$ docker exec -it local-mypostgres bash
root@9ab15e9feb4d:/# psql -U postgres
psql (9.6.5)
Type "help" for help.
postgres=# CREATE DATABASE odscourse;
postgres=# \l
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
-----------+----------+----------+------------+------------+-----------------------
odscourse | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
postgres | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
template0 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
template1 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
postgres=# \q
root@9ab15e9feb4d:/# exit
Создание таблицы с данными по оттоку клиентов
Для взаимодействия с PostgreSQL из Python мы будем использовать драйвер psycopg2
, который можно установить следующей командой:
Note
Перед установкой новых пакетов не забудьте активировать виртуальное окружение.
(cs102) $ python -m pip install psycopg2
Теперь скачаем файл с данными, которые потребуются для выполнения примеров:
(cs102) $ wget https://raw.githubusercontent.com/Yorko/mlcourse_open/master/data/telecom_churn.csv
Создадим таблицу, содержащую данные по оттоку клиентов:
import psycopg2
import csv
conn = psycopg2.connect("host=localhost port=5433 dbname=odscourse user=postgres password=secret")
cursor = conn.cursor()
query = """
CREATE TABLE IF NOT EXISTS telecom_churn (
id SERIAL PRIMARY KEY,
state VARCHAR,
account_length INTEGER,
area_code INTEGER,
international_plan VARCHAR,
voice_mail_plan VARCHAR,
number_vmail_messages INTEGER,
total_day_minutes REAL,
total_day_calls INTEGER,
total_day_charge REAL,
total_eve_minutes REAL,
total_eve_calls INTEGER,
total_eve_charge REAL,
total_night_minutes REAL,
total_night_calls INTEGER,
total_night_charge REAL,
total_intl_minutes REAL,
total_intl_calls INTEGER,
total_intl_charge REAL,
customer_service_calls INTEGER,
churn BOOLEAN
)
"""
cursor.execute(query)
conn.commit()
with open('telecom_churn.csv', 'r') as f:
reader = csv.reader(f)
# Skip the header row
next(reader)
for Id, row in enumerate(reader):
cursor.execute(
"INSERT INTO telecom_churn VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
[Id] + row
)
conn.commit()
Посмотрим на первые 5 строк:
import psycopg2
conn = psycopg2.connect("host=localhost port=5433 dbname=odscourse user=postgres password=secret")
cursor = conn.cursor()
cursor.execute("SELECT * FROM telecom_churn LIMIT 5")
records = cursor.fetchall()
print(records)
[(0, 'KS', 128, 415, 'No', 'Yes', 25, 265.1, 110, 45.07, 197.4, 99, 16.78, 244.7, 91, 11.01, 10.0, 3, 2.7, 1, False),
(1, 'OH', 107, 415, 'No', 'Yes', 26, 161.6, 123, 27.47, 195.5, 103, 16.62, 254.4, 103, 11.45, 13.7, 3, 3.7, 1, False),
(2, 'NJ', 137, 415, 'No', 'No', 0, 243.4, 114, 41.38, 121.2, 110, 10.3, 162.6, 104, 7.32, 12.2, 5, 3.29, 0, False),
(3, 'OH', 84, 408, 'Yes', 'No', 0, 299.4, 71, 50.9, 61.9, 88, 5.26, 196.9, 89, 8.86, 6.6, 7, 1.78, 2, False),
(4, 'OK', 75, 415, 'Yes', 'No', 0, 166.7, 113, 28.34, 148.3, 122, 12.61, 186.9, 121, 8.41, 10.1, 3, 2.73, 3, False)]
Посмотрим на распределение данных по целевой переменной churn
:
Note
Для более красивого вывода табличных данных можно воспользоваться модулем tabulate
. Чтобы установить модуль воспользуйтесь командой pip install tabulate
.
from tabulate import tabulate
def fetch_all(cursor):
colnames = [desc[0] for desc in cursor.description]
records = cursor.fetchall()
return [{colname:value for colname, value in zip(colnames, record)} for record in records]
cursor.execute(
"""
SELECT churn, COUNT(*)
FROM telecom_churn
GROUP BY churn
"""
)
print(tabulate(fetch_all(cursor), "keys", "psql"))
+---------+---------+
| churn | count |
|---------+---------|
| False | 2850 |
| True | 483 |
+---------+---------+
Посмотрим на распределение пользователей по переменной area_code
. Нормализуем значения, чтобы посмотреть не абсолютные частоты, а относительные:
cursor.execute(
"""
SELECT area_code, ROUND((COUNT(*) / (SELECT COUNT(*) FROM telecom_churn)::numeric), 6)
FROM telecom_churn
GROUP BY area_code;
"""
)
print(tabulate(fetch_all(cursor), "keys", "psql"))
+-------------+----------+
| area_code | round |
|-------------+----------|
| 408 | 0.251425 |
| 510 | 0.252025 |
| 415 | 0.49655 |
+-------------+----------+
Сортировка
Упорядочим значения в порядке убывания по столбцу total_day_charge
:
cursor.execute("SELECT * FROM telecom_churn ORDER BY total_day_charge DESC LIMIT 5")
records = cursor.fetchall()
print(records)
[(365, 'CO', 154, 415, 'No', 'No', 0, 350.8, 75, 59.64, 216.5, 94, 18.4, 253.9, 100, 11.43, 10.1, 9, 2.73, 1, True),
(985, 'NY', 64, 415, 'Yes', 'No', 0, 346.8, 55, 58.96, 249.5, 79, 21.21, 275.4, 102, 12.39, 13.3, 9, 3.59, 1, True),
(2594, 'OH', 115, 510, 'Yes', 'No', 0, 345.3, 81, 58.7, 203.4, 106, 17.29, 217.5, 107, 9.79, 11.8, 8, 3.19, 1, True),
(156, 'OH', 83, 415, 'No', 'No', 0, 337.4, 120, 57.36, 227.4, 116, 19.33, 153.9, 114, 6.93, 15.8, 7, 4.27, 0, True),
(605, 'MO', 112, 415, 'No', 'No', 0, 335.5, 77, 57.04, 212.5, 109, 18.06, 265.0, 132, 11.93, 12.7, 8, 3.43, 2, True)]
Упорядочивать можно по нескольким столбцам:
cursor.execute("SELECT * FROM telecom_churn ORDER BY churn ASC, total_day_charge DESC LIMIT 5")
records = cursor.fetchall()
print(records)
[(688, 'MN', 13, 510, 'No', 'Yes', 21, 315.6, 105, 53.65, 208.9, 71, 17.76, 260.1, 123, 11.7, 12.1, 3, 3.27, 3, False),
(2259, 'NC', 210, 415, 'No', 'Yes', 31, 313.8, 87, 53.35, 147.7, 103, 12.55, 192.7, 97, 8.67, 10.1, 7, 2.73, 3, False),
(534, 'LA', 67, 510, 'No', 'No', 0, 310.4, 97, 52.77, 66.5, 123, 5.65, 246.5, 99, 11.09, 9.2, 10, 2.48, 4, False),
(575, 'SD', 114, 415, 'No', 'Yes', 36, 309.9, 90, 52.68, 200.3, 89, 17.03, 183.5, 105, 8.26, 14.2, 2, 3.83, 1, False),
(2858, 'AL', 141, 510, 'No', 'Yes', 28, 308.0, 123, 52.36, 247.8, 128, 21.06, 152.9, 103, 6.88, 7.4, 3, 2.0, 1, False)]
Извлечение данных
Ответим на вопрос: какова доля людей нелояльных пользователей в нашем датафрейме?
cursor.execute("SELECT AVG(churn::int) FROM telecom_churn")
print(tabulate(fetch_all(cursor), "keys", "psql"))
+----------+
| avg |
|----------|
| 0.144914 |
+----------+
Ответим на вопрос: каковы средние значения числовых признаков среди нелояльных пользователей?
from pprint import pprint as pp
cursor.execute("""
SELECT AVG(account_length), AVG(number_vmail_messages), AVG(total_day_minutes), AVG(total_day_calls),
AVG(total_day_charge), AVG(total_eve_minutes), AVG(total_eve_calls), AVG(total_eve_charge),
AVG(total_night_minutes), AVG(total_night_calls), AVG(total_night_charge), AVG(total_intl_minutes),
AVG(total_intl_calls), AVG(total_intl_charge), AVG(customer_service_calls), AVG(churn::int)
FROM telecom_churn WHERE churn = TRUE
""")
records = cursor.fetchall()
pp(records)
[(Decimal('102.6645962732919255'),
Decimal('5.1159420289855072'),
206.9140780984,
Decimal('101.3354037267080745'),
35.1759213532473,
212.410144829602,
Decimal('100.5610766045548654'),
18.0549689119153,
205.231677321914,
Decimal('100.3995859213250518'),
9.23552795029081,
10.6999999869684,
Decimal('4.1635610766045549'),
2.88954451525927,
Decimal('2.2298136645962733'),
Decimal('1.00000000000000000000'))]
Ответим на вопрос: сколько в среднем в течение дня разговаривают по телефону нелояльные пользователи?
cursor.execute("""
SELECT AVG(total_day_minutes) FROM telecom_churn WHERE churn = TRUE
""")
print(tabulate(fetch_all(cursor), "keys", "psql"))
+---------+
| avg |
|---------|
| 206.914 |
+---------+
Какова максимальная длина международных звонков среди лояльных пользователей (churn = FALSE
), не пользующихся услугой международного роуминга (international_plan = No
)?
cursor.execute("""
SELECT MAX(total_intl_minutes) FROM telecom_churn
WHERE churn = FALSE AND international_plan = 'No'
""")
print(tabulate(fetch_all(cursor), "keys", "psql"))
+-------+
| max |
|-------|
| 18.9 |
+-------+
Для замены значений в колонке можно воспользоваться CASE
, например:
cursor.execute("""
SELECT (CASE WHEN international_plan = 'No' THEN False ELSE True END) as international_plan
FROM telecom_churn
LIMIT 5
""")
print(tabulate(fetch_all(cursor), "keys", "psql"))
+----------------------+
| international_plan |
|----------------------|
| False |
| False |
| False |
| True |
| True |
+----------------------+
Группировка данных
Группирование данных в зависимости от значения признака churn
и вывод статистик по трём столбцам в каждой группе:
cursor.execute("""
SELECT COUNT(*),
AVG(total_day_minutes), STDDEV(total_day_minutes), MIN(total_day_minutes),
PERCENTILE_DISC(0.5) WITHIN GROUP (ORDER BY total_day_minutes) as "50%", MAX(total_day_minutes)
FROM telecom_churn
GROUP BY churn
""")
print(tabulate(fetch_all(cursor), "keys", "psql"))
+---------+---------+----------+-------+-------+-------+
| count | avg | stddev | min | 50% | max |
|---------+---------+----------+-------+-------+-------|
| 2850 | 175.176 | 50.1817 | 0 | 177.2 | 315.6 |
| 483 | 206.914 | 68.9978 | 0 | 217.6 | 350.8 |
+---------+---------+----------+-------+-------+-------+
Note
Примеры с таблицами сопряженности, аналогичными тем, которые рассмотрены в статье, будут показаны далее.
Допустим, мы хотим посмотреть, как наблюдения в нашей выборке распределены в контексте двух признаков: churn
и international_plan
:
cursor.execute("""
SELECT churn, international_plan, COUNT(*) FROM telecom_churn
GROUP BY churn, international_plan
""")
print(tabulate(fetch_all(cursor), "keys", "psql"))
+---------+----------------------+---------+
| churn | international_plan | count |
|---------+----------------------+---------|
| True | No | 346 |
| False | Yes | 186 |
| False | No | 2664 |
| True | Yes | 137 |
+---------+----------------------+---------+
Давайте посмотрим среднее число дневных, вечерних и ночных звонков для разных area_code
:
cursor.execute("""
SELECT area_code,
AVG(total_day_calls) as avg_total_day_calls,
AVG(total_eve_calls) as avg_total_eve_calls,
AVG(total_night_calls) as avg_total_night_calls
FROM telecom_churn
GROUP BY area_code
ORDER BY area_code
""")
print(tabulate(fetch_all(cursor), "keys", "psql"))
+-------------+-----------------------+-----------------------+-------------------------+
| area_code | avg_total_day_calls | avg_total_eve_calls | avg_total_night_calls |
|-------------+-----------------------+-----------------------+-------------------------|
| 408 | 100.496 | 99.7888 | 99.0394 |
| 415 | 100.576 | 100.504 | 100.398 |
| 510 | 100.098 | 99.6714 | 100.601 |
+-------------+-----------------------+-----------------------+-------------------------+
Хотим посчитать общее количество звонков для всех пользователей. Создадим временную таблицу и добавим в нее столбец total_calls
:
cursor.execute("""
CREATE TABLE telecom_churn_temp AS
SELECT *, (total_day_calls + total_eve_calls + total_night_calls + total_intl_calls) as total_calls
FROM telecom_churn
LIMIT 5;
SELECT total_calls FROM telecom_churn_temp
""")
print(tabulate(fetch_all(cursor), "keys", "psql"))
+---------------+
| total_calls |
|---------------|
| 303 |
| 332 |
| 333 |
| 255 |
| 359 |
+---------------+
Первые попытки прогнозирования оттока
Посмотрим, как отток связан с признаком «Подключение международного роуминга» (international_plan
). Сделаем это с помощью сводной таблицы crosstab:
cursor.execute("""
CREATE EXTENSION tablefunc;
SELECT Churn, SUM(No) as No, SUM(Yes) as Yes, SUM(No+Yes) as "All" FROM (
SELECT *
FROM crosstab('SELECT churn, international_plan, COUNT(*)::int FROM telecom_churn GROUP BY churn, international_plan ORDER BY 1,2')
AS (Churn BOOLEAN, No INTEGER, Yes INTEGER)
) results_tbl
GROUP BY rollup(Churn)
""")
print(tabulate(fetch_all(cursor), "keys", "psql"))
+---------+------+-------+-------+
| churn | no | yes | All |
|---------+------+-------+-------|
| False | 2664 | 186 | 2850 |
| True | 346 | 137 | 483 |
| | 3010 | 323 | 3333 |
+---------+------+-------+-------+
Далее посмотрим на еще один важный признак – «Число обращений в сервисный центр» (customer_service_calls
):
cursor.execute("""
SELECT Churn,
SUM("0") as "0", SUM("1") as "1", SUM("2") as "2", SUM("3") as "3",
SUM("4") as "4", SUM("5") as "5", SUM("6") as "6", SUM("7") as "7",
SUM("8") as "8", (CASE WHEN SUM("9") IS NULL THEN 0 ELSE SUM("9") END) as "9",
SUM("0"+"1"+"2"+"3"+"4"+"5"+"6"+"7"+"8"+(CASE WHEN "9" IS NULL THEN 0 ELSE "9" END)) as "ALL"
FROM (
SELECT * FROM crosstab(
'SELECT churn, customer_service_calls, COUNT(*)::int
FROM telecom_churn GROUP BY churn, customer_service_calls ORDER BY 1,2
') AS (
Churn BOOLEAN, "0" INTEGER, "1" INTEGER, "2" INTEGER, "3" INTEGER,
"4" INTEGER, "5" INTEGER, "6" INTEGER, "7" INTEGER, "8" INTEGER, "9" INTEGER)
) results
GROUP BY rollup(Churn)
""")
print(tabulate(fetch_all(cursor), "keys", "psql"))
+---------+-----+------+-----+-----+-----+-----+-----+-----+-----+-----+-------+
| churn | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | ALL |
|---------+-----+------+-----+-----+-----+-----+-----+-----+-----+-----+-------|
| False | 605 | 1059 | 672 | 385 | 90 | 26 | 8 | 4 | 1 | 0 | 2850 |
| True | 92 | 122 | 87 | 44 | 76 | 40 | 14 | 5 | 1 | 2 | 483 |
| | 697 | 1181 | 759 | 429 | 166 | 66 | 22 | 9 | 2 | 2 | 3333 |
+---------+-----+------+-----+-----+-----+-----+-----+-----+-----+-----+-------+
Добавим бинарный признак — результат сравнения customer_service_calls > 3
. И еще раз посмотрим, как он связан с оттоком:
cursor.execute("""
SELECT Churn, SUM("0") as "0", SUM("1") as "1", SUM("0"+"1") as "ALL"
FROM (
SELECT * FROM crosstab('
SELECT churn, (CASE WHEN customer_service_calls > 3 THEN 1 ELSE 0 END) as many_service_calls, COUNT(*)::int
FROM telecom_churn GROUP BY churn, many_service_calls ORDER BY 1,2
') AS (
Churn BOOLEAN, "0" INTEGER, "1" INTEGER
)
) results
GROUP BY rollup(Churn)
""")
print(tabulate(fetch_all(cursor), "keys", "psql"))
+---------+------+-----+-------+
| churn | 0 | 1 | ALL |
|---------+------+-----+-------|
| False | 2721 | 129 | 2850 |
| True | 345 | 138 | 483 |
| | 3066 | 267 | 3333 |
+---------+------+-----+-------+
Объединим рассмотренные выше условия и построим сводную таблицу для этого объединения и оттока:
cursor.execute("""
SELECT Churn, SUM("0") as "0", SUM("1") as "1", SUM("0"+"1") as "ALL"
FROM (
SELECT * FROM crosstab('
SELECT churn, (
CASE
WHEN customer_service_calls > 3 AND international_plan LIKE $$Yes$$
THEN 1
ELSE 0
END) as many_calls_and_plan, COUNT(*)::int
FROM telecom_churn GROUP BY churn, many_calls_and_plan ORDER BY 1,2
') AS (
Churn BOOLEAN, "0" INTEGER, "1" INTEGER
)
) results
GROUP BY rollup(Churn)
""")
print(tabulate(fetch_all(cursor), "keys", "psql"))
+---------+------+-----+-------+
| churn | 0 | 1 | ALL |
|---------+------+-----+-------|
| False | 2841 | 9 | 2850 |
| True | 464 | 19 | 483 |
| | 3305 | 28 | 3333 |
+---------+------+-----+-------+