Классификация текстов рецензий на фильмы

TensorFlow

Классификация текстов рецензий на фильмы

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

Мы будем использовать набор данных IMDB, который содержит 50 000 текстов обзоров фильмов из базы данных фильмов в Интернете. Мы разделили эти обзоры фильмов на обучающую выборку (25 000 обзоров фильмов) и тестовую выборку (25 000 обзоров фильмов). Обучающая и тестовая выборки сбалансированы, то есть содержат одинаковое количество положительных и отрицательных отзывов о фильмах.

В этой записной книжке используется tf.keras, высокоуровневый API для построения и обучения моделей в TensorFlow. Более подробное руководство по классификации текста с использованием tf.keras см. в Руководстве по классификации текста MLCC.

In [1]:
import tensorflow as tf
from tensorflow import keras

import numpy as np

print(tf.__version__)
1.13.1

Загрузите набор данных IMDB

Набор данных IMDB включен в TensorFlow. Мы предварительно обработали этот набор данных, чтобы преобразовать обзоры фильмов (последовательности слов) в последовательности целых чисел, где каждое целое число представляет определенное слово в словаре.

Следующий код загрузит набор данных IMDB на ваш компьютер (если вы уже загрузили набор данных, будет использоваться кешированная копия):

In [2]:
imdb = keras.datasets.imdb

(train_data, train_labels), (test_data, test_labels) = imdb.load_data(num_words=10000)

Параметр num_words=10000 сохранит 10000 наиболее часто встречающихся слов в обучающих данных. Чтобы сохранить размер данных на управляемом уровне, редкие слова отбрасываются.

Исследуйте данные

Давайте уделим немного времени, чтобы понять формат данных. Набор данных был предварительно обработан: каждый образец представляет собой массив целых чисел, представляющих слова в обзоре фильма. Каждая метка представляет собой целочисленное значение от 0 до 1, где 0 — отрицательный отзыв, а 1 — положительный отзыв.

In [3]:
print("Training entries: {}, labels: {}".format(len(train_data), len(train_labels)))
Training entries: 25000, labels: 25000

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

In [4]:
print(train_data[0])
[1, 14, 22, 16, 43, 530, 973, 1622, 1385, 65, 458, 4468, 66, 3941, 4, 173, 36, 256, 5, 25, 100, 43, 838, 112, 50, 670, 2, 9, 35, 480, 284, 5, 150, 4, 172, 112, 167, 2, 336, 385, 39, 4, 172, 4536, 1111, 17, 546, 38, 13, 447, 4, 192, 50, 16, 6, 147, 2025, 19, 14, 22, 4, 1920, 4613, 469, 4, 22, 71, 87, 12, 16, 43, 530, 38, 76, 15, 13, 1247, 4, 22, 17, 515, 17, 12, 16, 626, 18, 2, 5, 62, 386, 12, 8, 316, 8, 106, 5, 4, 2223, 5244, 16, 480, 66, 3785, 33, 4, 130, 12, 16, 38, 619, 5, 25, 124, 51, 36, 135, 48, 25, 1415, 33, 6, 22, 12, 215, 28, 77, 52, 5, 14, 407, 16, 82, 2, 8, 4, 107, 117, 5952, 15, 256, 4, 2, 7, 3766, 5, 723, 36, 71, 43, 530, 476, 26, 400, 317, 46, 7, 4, 2, 1029, 13, 104, 88, 4, 381, 15, 297, 98, 32, 2071, 56, 26, 141, 6, 194, 7486, 18, 4, 226, 22, 21, 134, 476, 26, 480, 5, 144, 30, 5535, 18, 51, 36, 28, 224, 92, 25, 104, 4, 226, 65, 16, 38, 1334, 88, 12, 16, 283, 5, 16, 4472, 113, 103, 32, 15, 16, 5345, 19, 178, 32]

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

In [5]:
len(train_data[0]), len(train_data[1])
Out[5]:
(218, 189)

Преобразование целых чисел обратно в слова

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

In [9]:
# A dictionary mapping words to an integer index
word_index = imdb.get_word_index()

# The first indices are reserved
word_index = {k:(v+3) for k,v in word_index.items()}
word_index["<PAD>"] = 0
word_index["<START>"] = 1
word_index["<UNK>"] = 2  # unknown
word_index["<UNUSED>"] = 3

reverse_word_index = dict([(value, key) for (key, value) in word_index.items()])

def decode_review(text):
    return ' '.join([reverse_word_index.get(i, '?') for i in text])
Downloading data from https://storage.googleapis.com/tensorflow/tf-keras-datasets/imdb_word_index.json
1646592/1641221 [==============================] - 1s 0us/step

Теперь мы можем отобразить текст первого обзора фильма с помощью функции decode_review:

In [10]:
decode_review(train_data[0])
Out[10]:
"<START> this film was just brilliant casting location scenery story direction everyone's really suited the part they played and you could just imagine being there robert <UNK> is an amazing actor and now the same being director <UNK> father came from the same scottish island as myself so i loved the fact there was a real connection with this film the witty remarks throughout the film were great it was just brilliant so much that i bought the film as soon as it was released for <UNK> and would recommend it to everyone to watch and the fly fishing was amazing really cried at the end it was so sad and you know what they say if you cry at a film it must have been good and this definitely was also <UNK> to the two little boy's that played the <UNK> of norman and paul they were just brilliant children are often left out of the <UNK> list i think because the stars that play them all grown up are such a big profile for the whole film but these children are amazing and should be praised for what they have done don't you think the whole story was so lovely because it was true and was someone's life after all that was shared with us all"

Подготовить данные

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

  • Горячее кодирование массивов, преобразование их в вектор из 0 и 1. Например, последовательность [3, 5] станет 10000-мерным вектором, все из которых будут преобразованы в 0, за исключением индексов 3 и 5, которые будут преобразованы в 1. Затем сделайте его первым слоем сети, плотным слоем, который может обрабатывать векторные данные с плавающей запятой. Однако этот подход интенсивно использует память и требует матрицы размера num_words * num_reviews.

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

В этом уроке мы будем использовать второй метод.

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

In [11]:
train_data = keras.preprocessing.sequence.pad_sequences(train_data,
                                                        value=word_index["<PAD>"],
                                                        padding='post',
                                                        maxlen=256)

test_data = keras.preprocessing.sequence.pad_sequences(test_data,
                                                       value=word_index["<PAD>"],
                                                       padding='post',
                                                       maxlen=256)

Теперь давайте посмотрим на длину образца:

In [12]:
len(train_data[0]), len(train_data[1])
Out[12]:
(256, 256)

И посмотрите (теперь заполненный) первый обзор фильма:

In [13]:
print(train_data[0])
[   1   14   22   16   43  530  973 1622 1385   65  458 4468   66 3941
    4  173   36  256    5   25  100   43  838  112   50  670    2    9
   35  480  284    5  150    4  172  112  167    2  336  385   39    4
  172 4536 1111   17  546   38   13  447    4  192   50   16    6  147
 2025   19   14   22    4 1920 4613  469    4   22   71   87   12   16
   43  530   38   76   15   13 1247    4   22   17  515   17   12   16
  626   18    2    5   62  386   12    8  316    8  106    5    4 2223
 5244   16  480   66 3785   33    4  130   12   16   38  619    5   25
  124   51   36  135   48   25 1415   33    6   22   12  215   28   77
   52    5   14  407   16   82    2    8    4  107  117 5952   15  256
    4    2    7 3766    5  723   36   71   43  530  476   26  400  317
   46    7    4    2 1029   13  104   88    4  381   15  297   98   32
 2071   56   26  141    6  194 7486   18    4  226   22   21  134  476
   26  480    5  144   30 5535   18   51   36   28  224   92   25  104
    4  226   65   16   38 1334   88   12   16  283    5   16 4472  113
  103   32   15   16 5345   19  178   32    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0    0    0    0    0    0    0    0    0    0    0
    0    0    0    0]

Построить модель

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

  • Сколько слоев использовать в модели?
  • Сколько скрытых единиц использовать для каждого слоя?

В этом примере входные данные состоят из массива индексов слов. Метка для прогнозирования — либо 0, либо 1. Далее мы строим модель для этой задачи:

In [14]:
# input shape is the vocabulary count used for the movie reviews (10,000 words)
vocab_size = 10000

model = keras.Sequential()
model.add(keras.layers.Embedding(vocab_size, 16))
model.add(keras.layers.GlobalAveragePooling1D())
model.add(keras.layers.Dense(16, activation=tf.nn.relu))
model.add(keras.layers.Dense(1, activation=tf.nn.sigmoid))

model.summary()
WARNING:tensorflow:From e:\program files\python37\lib\site-packages\tensorflow\python\ops\resource_variable_ops.py:435: colocate_with (from tensorflow.python.framework.ops) is deprecated and will be removed in a future version.
Instructions for updating:
Colocations handled automatically by placer.
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
embedding (Embedding)        (None, None, 16)          160000    
_________________________________________________________________
global_average_pooling1d (Gl (None, 16)                0         
_________________________________________________________________
dense (Dense)                (None, 16)                272       
_________________________________________________________________
dense_1 (Dense)              (None, 1)                 17        
=================================================================
Total params: 160,289
Trainable params: 160,289
Non-trainable params: 0
_________________________________________________________________

Сложите слои, чтобы построить классификатор:

  1. Первый слой — это слой внедрения. Этот слой ищет вектор встраивания для каждого индекса слова в словаре с целочисленным кодированием. Модель изучает эти векторы при обучении. Эти векторы добавляют измерение к выходному массиву. Сгенерированные размеры: (партия, последовательность, встраивание).
  2. Затем слой GlobalAveragePooling1D возвращает выходной вектор фиксированной длины для каждой выборки путем усреднения по измерению последовательности. Таким образом, модель может обрабатывать входные данные различной длины самым простым способом.
  3. Этот выходной вектор фиксированной длины передается в полносвязный (плотный) слой (содержащий 16 скрытых элементов).
  4. Последний слой плотно связан с одним выходным узлом. После применения сигмовидной функции активации результат представляет собой значение с плавающей запятой от 0 до 1, представляющее вероятность или уровень достоверности.

скрытая единица

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

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

Функции потерь и оптимизаторы

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

Эта функция не единственная функция потерь, например, вы можете выбрать mean_squared_error. Но в целом бинарная_кроссэнтропия лучше подходит для вероятностных задач, она измеряет «разрыв» между вероятностными распределениями, в данном случае «разрыв» между фактическим распределением и прогнозом.

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

Теперь настройте модель для использования оптимизатора и функции потерь:

In [15]:
model.compile(optimizer=tf.train.AdamOptimizer(),
              loss='binary_crossentropy',
              metrics=['accuracy'])

Создайте набор проверки

Во время обучения нам нужно проверить, насколько хорошо модель обрабатывает данные, которые она никогда раньше не видела. Мы разделили 10 000 выборок из исходных обучающих данных, чтобы создать проверочный набор. (Почему бы не использовать тестовый набор сейчас? Наша цель — разработать и настроить модель, используя только обучающие данные, а затем оценить точность, используя тестовые данные только один раз.)

In [16]:
x_val = train_data[:10000]
partial_x_train = train_data[10000:]

y_val = train_labels[:10000]
partial_y_train = train_labels[10000:]

Создайте набор проверки

Во время обучения нам нужно проверить, насколько хорошо модель обрабатывает данные, которые она никогда раньше не видела. Мы разделили 10 000 выборок из исходных обучающих данных, чтобы создать проверочный набор. (Почему бы не использовать тестовый набор сейчас? Наша цель — разработать и настроить модель, используя только обучающие данные, а затем оценить точность, используя тестовые данные только один раз.)

In [17]:
x_val = train_data[:10000]
partial_x_train = train_data[10000:]

y_val = train_labels[:10000]
partial_y_train = train_labels[10000:]

Обучите модель

Обучите модель на 40 эпох с мини-партиями из 512 выборок. Это сделает 40 итераций всех выборок в тензорах x_train и y_train. Во время обучения следите за потерями и точностью модели на проверочном наборе из 10 000 образцов:

In [18]:
history = model.fit(partial_x_train,
                    partial_y_train,
                    epochs=40,
                    batch_size=512,
                    validation_data=(x_val, y_val),
                    verbose=1)
Train on 15000 samples, validate on 10000 samples
WARNING:tensorflow:From e:\program files\python37\lib\site-packages\tensorflow\python\ops\math_ops.py:3066: to_int32 (from tensorflow.python.ops.math_ops) is deprecated and will be removed in a future version.
Instructions for updating:
Use tf.cast instead.
Epoch 1/40
15000/15000 [==============================] - 1s 94us/sample - loss: 0.6914 - acc: 0.6433 - val_loss: 0.6891 - val_acc: 0.6996
Epoch 2/40
15000/15000 [==============================] - 1s 77us/sample - loss: 0.6847 - acc: 0.7456 - val_loss: 0.6802 - val_acc: 0.7537
Epoch 3/40
15000/15000 [==============================] - 1s 60us/sample - loss: 0.6705 - acc: 0.7683 - val_loss: 0.6619 - val_acc: 0.7626
Epoch 4/40
15000/15000 [==============================] - 1s 61us/sample - loss: 0.6452 - acc: 0.7761 - val_loss: 0.6333 - val_acc: 0.7592
Epoch 5/40
15000/15000 [==============================] - 1s 72us/sample - loss: 0.6089 - acc: 0.7996 - val_loss: 0.5960 - val_acc: 0.7908
Epoch 6/40
15000/15000 [==============================] - 1s 61us/sample - loss: 0.5642 - acc: 0.8184 - val_loss: 0.5537 - val_acc: 0.8096
Epoch 7/40
15000/15000 [==============================] - 1s 78us/sample - loss: 0.5155 - acc: 0.8357 - val_loss: 0.5087 - val_acc: 0.8244
Epoch 8/40
15000/15000 [==============================] - 1s 69us/sample - loss: 0.4678 - acc: 0.8529 - val_loss: 0.4681 - val_acc: 0.8374
Epoch 9/40
15000/15000 [==============================] - 1s 64us/sample - loss: 0.4243 - acc: 0.8668 - val_loss: 0.4322 - val_acc: 0.8476
Epoch 10/40
15000/15000 [==============================] - 1s 60us/sample - loss: 0.3863 - acc: 0.8775 - val_loss: 0.4029 - val_acc: 0.8527
Epoch 11/40
15000/15000 [==============================] - 1s 64us/sample - loss: 0.3544 - acc: 0.8861 - val_loss: 0.3789 - val_acc: 0.8607
Epoch 12/40
15000/15000 [==============================] - 1s 59us/sample - loss: 0.3276 - acc: 0.8933 - val_loss: 0.3603 - val_acc: 0.8646
Epoch 13/40
15000/15000 [==============================] - 1s 76us/sample - loss: 0.3057 - acc: 0.8982 - val_loss: 0.3442 - val_acc: 0.8697
Epoch 14/40
15000/15000 [==============================] - 1s 69us/sample - loss: 0.2859 - acc: 0.9034 - val_loss: 0.3323 - val_acc: 0.8734
Epoch 15/40
15000/15000 [==============================] - 1s 67us/sample - loss: 0.2693 - acc: 0.9079 - val_loss: 0.3227 - val_acc: 0.8740
Epoch 16/40
15000/15000 [==============================] - 1s 66us/sample - loss: 0.2544 - acc: 0.9135 - val_loss: 0.3146 - val_acc: 0.8759
Epoch 17/40
15000/15000 [==============================] - 1s 65us/sample - loss: 0.2406 - acc: 0.9173 - val_loss: 0.3078 - val_acc: 0.8788
Epoch 18/40
15000/15000 [==============================] - 1s 65us/sample - loss: 0.2285 - acc: 0.9218 - val_loss: 0.3021 - val_acc: 0.8808
Epoch 19/40
15000/15000 [==============================] - 1s 62us/sample - loss: 0.2174 - acc: 0.9239 - val_loss: 0.2972 - val_acc: 0.8819
Epoch 20/40
15000/15000 [==============================] - 1s 58us/sample - loss: 0.2075 - acc: 0.9281 - val_loss: 0.2939 - val_acc: 0.8817
Epoch 21/40
15000/15000 [==============================] - 1s 57us/sample - loss: 0.1974 - acc: 0.9342 - val_loss: 0.2910 - val_acc: 0.8832
Epoch 22/40
15000/15000 [==============================] - 1s 64us/sample - loss: 0.1889 - acc: 0.9374 - val_loss: 0.2889 - val_acc: 0.8840
Epoch 23/40
15000/15000 [==============================] - 1s 70us/sample - loss: 0.1803 - acc: 0.9411 - val_loss: 0.2880 - val_acc: 0.8830
Epoch 24/40
15000/15000 [==============================] - 1s 73us/sample - loss: 0.1729 - acc: 0.9443 - val_loss: 0.2863 - val_acc: 0.8852
Epoch 25/40
15000/15000 [==============================] - 1s 74us/sample - loss: 0.1654 - acc: 0.9475 - val_loss: 0.2853 - val_acc: 0.8851
Epoch 26/40
15000/15000 [==============================] - 1s 97us/sample - loss: 0.1586 - acc: 0.9507 - val_loss: 0.2860 - val_acc: 0.8837
Epoch 27/40
15000/15000 [==============================] - 1s 69us/sample - loss: 0.1522 - acc: 0.9531 - val_loss: 0.2857 - val_acc: 0.8851
Epoch 28/40
15000/15000 [==============================] - 1s 67us/sample - loss: 0.1461 - acc: 0.9553 - val_loss: 0.2860 - val_acc: 0.8852
Epoch 29/40
15000/15000 [==============================] - 1s 62us/sample - loss: 0.1408 - acc: 0.9579 - val_loss: 0.2882 - val_acc: 0.8840
Epoch 30/40
15000/15000 [==============================] - 1s 64us/sample - loss: 0.1352 - acc: 0.9595 - val_loss: 0.2875 - val_acc: 0.8854
Epoch 31/40
15000/15000 [==============================] - 1s 59us/sample - loss: 0.1296 - acc: 0.9619 - val_loss: 0.2889 - val_acc: 0.8859
Epoch 32/40
15000/15000 [==============================] - 1s 62us/sample - loss: 0.1245 - acc: 0.9652 - val_loss: 0.2905 - val_acc: 0.8857
Epoch 33/40
15000/15000 [==============================] - 1s 60us/sample - loss: 0.1196 - acc: 0.9667 - val_loss: 0.2930 - val_acc: 0.8838
Epoch 34/40
15000/15000 [==============================] - 1s 70us/sample - loss: 0.1153 - acc: 0.9678 - val_loss: 0.2948 - val_acc: 0.8846
Epoch 35/40
15000/15000 [==============================] - 1s 60us/sample - loss: 0.1111 - acc: 0.9687 - val_loss: 0.2981 - val_acc: 0.8841
Epoch 36/40
15000/15000 [==============================] - 1s 66us/sample - loss: 0.1068 - acc: 0.9716 - val_loss: 0.2997 - val_acc: 0.8843
Epoch 37/40
15000/15000 [==============================] - 1s 61us/sample - loss: 0.1027 - acc: 0.9722 - val_loss: 0.3023 - val_acc: 0.8837
Epoch 38/40
15000/15000 [==============================] - 1s 64us/sample - loss: 0.0988 - acc: 0.9734 - val_loss: 0.3059 - val_acc: 0.8829
Epoch 39/40
15000/15000 [==============================] - 1s 60us/sample - loss: 0.0957 - acc: 0.9744 - val_loss: 0.3095 - val_acc: 0.8823
Epoch 40/40
15000/15000 [==============================] - 1s 60us/sample - loss: 0.0917 - acc: 0.9769 - val_loss: 0.3117 - val_acc: 0.8822

Модель оценки

Посмотрим, как поведет себя модель. Модель возвращает два значения: потери (число, представляющее ошибку, чем меньше, тем лучше) и точность.

In [19]:
results = model.evaluate(test_data, test_labels)

print(results)
25000/25000 [==============================] - 1s 39us/sample - loss: 0.3328 - acc: 0.87141s - loss: 0.31
[0.33284874985694884, 0.8714]

С помощью этого довольно простого метода можно достичь точности около 87%. При использовании более продвинутых методов точность модели должна быть ближе к 95%.

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

model.fit() возвращает объект History, содержащий словарь всего, что произошло во время обучения:

In [20]:
history_dict = history.history
history_dict.keys()
Out[20]:
dict_keys(['loss', 'acc', 'val_loss', 'val_acc'])

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

In [23]:
import matplotlib.pyplot as plt

acc = history.history['acc']
val_acc = history.history['val_acc']
loss = history.history['loss']
val_loss = history.history['val_loss']

epochs = range(1, len(acc) + 1)

# "bo" is for "blue dot"
plt.plot(epochs, loss, 'bo', label='Training loss')
# b is for "solid blue line"
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()

plt.show()
In [22]:
plt.clf()   # clear figure
acc_values = history_dict['acc']
val_acc_values = history_dict['val_acc']

plt.plot(epochs, acc, 'bo', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend()

plt.show()

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

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

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

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