Реализация нейронной сети для соревнования Digit Recognizer на Kaggle и её прикладное использование. Часть №1

В данной статье будет рассмотрено одно из решений обучающей задачи на платформе Kaggle по распознаванию рукописных цифр. Будут продемонстрированы несколько трюков, которые могут помочь читателю добиться высоких результатов в данном соревновании. После реализации нейронной сети будет реализовано серверное и веб-приложение, с помощью которых пользователь сможет рисовать цифры и распознавать их с помощью нейронной сети. Статья ориентирована на начинающих специалистов в области машинного обучения и не носит новаторский характер. Списки на используемые источники (в том числе исходный код) будут представлены в конце статьи. Решения не новы, однако с их помощью можно достичь высоких результатов. Например, автору удалось добиться score равному 0.99896, а с помощью читерства — 1.

077a0b4e9422f4e0b50d60365fa00ed2.png

Введение

В настоящее время существует большое число решений классической задачи Digit Recognizer на платформе Kaggle. Участники представляют свои решения значение score в которых могут быть самые разные и не всегда удаётся повторить успех автора исходного решения, которое довольно часто разбирается новичками решившими взобраться на вершину лидерборда. Чтобы добиться высоких результатов существующего обучающего набора может быть недостаточно и приходится искать обходные пути с помощью применения различных архитектур или наращивания обучающего набора (часто это делают через аугментацию, но в статье будет рассмотрен ещё один приём). Однако из большого числа найденных мной решений лишь не многие позволяли продвигаться по лидерборду и улучшать показатель своего score на более существенные значения. В каждом решении есть определённые недостатки, но один из них самый существенный и распространяется на все решения и это нехватка данных в датасете — проблема, которая будет решена в рамках данной статьи.

Постановка задачи

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

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

Разработка нейронной сети

Подготовка данных

Для начала отметим, что данных для обучения с соревнования Kaggle достаточно мало, чтобы хорошо обучить модель для распознавания цифр — их всего 42000:

# Тренировочный датасет MNIST с соревнования Kaggle
train_dataset = np.loadtxt('train.csv', skiprows=1, delimiter=',')
train_dataset.shape # (42000, 785)

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

# Генератор новых данных
datagen = ImageDataGenerator(
   rotation_range=10,          # Поворот на случайный угол до 10 градусов
   zoom_range=0.1,             # Увеличение размера до 10 %
   width_shift_range=0.1,      # Сдвих влево / вправо до 10 процентов
   height_shift_range=0.1,     # Сдвиг вверх / вниз до 10 процентов
)

Помимо этого следует воспользоваться трюком, с помощью которого мы увеличим обучающую выборку до 112000 записей. Заключается трюк в том, чтобы просто объединить обучающий набор из соревнования в Kaggle и обучающий / тестовый наборы из стандартного датасета MNIST, который также расположен на Kaggle.

Достаточно простой трюк. Следующий код его реализует:

# Тренировочный датасет стандартного MNIST
train_mnist = np.loadtxt('mnist_train.csv', skiprows=1, delimiter=',')

# Тестовый датасет стандартного MNIST
test_mnist = np.loadtxt('mnist_test.csv', skiprows=1, delimiter=',')

# Тренировочный датасет MNIST с соревнования Kaggle
train_dataset = np.loadtxt('train.csv', skiprows=1, delimiter=',')

# Объединяем датасеты
dataset = np.concatenate((train_dataset, train_mnist, test_mnist))

print(dataset.shape) # (112000, 785)

Между тренировочным датасетом с соревнования Kaggle и стандартным датасетом MNIST есть небольшая разница: в стандартном датасете записей на 18000 больше.

train_dataset.shape # (42000, 785)

train_mnist.shape # (60000, 785)

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

И да, датасеты абсолютно одинаковые, просто в соревновании не 60000 записей в обучающей выборке на 10000 в тестовой, а 42000 на 28000. В сумме одно и тоже — 70000.

С помощью данного трюка также происходит обучение на модифицированной тестовой выборке. Просто она модифицируется во время обучения при работе генератора новых данных (ImageDataGenerator). В этом и кроется суть трюка — наша модель максимально близка к тестовому набору данных соревнования, чтобы можно было их правильнее определить. Ведь модель может найти признаки в модифицированной тестовой выборке, а затем легко их определить и в целевой тестовой выборке. Здесь я ещё раз напомню о задаче — главное реализовать нейронную сеть, которая получит score максимально приближённое или равное единице.

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

# Выделяем данные для обучения (без первого столбца с ответами)
x_train = dataset[:, 1:]

# Изменяем размер данных обучающей выборки (28x28x2)
x_train = x_train.reshape(x_train.shape[0], 28, 28, 1)

# Размер входа
input_shape = (28, 28, 1)

Как видно из программного кода сначала мы выделяем отдельно обучающую выборку (без ответов, просто данные), а затем изменяем размер обучающей выборке. Изменить размер выборки получилось благодаря удалению столбцов ответов (785 -1 = 784 = 28×28). Если бы мы его не удаляли, у нас была бы ошибка:

Рисунок 1 - Ошибка если столбец ответов не был удалён

Рисунок 1 — Ошибка если столбец ответов не был удалён

Поэтому всегда важно следить за размерами в своих данных и какие данные вообще удаляются или выделяются в отдельное множество.

После того, как мы выделили обучающую выборку от ответов, пора нормализовать данные. Для этого достаточно поделить все элементы многомерного массива на 255:

# Нормализация данных для обучения
x_train /= 255.0

Почему 255, а не, скажем, 127? Или может быть 65? Всё достаточно просто.

В многомерном массива x_train содержится обучающая выборка, каждый элемент который представляет собой определённый массив, состоящий из значений от 0 до 255, и этот диапазон выбран не случайно — это интенсивность цветов, берущая своё начало из цветовой кодировки RGB. Полезный материал по данной теме будет представлен в конце статьи. А мы продолжаем.

Теперь необходимо выделить ответы от обучающей выборки и преобразовать их в формат понятный машине one hot encoding данный формат достаточно популярен в задачах, где необходимо сделать какую-либо классификацию по категориальным признакам. Сделать это можно с помощью следующего кода:

# Выделяем правильные ответы
y_train = dataset[:, 0]

# Преобразуем ответы в формат one hot encoding
y_train = utils.to_categorical(y_train)

Рисунок 2 - Ответы в формате one hot encoding

Рисунок 2 — Ответы в формате one hot encoding

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

Т.е. требуется разделить обучающую выборку и столбец ответов на две выборки — обучающую и тестовую. Получается небольшая тавтология, ведь обучающая выборка уже есть, а нам нужна ещё одна. Но этому есть логическое объяснение, которое я дам чуть позже, когда модель будет непосредственно учиться.

# Разделяем данные на два набора - для обучающей выборки и для тестирования
X_train, X_val, Y_train, Y_val = train_test_split(x_train, y_train, test_size = 0.1, random_state=random_seed)

Разделение обучающей выборки и ответов на под выборки реализовано с помощью утилиты train_test_split. Ей были переданы такие параметры, как:

  1. Обучающая выборка

  2. Выборка ответов выделенная из обучающей выборки

  3. Размер тестовой выборки (в данном случае — 10% от обучающей)

  4. random_state, который равен инициализатору генератора случайных чисел

Для справки (random seed)

Random seed (рандомное зерно) — это значение, используемое для инициализации генератора случайных чисел. Генератор случайных чисел — это алгоритм, который создает последовательность чисел, которая кажется случайной. Random seed позволяет сделать эту последовательность более детерминированной и повторяемой. То есть, указав определенное random seed мы можем получить одинаковую последовательность чисел при каждом запуске генератора случайных чисел.

Генератор псевдослучайных чисел — это алгоритм, который создаёт последовательность чисел, которая кажется случайной. Рандомное зерно позволяет сделать эту последовательность более детерменированной и повторяемой. Указав определённое рандомное зерно мы можем получить одинаковую последовательность чисел при каждом новом запуске генератора псевдослучайных чисел.

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

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

X_train.shape # (100800, 28, 28, 1)

Всё же больше 100000 записей лучше, чем меньше 50000.

Теперь рассмотрим пример того, как работает механизм аугментации. Т.е. каким образом данные модифицируются.

Ранее в статье я уже описал модель своего ImageDataGenerator

datagen = ImageDataGenerator(
   rotation_range=10,          # Поворот на случайный угол до 10 градусов
   zoom_range=0.1,            # Увеличение размера до 10 %
   width_shift_range=0.1,      # Сдвих влево / вправо до 10 процентов
   height_shift_range=0.1,     # Сдвиг вверх / вниз до 10 процентов
)

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

Рисунок 3 - Аугментация данных для цифры 6 (к этому числу мы ещё вернёмся ... )

Рисунок 3 — Аугментация данных для цифры 6 (к этому числу мы ещё вернёмся …)

Как мы можем видеть, цифра изменяет своё положение и в целом идут определённые изменения, что можно оценить невооруженным глазом.

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

Разработка архитектуры нейронной сети

Для начала, приведу полный листинг программного кода архитектуры нейронной сети:

# Создание свёрточной нейронной сети

model = Sequential()

# Входной слой
model.add(Input(shape=(28, 28, 1)))
# Добавление строк и столбцов с нулями сверху, снизу, слева и справа от изображения
model.add(ZeroPadding2D(padding=(1, 1), input_shape=(28, 28, 1)))
# Слой свёрточной сети с ядром свёртки размера 5x5 и фильтром 32
model.add(Conv2D(filters = 32, kernel_size = (5,5), padding = 'Same',
                 activation ='relu', input_shape = (28,28,1)))
# Batch-нормализация (ускоряем обучение, стабилизируем нейронную сеть)
model.add(BatchNormalization())
# Слой свёртки с ядром свёртки 5x5 и фильтром 32
model.add(Conv2D(filters = 32, kernel_size = (5,5),padding = 'Same',
                 activation ='relu'))
model.add(BatchNormalization())
# Слой функции активации "relu"
model.add(Activation(activations.relu))
# Максимальная операция объединения в пул для 2D-пространственных данных
model.add(MaxPooling2D(pool_size=(2,2)))
# Добавление нулевых строк и столбцов
model.add(ZeroPadding2D(padding=(1, 1)))
# Применение метода регуляризации, который случайным образом отключает от изменения 20% нейронов
model.add(Dropout(0.2))

# Свёрточный слой с ядром 3x3 и фильтром 64
model.add(Conv2D(filters = 64, kernel_size = (3,3),padding = 'Same',
                 activation ='relu'))
model.add(BatchNormalization())
model.add(Conv2D(filters = 64, kernel_size = (3,3),padding = 'Same',
                 activation ='relu'))
model.add(BatchNormalization())
model.add(Activation(activations.relu))
model.add(MaxPooling2D(pool_size=(2,2), strides=(2,2)))
model.add(Dropout(0.2))

# Свёрточный слой с ядром 3x3 и фильтром 256
model.add(Conv2D(filters = 256, kernel_size = (3,3),padding = 'Same',
                 activation ='relu'))
model.add(BatchNormalization())
model.add(Conv2D(filters = 256, kernel_size = (3,3),padding = 'Same',
                 activation ='relu'))
model.add(BatchNormalization())
model.add(Activation(activations.relu))
model.add(MaxPooling2D(pool_size=(2,2), strides=(2,2)))
model.add(Dropout(0.3))

# Сглаживание многомерных тензоров в одно измерение (конвертация в вектор размера 1xN)
model.add(Flatten())
# Полносвязный слой с функцией активации "relu"
model.add(Dense(128, activation = "relu"))
model.add(Dropout(0.4))
# Полносвязный слой с функцией активации "softmax" (категоризация)
model.add(Dense(10, activation = "softmax"))

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

Входной слой задаётся явным образом через соответствующий layer и размер данных поступающих на вход (для одного изображения) — 28×28, с одним каналом.

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

После ZeroPadding2D данные переходя в свёрточный слой с ядром свёртки 5×5 и количеством фильтров 32, а затем идёт batch-нормализация для стабилизации работы нейронной сети и ускорения её обучения.

Затем все данные проходят через функцию активации relu, после которой идёт слой MaxPooling2D и ZeroPadding2D с Dropout’ом, который временно не изменяет какой-то процент нейроннов.

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

В самом конце происходит сглаживание многомерных тензоров в одно измерение (иными словами многомерный массив становится одномерным), после чего данные проходят ещё через один слой с функцией активацией relu, Dropout’ом и на выходе получаем результат работы функции активации softmax, которая определила класс цифры на изображении (от 0 до 9).

В общем-то, это всё что связано с архитектурой сети.

Рисунок 4 - Результат сборки архитектуры нейронной сети

Рисунок 4 — Результат сборки архитектуры нейронной сети

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

Перейдём в написанию callbacks для нейронной сети.

Callbacks

Колбэки (или callbacks) — это отличный инструмент, который может быть полезен в течении всего обучения нейронной сети.

Для тех, кто знаком с backend-разработкой

Callbacks можно грубо интерпретировать как middleware в серверном программировании когда перед обработкой нового запроса сначала обрабатываются callbacks, а лишь затем сам запрос (или наоборот — сначала запрос, а затем callbacks). Callbacks является чем-то вроде middleware, только в машинном обучении.

Для начала, следует определить колбэк, который отработает тогда, когда нейронная сеть зафиксировала свой лучший результат во время обучения. Иными словами — нужно периодически «сохраняться», сохраняя лучший результат. С этим нам поможет колбэк ModelCheckpoint.

# Колбэк для сохранения лучшего варианта работы нейронной сети
checkpoint = ModelCheckpoint('mnist-cnn.hd5',
                             monitor='val_accuracy',     # Доля правильных ответов на проверочном множестве
                             save_best_only=True,        # Сохраняем только лучший результат
                             verbose=1)                  # Вывод логов

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

Следующий колбэк необходим для того, чтобы нейронная сеть двигалась дальше в случае если результат остаётся прежним какое-то число итераций (т.е. обучение не улучшается). Данный колбэк называется ReduceLROnPlateau и определяется следующим образом:

# Колбэк для изменения скорости обучения
learning_rate_reduction = ReduceLROnPlateau(monitor='val_accuracy', # Метрика
                                            patience=3,             # Число эпох без улучшения результата
                                            verbose=1,              # Вывод логов
                                            factor=0.5,             # Коэффициент на который мы будем умножать скорость обучения сети
                                            min_lr=0.00001)         # Минимальная скорость обучения сети

Наиболее важными параметрами в этом колбэке выступают patience, factor и min_lr.

Параметр patience отвечает за число эпох (или итераций), преодолев которые нужно умножать скорость обучения на параметр factor, а min_lr определяет насколько минимально мы можем уменьшить скорость обучения.

После всех необходимых процедур определяем размер мини-выборки для обучения и запускаем сам процесс обучения:

# Размер мини-выборки
batch_size = 32

# Сборка модели с сохранением истории изменений
history = model.fit(datagen.flow(X_train, Y_train, batch_size=batch_size),
                    epochs=35,
                    validation_data=(X_val, Y_val),
                    steps_per_epoch=X_train.shape[0] // batch_size,
                    verbose=1,
                    callbacks=[checkpoint, learning_rate_reduction],
                    shuffle=True)

Возвращаемся к теме тавтологии обучающих и тестовых выборок. Как видно из кода в datagen.flow передаются данные для обучения X_train (данные) и Y_train (метки). Эти данные являются под выборкой обучающей выборки которую мы разделили с помощью утилиты train_test_split. Но также из обучающей выборки мы взяли данные и для тестирования — X_val (данные) и Y_val (метки). При каждой эпохе обучения у нас генерируются данные с помощью ImageDataGenerator и проверяются с помощью тестирующей выборки. Таким образом нейронная сеть всегда получает почти уникальную обучающую выборку, часть из которой максимальна приближена к тестирующей под выборке и тестирующей выборке (которая не участвовала в объединении выборок).

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

После порядка 30 итераций максимальный результат данной нейронной сети и небольшой хитрости с выборками был получен результат в score = 0.9986, что продемонстрировано на рисунке ниже. Результат локально, а не на Kaggle.

Рисунок 5 - Визуализация результатов обучения нейронной сети

Рисунок 5 — Визуализация результатов обучения нейронной сети

После отправки результатов на Kaggle результат составил 0.99896.

Рисунок 6 - Подтверждение результата

Рисунок 6 — Подтверждение результата

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

Что ж, останавливаться на малом не будем, понятно что можно продолжить идею развития обучающей выборки и можно даже реализовать алгоритм динамической подгрузки новых данных с аугментацией, но хотелось бы для «галочки» добиться результата в score = 1 с минимальными усилиями, ведь интересно, каким образом можно осуществить «читинг».

HESOYAM для «нейронной сети»

После долгих раздумий и сравнительно не долгих поисков я наткнулся на интересный код, который позволяет считерить самым простым способом — просто подстроив submission.csv (подстроив ответы) таким образом, чтобы он Kaggle выдавал желаемую единицу. С материалом этого чита на Kaggle читатель может ознакомиться самостоятельно, а я лишь сделаю попытку объяснить как это всё работает и подтвердить что этот чит-код (в буквальном смысле) сработал и у меня.

Для реализации чита нам потребуется лишь библиотека pandas и стандартный датасет MNIST, который уже был упомянут в статье выше.

В следующем коде происходит просто загрузка датасета:

import pandas as pd

# Чтение данных из MNIST test и train
mnist_test = pd.read_csv("/content/mnist_test.csv")
mnist_train = pd.read_csv("/content/mnist_train.csv")

# Чтение примера ответа
sample_submission = pd.read_csv("/content/sample_submission.csv")

# Чтение данных из MNIST Kagle (стандарт)
test = pd.read_csv("/content/test.csv")
train = pd.read_csv("/content/train.csv")

Далее получаем количество колонок в тестовой выборке из соревнования по Kaggle, а также добавляем в словари test и train по ключу dataset их наименования (по сути добавляем столбцы и каждый столбец будет равен test или train соответственно):

# Получение количества колонок (cols)
cols = test.columns

# Добавление данных в столбец dataset
test['dataset'] = 'test'
train['dataset'] = 'train'

Следующий код объединяет два датасета (train и test) и сохраняет их в новом датасете, который называется dataset.

dataset = pd.concat([train.drop('label', axis=1), test]).reset_index()

Параметр drop используется для удаления столбца label из датасета train. Параметр axis=1 означает, что мы работаем со строками (по умолчанию столбцы).

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

Далее необходимо объединить датасеты mnist_train и mnist_test в один, почти аналогично объединению train и test.

# Объединение датасетов mnist_train и mnist_test, со сбросом индекса
mnist = pd.concat([mnist_train, mnist_test]).reset_index(drop=True)

# Получение списка ответов
labels = mnist['label'].values

# Удаление строки с наименованием label и прочих не нужных данных
mnist.drop('label', axis=1, inplace=True)

# Добавление числа колонок (чтобы было одинаковое количество)
mnist.columns = cols

Итак, на данном этапе мы имеем dataset, который содержит в себе объединение train и тест, а также датасет mnist, который также в себе содержит mnist_train и mnist_test.

Ответы расположены в одном месте — labels, причём взяты они из стандартной обучающей и тестовой выборки MNIST.

Далее идёт работа с индексами и значениями.

idx_mnist = mnist.sort_values(by=list(mnist.columns)).index
dataset_from = dataset.sort_values(by=list(mnist.columns))['dataset'].values
original_idx = dataset.sort_values(by=list(mnist.columns))['index'].values

Сначала mnist сортируется по значениям в каждом столбце и получаются индексы отсортированных строк. Они сохраняются в переменной idx_mnist.

Затем dataset также сортируется по значениям в каждом столбце, соответствующим столбцам в mnist. Из отсортированного dataset выбираются значения столбца dataset с помощью [dataset].values и сохраняются в переменной dataset_from.

Ну и то же самое делается для столбца index в dataset, и его значения сохраняются в переменной original_idx.

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

for i in range(len(idx_mnist)):
    if dataset_from[i] == 'test':
        sample_submission.loc[original_idx[i], 'Label'] = labels[idx_mnist[i]]

Выполняется цикл, в котором происходит проверка условия: если значение переменной dataset_from[i] равно строке test, то в датафрейме sample_submission значение столбца Label для строки с индексом original_idx[i] присваивается значение labels[idx_mnist[i]]. Т.е. мы буквально встраиваем правильный ответ в sample_submission.

Ну и в самом конце необходимо сформировать ответ на основе уже существующего sample_submission и отправить его на Kaggle.

# Конвертация в csv
sample_submission.to_csv('submission2.csv', index=False)

# Отправляем решение на соревнование
!kaggle competitions submit -c digit-recognizer -m "submission for score 1" -f submission2.csv

Рисунок 7 - Проверка результата

Рисунок 7 — Проверка результата

Как видите — ничего сложного, просто подделка ответа основанная на простой работе с индексами, фильтрами и встраиванием.

Hey, CJ

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

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

Выводы

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

В следующей части статьи будет рассмотрена разработка веб-приложения на React.js, в котором можно будет нарисовать цифру на холсте и отправив на сервер получить определённый ответ (распознанную цифру). Сервер будет написан на Flask и будет разобран алгоритм загрузки весов моделей и работа обученной нейронной сети (и ещё вернёмся к цифре 6…).

Список использованных источников

  1. Исходный код реализованной нейронной сети, чита для score = 1 и обученная модель со score = 0.99896 на Kaggle (public): исходный код

  2. Оригинальный код на Kaggle про чит: оригинальный чит-код

  3. Ссылка на стандартный датасет MNIST на платформе Kaggle: стандартный датасет

  4. Ссылка на соревнование: Digit Recognizer

© Habrahabr.ru