Открытый курс машинного обучения. Тема 2: Визуализация данных c Python

62b42bb533f44cbaa8d6306332512555.png

Привет всем, кто начал проходить курс! Новые участники, добро пожаловать! Второе занятие посвящено визуализации данных в Python. Сначала мы посмотрим на основные методы библиотек Seaborn и Plotly, затем поанализируем знакомый нам по первой статье набор данных по оттоку клиентов телеком-оператора.


Напомним, что к курсу еще можно подключиться, дедлайн по 1 домашнему заданию — 6 марта 23:59.


Сейчас статья уже будет существенно длиннее. Готовы? Поехали!



План этой статьи


  • Демонстрация основных методов Seaborn и Plotly
  • Пример визуального анализа данных
  • Подглядывание в n-мерное пространство с t-SNE
  • Домашнее задание №2
  • Обзор полезных ресурсов

Демонстрация основных методов Seaborn и Plotly

В начале как всегда настроим окружение: импортируем все необходимые библиотеки и немного настроим дефолтное отображение картинок.


# Python 2 and 3 compatibility
# pip install future
from __future__ import (absolute_import, division,
                        print_function, unicode_literals)
# отключим предупреждения Anaconda
import warnings
warnings.simplefilter('ignore')

# будем отображать графики прямо в jupyter'e
%pylab inline

#увеличим дефолтный размер графиков
from pylab import rcParams
rcParams['figure.figsize'] = 8, 5
import pandas as pd
import seaborn as sns

После этого загрузим в dataframe данные, с которыми будем работать. Для примеров я выбрала данные о продажах и оценках видео-игр из Kaggle Datasets.


df = pd.read_csv('../../data/video_games_sales.csv')
df.info()


RangeIndex: 16719 entries, 0 to 16718
Data columns (total 16 columns):
Name               16717 non-null object
Platform           16719 non-null object
Year_of_Release    16450 non-null float64
Genre              16717 non-null object
Publisher          16665 non-null object
NA_Sales           16719 non-null float64
EU_Sales           16719 non-null float64
JP_Sales           16719 non-null float64
Other_Sales        16719 non-null float64
Global_Sales       16719 non-null float64
Critic_Score       8137 non-null float64
Critic_Count       8137 non-null float64
User_Score         10015 non-null object
User_Count         7590 non-null float64
Developer          10096 non-null object
Rating             9950 non-null object
dtypes: float64(9), object(7)
memory usage: 2.0+ MB

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


df = df.dropna()
print(df.shape)

(6825, 16)

Всего в таблице 6825 объектов и 16 признаков для них. Посмотрим на несколько первых записей c помощью метода head, чтобы убедиться, что все распарсилось правильно. Для удобства я оставила только те признаки, которые мы будем в дальнейшем использовать.


useful_cols = ['Name', 'Platform', 'Year_of_Release', 'Genre', 
               'Global_Sales', 'Critic_Score', 'Critic_Count',
               'User_Score', 'User_Count', 'Rating'
              ]
df[useful_cols].head()

img


Прежде чем мы перейдем к рассмотрению методов библиотек seaborn и plotly, обсудим самый простой и зачастую удобный способ визуализировать данные из pandas dataframe — это воспользоваться функцией plot.
Для примера построим график продаж видео игр в различных странах в зависимости от года. Для начала отфильтруем только нужные нам столбцы, затем посчитаем суммарные продажи по годам и у получившегося dataframe вызовем функцию plot без параметров.


sales_df = df[[x for x in df.columns if 'Sales' in x] + ['Year_of_Release']]
sales_df.groupby('Year_of_Release').sum().plot()

Реализация функции plot в pandas основана на библиотеке matplotlib.


img


C помощью параметра kind можно изменить тип графика, например, на bar chart. Matplotlib позволяет очень гибко настраивать графики. На графике можно изменить почти все, что угодно, но потребуется порыться в документации и найти нужные параметры. Например, параметра rot отвечает за угол наклона подписей к оси x.


sales_df.groupby('Year_of_Release').sum().plot(kind='bar', rot=45)

img


Seaborn


Теперь давайте перейдем к библиотеке seaborn. Seaborn — это по сути более высокоуровневое API на базе библиотеки matplotlib. Seaborn содержит более адекватные дефолтные настройки оформления графиков. Если просто добавить в код import seaborn, то картинки станут гораздо симпатичнее. Также в библиотеке есть достаточно сложные типы визуализации, которые в matplotlib потребовали бы большого количество кода.


Познакомимся с первым таким «сложным» типом графиков pair plot (scatter plot matrix). Эта визуализация поможет нам посмотреть на одной картинке, как связаны между собой различные признаки.


cols = ['Global_Sales', 'Critic_Score', 'Critic_Count', 'User_Score', 'User_Count']
sns_plot = sns.pairplot(df[cols])
sns_plot.savefig('pairplot.png')

Как можно видеть, на диагонали матрицы графиков расположены гистограммы распределений признака. Остальные же графики — это обычные scatter plots для соответствующих пар признаков.


Для сохранения графиков в файлы стоит использовать метод savefig.


img


С помощью seaborn можно построить и распределение dist plot. Для примера посмотрим на распределение оценок критиков Critic_Score. По default’у на графике отображается гистограмма и kernel density estimation.


sns.distplot(df.Critic_Score)

img


Для того, чтобы подробнее посмотреть на взаимосвязь двух численных признаков, есть еще и joint plot — это гибрид scatter plot и histogram. Посмотрим на то, как связаны между собой оценка критиков Critic_Score и оценка пользователя User_Score.


img


Еще один полезный тип графиков — это box plot. Давайте сравним оценки игр от критиков для топ-5 крупнейших игровых платформ.


top_platforms = df.Platform.value_counts().sort_values(ascending = False).head(5).index.values
sns.boxplot(y="Platform", x="Critic_Score", data=df[df.Platform.isin(top_platforms)], orient="h")

img


Думаю, стоит обсудить немного подробнее, как же понимать box plot. Box plot состоит из коробки (поэтому он и называется box plot), усиков и точек. Коробка показывает интерквартильный размах распределения, то есть соответственно 25% (Q1) и 75% (Q3) перцентили. Черта внутри коробки обозначает медиану распределения.
С коробкой разобрались, перейдем к усам. Усы отображают весь разброс точек кроме выбросов, то есть минимальные и максимальные значения, которые попадают в промежуток (Q1 - 1.5*IQR, Q3 + 1.5*IQR), где IQR = Q3 - Q1 — интерквартильный размах. Точками на графике обозначаются выбросы (outliers) — те значения, которые не вписываются в промежуток значений, заданный усами графика.


Для понимания лучше один раз увидеть, поэтому вот еще и картинка с wikipedia:
img


И еще один тип графиков (последний из тех, которые мы рассмотрим в этой статье) — это heat map. Heat map позволяет посмотреть на распределение какого-то численного признака по двум категориальным. Визуализируем суммарные продажи игр по жанрам и игровым платформам.


platform_genre_sales = df.pivot_table(
                        index='Platform', 
                        columns='Genre', 
                        values='Global_Sales', 
                        aggfunc=sum).fillna(0).applymap(float)
sns.heatmap(platform_genre_sales, annot=True, fmt=".1f", linewidths=.5)

img


Plotly


Мы рассмотрели визуализации на базе библиотеки matplotlib. Однако это не единственная опция для построения графиков на языке python. Познакомимся также с библиотекой plotly. Plotly — это open-source библиотека, которая позволяет строить интерактивные графики в jupyter.notebook’e без необходимости зарываться в javascript код.


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


Перед началом работы импортируем все необходимые модули и инициализируем plotly с помощью команды init_notebook_mode.


from plotly.offline import download_plotlyjs, init_notebook_mode, plot, iplot
import plotly
import plotly.graph_objs as go

init_notebook_mode(connected=True)

Для начала построим line plot с динамикой числа вышедших игр и их продаж по годам.


# посчитаем число вышедших игр и проданных копий по годам
years_df = df.groupby('Year_of_Release')[['Global_Sales']].sum().join(
    df.groupby('Year_of_Release')[['Name']].count()
)
years_df.columns = ['Global_Sales', 'Number_of_Games']

# создаем линию для числа проданных копий
trace0 = go.Scatter(
    x=years_df.index,
    y=years_df.Global_Sales,
    name='Global Sales'
)

# создаем линию для числа вышедших игр 
trace1 = go.Scatter(
    x=years_df.index,
    y=years_df.Number_of_Games,
    name='Number of games released'
)

# определяем массив данных и задаем title графика в layout
data = [trace0, trace1]
layout = {'title': 'Statistics of video games'}

# cоздаем объект Figure и визуализируем его
fig = go.Figure(data=data, layout=layout)
iplot(fig, show_link=False)

В plotly строится визуализация объекта Figure, который состоит из данных (массив линий, которые в библиотеке называются traces) и оформления/стиля, за который отвечает объект layout. В простых случаях можно вызывать функцию iplot и просто от массива traces.


Параметр show_link отвечает за ссылки на online-платформу plot.ly на графиках. Поскольку обычно это функциональность не нужна, то я предпочитаю скрывать ее для предотвращения случайных нажатий.


img


Можно сразу сохранить график в виде html-файла.


plotly.offline.plot(fig, filename='years_stats.html', show_link=False)

Посмотрим также на рыночную долю игровых платформ, рассчитанную по количеству выпущенных игр и по суммарной выручке. Для этого построим bar chart.


# считаем число проданных и вышедших игр по платформам
platforms_df = df.groupby('Platform')[['Global_Sales']].sum().join(
    df.groupby('Platform')[['Name']].count()
)
platforms_df.columns = ['Global_Sales', 'Number_of_Games']
platforms_df.sort_values('Global_Sales', ascending=False, inplace=True)

# создаем traces для визуализации
trace0 = go.Bar(
    x=platforms_df.index,
    y=platforms_df.Global_Sales,
    name='Global Sales'
)

trace1 = go.Bar(
    x=platforms_df.index,
    y=platforms_df.Number_of_Games,
    name='Number of games released'
)

# создаем массив с данными и задаем title для графика и оси x в layout
data = [trace0, trace1]
layout = {'title': 'Share of platforms', xaxis = {'title': 'platform'}}

# создаем объект Figure и визуализируем его
fig = go.Figure(data=data, layout=layout)
iplot(fig, show_link=False)

img


В plotly можно построить и box plot. Рассмотрим распределения оценок критиков в зависимости от жанра игры.



# создаем Box trace для каждого жанра из наших данных
data = []
for genre in df.Genre.unique():
    data.append(
        go.Box(y=df[df.Genre==genre].Critic_Score, name=genre)
    )

# визуализируем данные
iplot(data, show_link = False)

img


С помощью plotly можно построить и другие типы визуализаций. Графики получаются достаточно симпатичными с дефолтными настройками. Однако библиотека позволяет и гибко настраивать различные параметры визуализации: цвета, шрифты, подписи, аннотации и многое другое.


Пример визуального анализа данных

Считываем в DataFrame знакомые нам по первой статье данные по оттоку клиентов телеком-оператора.


df = pd.read_csv('../../data/telecom_churn.csv')

Проверим, все ли нормально считалось — посмотрим на первые 5 строк (метод head).


df.head()

9780226a800b4a1da342daaa966b4a0e.png

Число строк (клиентов) и столбцов (признаков):


df.shape

(3333, 20)

Посмотрим на признаки и убедимся, что пропусков ни в одном из них нет — везде по 3333 записи.


df.info()


RangeIndex: 3333 entries, 0 to 3332
Data columns (total 20 columns):
State                     3333 non-null object
Account length            3333 non-null int64
Area code                 3333 non-null int64
International plan        3333 non-null object
Voice mail plan           3333 non-null object
Number vmail messages     3333 non-null int64
Total day minutes         3333 non-null float64
Total day calls           3333 non-null int64
Total day charge          3333 non-null float64
Total eve minutes         3333 non-null float64
Total eve calls           3333 non-null int64
Total eve charge          3333 non-null float64
Total night minutes       3333 non-null float64
Total night calls         3333 non-null int64
Total night charge        3333 non-null float64
Total intl minutes        3333 non-null float64
Total intl calls          3333 non-null int64
Total intl charge         3333 non-null float64
Customer service calls    3333 non-null int64
Churn                     3333 non-null bool
dtypes: bool(1), float64(8), int64(8), object(3)
memory usage: 498.1+ KB

Описание признаков
Название Описание Тип
State Буквенный код штата номинальный
Account length Как долго клиент обслуживается компанией количественный
Area code Префикс номера телефона количественный
International plan Международный роуминг (подключен/не подключен) бинарный
Voice mail plan Голосовая почта (подключена/не подключена) бинарный
Number vmail messages Количество голосовых сообщений количественный
Total day minutes Общая длительность разговоров днем количественный
Total day calls Общее количество звонков днем количественный
Total day charge Общая сумма оплаты за услуги днем количественный
Total eve minutes Общая длительность разговоров вечером количественный
Total eve calls Общее количество звонков вечером количественный
Total eve charge Общая сумма оплаты за услуги вечером количественный
Total night minutes Общая длительность разговоров ночью количественный
Total night calls Общее количество звонков ночью количественный
Total night charge Общая сумма оплаты за услуги ночью количественный
Total intl minutes Общая длительность международных разговоров количественный
Total intl calls Общее количество международных разговоров количественный
Total intl charge Общая сумма оплаты за международные разговоры количественный
Customer service calls Число обращений в сервисный центр количественный

Целевая переменная: Churn — Признак оттока, бинарный (1 — потеря клиента, то есть отток). Потом мы будем строить модели, прогнозирующие этот признак по остальным, поэтому мы и назвали его целевым.


Посмотрим на распределение целевого класса — оттока клиентов.


df['Churn'].value_counts()

False    2850
True      483
Name: Churn, dtype: int64

df['Churn'].value_counts().plot(kind='bar', label='Churn')
plt.legend()
plt.title('Распределение оттока клиентов');

3638374ca01a4777b9c7280e30669b7d.png


Выделим следующие группы признаков (среди всех кроме Churn):


  • бинарные: International plan, Voice mail plan
  • категориальные: State
  • порядковые: Customer service calls
  • количественные: все остальные

Посмотрим на корреляции количественных признаков. По раскрашенной матрице корреляций видно, что такие признаки как Total day charge считаются по проговоренным минутам (Total day minutes). То есть 4 признака можно выкинуть, они не несут полезной информации.


corr_matrix = df.drop(['State', 'International plan', 'Voice mail plan',
                      'Area code'], axis=1).corr()

sns.heatmap(corr_matrix);

14861103fbf14dff9c56802f709f0749.png


Теперь посмотрим на распределения всех интересующих нас количественных признаков. На бинарные/категориальные/порядковые признакие будем смотреть отдельно.


features = list(set(df.columns) - set(['State', 'International plan', 'Voice mail plan',  'Area code',
                                      'Total day charge',   'Total eve charge',   'Total night charge',
                                        'Total intl charge', 'Churn']))

df[features].hist(figsize=(20,12));

de301b18f8e5459b9ad8158314a80769.png


Видим, что большинство признаков распределены нормально. Исключения — число звонков в сервисный центр (Customer service calls) (тут больше подходит пуассоновское распределение) и число голосовых сообщений (Number vmail messages, пик в нуле, т.е. это те, у кого голосовая почта не подключена). Также смещено распределение числа международных звонков (Total intl calls).


Еще полезно строить вот такие картинки, где на главной диагонали рисуются распредления признаков, а вне главной диагонали — диаграммы рассеяния для пар признаков. Бывает, что это приводит к каким-то выводам, но в данном случае все примерно понятно, без сюрпризов.


sns.pairplot(df[features + ['Churn']], hue='Churn');

aef703336cbf42feabe289b0c849dc2b.png


Дальше посмотрим, как признаки связаны с целевым — с оттоком.


Построим boxplot-ы, описывающее статистики распределения количественных признаков в двух группах: среди лояльных и ушедших клиентов.


fig, axes = plt.subplots(nrows=3, ncols=4, figsize=(16, 10))

for idx, feat in  enumerate(features):
    sns.boxplot(x='Churn', y=feat, data=df, ax=axes[idx / 4, idx % 4])
    axes[idx / 4, idx % 4].legend()
    axes[idx / 4, idx % 4].set_xlabel('Churn')
    axes[idx / 4, idx % 4].set_ylabel(feat);

ca896ce024c5491fa8f21672efa8ab6e.png


На глаз наибольшее отличие мы видим для признаков Total day minutes, Customer service calls и Number vmail messages. Впоследствии мы научимся определять важность признаков в задаче классификации с помощью случайного леса (или градиентного бустинга), и окажется, что первые два — действительно очень важные признаки для прогнозирования оттока.


Посмотрим отдельно на картинки с распределением кол-ва проговоренных днем минут среди лояльных/ушедших. Слева — знакомые нам боксплоты, справа — сглаженные гистограммы распределения числового признака в двух группах (скорее просто красивая картинка, все и так понятно по боксплоту).


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


_, axes = plt.subplots(1, 2, sharey=True, figsize=(16,6))

sns.boxplot(x='Churn', y='Total day minutes', data=df, ax=axes[0]);
sns.violinplot(x='Churn', y='Total day minutes', data=df, ax=axes[1]);

f19c94facf30431cb37804b449bac34b.png


Теперь изобразим распределение числа обращений в сервисный центр (такую картинку мы строили в первой статье). Тут уникальных значений признака не много (признак можно считать как количественным целочисленным, так и порядковым), и наглядней изобразить распределение с помощью countplot. Наблюдение: доля оттока сильно возрастает начиная с 4 звонков в сервисный центр.


sns.countplot(x='Customer service calls', hue='Churn', data=df);

43c30f8b7053410c90e575d5b26d3ae1.png


Теперь посмотрим на связь бинарных признаков International plan и Voice mail plan с оттоком. Наблюдение: когда роуминг подключен, доля оттока намного выше, т.е. наличие международного роуминга — сильный признак. Про голосовую почту такого нельзя сказать.


_, axes = plt.subplots(1, 2, sharey=True, figsize=(16,6))

sns.countplot(x='International plan', hue='Churn', data=df, ax=axes[0]);
sns.countplot(x='Voice mail plan', hue='Churn', data=df, ax=axes[1]);

60f6d06912784245acb19571361e6627.png


Наконец, посмотрим, как с оттоком связан категориальный признак State. С ним уже не так приятно работать, поскольку число уникальных штатов довольно велико — 51. Можно в начале построить сводную табличку или посчитать процент оттока для каждого штата. Но данных по каждом штату по отдельности маловато (ушедших клиентов всего от 3 до 17 в каждом штате), поэтому, возможно, признак State впоследствии не стоит добавлять в модели классификации из-за риска переобучения (но мы это будем проверять на кросс-валидации, stay tuned!).


Доли оттока для каждого штата:


df.groupby(['State'])['Churn'].agg([np.mean]).sort_values(by='mean', ascending=False).T

e0846091885244e9b124207a834e908f.png

3c4d55e55f9044f0836fc513598de442.png


Видно, что в Нью-Джерси и Калифорнии доля оттока выше 25%, а на Гавайях и в Аляске меньше 5%. Но эти выводы построены на слишком скромной статистике и возможно, это просто особенности имеющихся данных (тут можно и гипотезы попроверять про корреляции Мэтьюса и Крамера, но это уже за рамками данной статьи).


Подглядывание в n-мерное пространство с t-SNE

Построим t-SNE представление все тех же данных по оттоку. Название метода сложное — t-distributed Stohastic Neighbor Embedding, математика тоже крутая (и вникать в нее не будем, но для желающих — вот оригинальная статья Д. Хинтона и его аспиранта в JMLR), но основная идея проста, как дверь: найдем такое отображение из многомерного признакового пространства на плоскость (или в 3D, но почти всегда выбирают 2D), чтоб точки, которые были далеко друг от друга, на плоскости тоже оказались удаленными, а близкие точки — также отобразились на близкие. То есть neighbor embedding — это своего рода поиск нового представления данных, при котором сохраняется соседство.


Немного деталей: выкинем штаты и признак оттока, бинарные Yes/No-признаки переведем в числа (pd.factorize). Также нужно масштабировать выборку — из каждого признака вычесть его среднее и поделить на стандартное отклонение, это делает StandardScaler.


from sklearn.manifold import TSNE
from sklearn.preprocessing import StandardScaler

# преобразуем все признаки в числовые, выкинув штаты
X = df.drop(['Churn', 'State'], axis=1)
X['International plan'] = pd.factorize(X['International plan'])[0]
X['Voice mail plan'] = pd.factorize(X['Voice mail plan'])[0]

scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

%%time
tsne = TSNE(random_state=17)
tsne_representation = tsne.fit_transform(X_scaled)

CPU times: user 20 s, sys: 2.41 s, total: 22.4 s
Wall time: 21.9 s

plt.scatter(tsne_representation[:, 0], tsne_representation[:, 1]);

5ad04843caac44f7890fd61a0d9bb9b1.png


Раскрасим полученное t-SNE представление данных по оттоку (зеленые — лояльные, красные — ушедшие клиенты).


plt.scatter(tsne_representation[:, 0], tsne_representation[:, 1], 
            c=df['Churn'].map({0: 'green', 1: 'red'}));

ca21921c11a44aa787dcedc74b7abe19.png


Видим, что ушедшие клиенты преимущественно «кучкуются» в некоторых областях признакового пространства.


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


_, axes = plt.subplots(1, 2, sharey=True, figsize=(16,6))

axes[0].scatter(tsne_representation[:, 0], tsne_representation[:, 1], 
            c=df['International plan'].map({'Yes': 'green', 'No': 'red'}));
axes[1].scatter(tsne_representation[:, 0], tsne_representation[:, 1], 
            c=df['Voice mail plan'].map({'Yes': 'green', 'No': 'red'}));
axes[0].set_title('International plan');
axes[1].set_title('Voice mail plan');

29efaf56ad824fba914123fe51871d92.png


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


Напоследок отметим минусы t-SNE (да, по нему тоже лучше писать отдельную статью):


  • большая вычислительная сложность. Вот эта реализация sklearn скорее всего не поможет в Вашей реальной задаче, на больших выборках стоит посмотреть в сторону Multicore-TSNE;
  • картинка может сильно поменяться при изменении random seed, это усложняет интерпретацию. Вот хороший тьюториал по t-SNE. Но в целом по таким картинкам не стоит делать далеко идущих выводов — не стоит гадать по кофейной гуще. Иногда что-то бросается в глаза и подтверждается при изучении, но это не часто происходит.

И еще пара картинок. С помощью t-SNE можно действительно получить хорошее представление о данных (как в случае с рукописными цифрами, вот хорошая статья), а можно просто нарисовать елочную игрушку.


5839c1f2f402489aaa928e593e9b1153.png

ad90891a141c448f90de89c34a79cc9f.jpg

Домашнее задание № 2


Второе домашнее задание посвящено анализу набора данных по популярности статей на Хабрахабре.


Мы предлагаем выполнить это задание и затем ответить на несколько вопросов. Ссылка на форму для ответов (она же — в тетрадке). Ответы в форме можно менять после отправки, но не после дедлайна.


Дедлайн: 13 марта 23:59 (жесткий).


Полезные ресурсы


  • Прежде всего, официальная документация и галерея примеров различных графиков для seaborn
  • При работе с plotly также поможет официальный сайт: полная документация, большое количество разобранных примеров
  • Кроме того, примеры анализа данных и визуализаций на plotly можно посмотреть в моей статье на Хабрахабре Немного про кино или как делать интерактивные визуализации в python. Из не расcмотренного здесь, но иногда полезного, в статье можно найти пример графика с drop-down menu.

Статья написана в соавторстве с yorko при поддержке комьюнити Open Data Science. Автор домашнего задания — cotique

Комментарии (2)

  • 6 марта 2017 в 16:04

    0

    А планируется статья про bokeh, folium и другие? Или они уже не торт по сравнению с seaborn и plotly и не стоит про них упоминать?
  • 6 марта 2017 в 16:09

    0

    Небольшой фидбек по 1 ДЗ, краткая стата по оценкам и решение на GitHub будут сразу после дедлайна.
    Также как и список фильмов из первого опроса :)

© Habrahabr.ru