В этой записной книжке мы подробно рассмотрим, как построить простую модель глубокого обучения (ИНС) для прогнозирования меток рукописных цифр с учетом их изображения. Мы будем использовать набор данных MNIST от Kaggle для обучения модели (ссылка). Я сделаю все возможное, чтобы все было максимально просто, и объясню шаги и процесс, которым мы следуем по мере изучения этой записной книжки. Первым шагом является проверка набора данных, формата, в котором хранятся изображения, и соответствующих меток, связанных с одним и тем же файлом .
import numpy as np
import pandas as pd
import os
for dirname, _, filenames in os.walk('/kaggle/input'):
for filename in filenames:
print(os.path.join(dirname, filename))
/kaggle/input/digit-recognizer/sample_submission.csv
/kaggle/input/digit-recognizer/train.csv
/kaggle/input/digit-recognizer/test.csv
Заметим, что данные хранятся в виде csv. Мы можем быстро использовать pandas для чтения CSV в виде фрейма данных и просмотра его содержимого. Мы читаем файлы train и test, чтобы увидеть столбцы в каждом из них, и это даст нам представление о том, что такое целевая переменная.
train_df = pd.read_csv("/kaggle/input/digit-recognizer/train.csv") test_df = pd.read_csv("/kaggle/input/digit-recognizer/test.csv") print(train_df.shape , test_df.shape) print("Train Cols : " , train_df.columns) print("Test Cols : " , test_df.columns) print(" Label - " , [i for i in train_df.columns if i not in test_df.columns] )
(42000, 785) (28000, 784) Train Cols : Index(['label', 'pixel0', 'pixel1', 'pixel2', 'pixel3', 'pixel4', 'pixel5', 'pixel6', 'pixel7', 'pixel8', ... 'pixel774', 'pixel775', 'pixel776', 'pixel777', 'pixel778', 'pixel779', 'pixel780', 'pixel781', 'pixel782', 'pixel783'], dtype='object', length=785) Test Cols : Index(['pixel0', 'pixel1', 'pixel2', 'pixel3', 'pixel4', 'pixel5', 'pixel6', 'pixel7', 'pixel8', 'pixel9', ... 'pixel774', 'pixel775', 'pixel776', 'pixel777', 'pixel778', 'pixel779', 'pixel780', 'pixel781', 'pixel782', 'pixel783'], dtype='object', length=784) Label - ['label']
В обучающем наборе данных 42 000 строк, а в test_dataset — 28 000 строк. Из названий столбцов делаем вывод, что картинки хранятся в виде строк, где столбец — pixel_1 указывает значение плотности пикселей в cell_no[1] в изображении . Поскольку есть значения до pixel_783, количество пикселей равно 784, что означает, что это изображение 28*28. Чтобы найти целевой столбец, мы просто проверяем столбцы в обучающем наборе данных, которые не являются частью тестового набора данных.
Создание класса набора данных для модели pytorch
Мы собираемся следовать рекомендациям pytorch по получению данных в виде набора данных, так как это упрощает нам жизнь при создании загрузчиков данных, которые впоследствии будут использоваться в процессах обучения. Здесь мы создаем собственный пользовательский класс, наследующий класс Dataset и указывающий, как получить доступ к элементам набора данных без использования метода getitem . В соответствии с этим методом мы считываем данные и сохраняем их в img_df, извлекаем изображение, преобразовываем их в массив 28 * 28 numpy, приводим значения в диапазон [0,1] и выполняем преобразования для обоих изображение и метка отдельно и возвращает их в виде тензоров X, y. Этот пользовательский класс принимает следующие аргументы
- csv_name — Имя, под которым хранятся данные
- img_dir — путь к каталогу, в котором хранится файл.
- transform — Преобразования, которые необходимо выполнить в векторе изображения
- target_transform — Преобразования, которые необходимо выполнить для целевой переменной.
import torch
from torch.utils.data import Dataset
from torchvision import datasets
from torchvision.transforms import ToTensor , Lambda
class CustomMNISTDataset(Dataset):
def __init__(self, csv_name, img_dir, transform=None, target_transform=None , label_name = "label"):
self.img_filename = csv_name
self.img_dir = img_dir
self.transform = transform
self.target_transform = target_transform
self.label_name = label_name
img_path = os.path.join(self.img_dir, self.img_filename)
self.img_df = pd.read_csv(img_path)
def __len__(self):
return len(self.img_df)
def __getitem__(self, idx):
# Extracting all the other columns except label_name
img_cols = [ i for i in self.img_df.columns if i not in self.label_name]
image = self.img_df.iloc[[idx]][img_cols].values
# Reshaping the array from 1*784 to 28*28
image = image.reshape(28,28)
# image = image.astype(float)
# Scaling the image so that the values only range between 0 and 1
image = image/255.0
if self.transform:
image = self.transform(image)
image = image.to(torch.float)
if self.label_name in self.img_df.columns:
if self.target_transform:
label = self.target_transform(label)
label = int(self.img_df.iloc[[idx]][self.label_name].values)
return image, label
# Exceptions for test where labels are absent
else :
return image
Распределение целевых меток
Перед фактическим созданием наборов данных мы просто заглядываем в обучающий набор данных, чтобы увидеть, сколько целевых переменных действительно присутствует в наборе данных. Это даст нам представление о том, смещен ли набор данных в сторону определенного ярлыка по сравнению с другими.
train_df = pd.read_csv("/kaggle/input/digit-recognizer/train.csv") train_df['label'].value_counts().sort_index()
0 4132 1 4684 2 4177 3 4351 4 4072 5 3795 6 4137 7 4401 8 4063 9 4188 Name: label, dtype: int64
Мы видим, что распределение меток довольно равное и здесь нет сильного перекоса в сторону какой-либо конкретной переменной.
Распределение меток как в поезде, так и в действительном наборе
Чтобы измерить фактическую производительность модели, необходимо создать обучающие и действительные подмножества из нашего набора training_data. Мы будем использовать только train_subset для обучения модели и проверим его производительность с действительным набором данных. Для создания действительного набора данных мы будем стратифицировать, используя метки y, чтобы гарантировать, что распределение как поезда, так и действительных подмножеств останется прежним.
## Illustration of creating a validation set from sklearn.model_selection import train_test_split indices = list(range(len(train_df))) train_indices , test_indices = train_test_split(indices, test_size=0.1, stratify=train_df['label']) # train_indices , test_indices = train_test_split(indices, test_size=0.1) len(train_indices) , len(test_indices) , len(train_df) train_subset = train_df.loc[train_indices] val_subset = train_df.loc[test_indices] print("Distribution of target values in training dataset ; ") print( train_subset['label'].value_counts().sort_index() / train_subset['label'].value_counts().sort_index().sum() ) print("Distribution of target values in validation dataset ; ") print( val_subset['label'].value_counts().sort_index() / val_subset['label'].value_counts().sort_index().sum() )
Distribution of target values in training dataset ; 0 0.098386 1 0.111534 2 0.099444 3 0.103598 4 0.096958 5 0.090344 6 0.098492 7 0.104788 8 0.096746 9 0.099709 Name: label, dtype: float64 Distribution of target values in validation dataset ; 0 0.098333 1 0.111429 2 0.099524 3 0.103571 4 0.096905 5 0.090476 6 0.098571 7 0.104762 8 0.096667 9 0.099762 Name: label, dtype: float64
Создание набора данных поезда с использованием данных поезда
Мы продолжим и создадим набор данных train_dataset, используя класс, который мы определили выше. Что касается аргументов, мы предоставляем каталог и csv_name на основе значений, указанных ниже. Для преобразований мы используем функцию ToTensor(), которая преобразует массив numpy в тензор, и Normalize(mean, std), помогая нам нормализовать набор данных с заданным средним значением и стандартным отклонением, чтобы значения не превышали пропорцию pf. Мы сохраняем значение target_transform как None, так как ничего не нужно делать с целевой переменной.
from torchvision import transforms # Crerating a temp dataset train_csv_name = "train.csv" test_csv_name = "test.csv" img_dir = "/kaggle/input/digit-recognizer/" # Converting X variables to Tensors transforms = transforms.Compose( [transforms.ToTensor() , transforms.Normalize((0.5,), (0.5,)) , ] ) # Converting y-labels to one hot encoding # target_transform = Lambda(lambda y: torch.zeros( # len(train_df['label'].unique()), dtype=torch.float).scatter_(dim=0, index=torch.tensor(y), value=1)) # This is not need since we are going to be using cross entropy loss function label_name = "label" train_dataset = CustomMNISTDataset(csv_name = train_csv_name , img_dir = img_dir , transform = transforms , target_transform = None , label_name = label_name) # Inspecting the fist line item under dataset x0 , y0 = train_dataset[0] print(x0.shape , y0)
torch.Size([1, 28, 28]) 1
Построение точек данных train_dataset
# Ploting some of the datapoints in the dataset
import matplotlib.pyplot as plt
# sample_img , sample_lbl = temp_train_dataset[3]
figure = plt.figure(figsize=(8, 8))
cols, rows = 3, 3
figure.add_subplot(rows, cols, 1)
for i in range(1, cols * rows + 1):
sample_idx = torch.randint(len(train_dataset), size=(1,)).item()
sample_img , sample_lbl = train_dataset[sample_idx]
figure.add_subplot(rows, cols, i)
plt.title(sample_lbl)
plt.axis("off")
plt.imshow(sample_img.squeeze(), cmap="gray")
plt.show()
Проверяем, есть ли на устройстве графический процессор, если нет, мы выполняем весь процесс обучения на процессоре (недостаток в том, что он может быть очень медленным)
## Checking if the GPU is being used properly . device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') print('Using device:', device) torch.cuda.is_available() x0 = x0.to(device) print("x0" , x0.is_cuda)
Using device: cpu x0 False
Создание тестовых и действительных подмножеств с использованием стратифицированных выборок
Затем мы приступаем к разделению набора training_dataset на обучающие и действительные подмножества, как определено выше, с использованием стратифицированной выборки. Мы сохраняем действительный набор как 10% от training_dataset . Обратите внимание, что когда мы смотрим на содержимое train_dataloaders , размерность равна (torch.Size([64, 1, 28, 28]), torch.Size([64]) ), который отличается от содержимого train_dataset torch.Size([1, 28, 28]), int). По сути, это пакет, который обрабатывается во время обучения.
from torch.utils.data import SubsetRandomSampler from sklearn.model_selection import train_test_split indices = list(range(len(train_df))) train_indices , valid_indices = train_test_split(indices, test_size=0.1, stratify=train_df['label']) # Creating PT data samplers and loaders: train_sampler = SubsetRandomSampler(train_indices) valid_sampler = SubsetRandomSampler(test_indices) train_dataloader = torch.utils.data.DataLoader(train_dataset , batch_size=64, sampler=train_sampler, num_workers=16) valid_dataloader = torch.utils.data.DataLoader(train_dataset, batch_size=64, sampler=valid_sampler, num_workers=16) x0 , y0 = next(iter(train_dataloader)) x0.shape , y0.shape
/opt/conda/lib/python3.7/site-packages/torch/utils/data/dataloader.py:490: UserWarning: This DataLoader will create 16 worker processes in total. Our suggested max number of worker in current system is 4, which is smaller than what this DataLoader is going to create. Please be aware that excessive worker creation might get DataLoader running slow or even freeze, lower the worker number to avoid potential slowness/freeze if necessary. cpuset_checked)) (torch.Size([64, 1, 28, 28]), torch.Size([64]))
Определение модели NN
Теперь, когда мы закончили определение загрузчиков данных, мы переходим к определению деталей нейронной сети, которые будут использоваться для прогнозов. Мы используем архитектуру с 2 скрытыми слоями и с выходным слоем, имеющим 10 узлов (поскольку существует 10 различных классов для предсказания). Мы определяем наш наш класс, наследующий nn.Module, чтобы определить нашу собственную функцию прямого распространения, поскольку это считается лучшей практикой при работе с pytorch.
Модель будет выглядеть следующим образом:
- Мы сглаживаем наше изображение 28 * 28 в тензор длины 784 (что достигается с помощью nn.Flatten)
- Затем мы вводим их в скрытый слой, содержащий 128 узлов, который затем подключается к другому скрытому слою с 64 узлами. Мы используем Relu в качестве функции активации между слоями.
- Наконец, мы соединяем скрытый слой со слоем, имеющим 10 узлов (эквивалентно количеству меток).
Обратите внимание, что в конце NN нет слоя softmax. Это связано с тем, что nn.CrossEntropyLoss() автоматически применяет softmax из полученных выходных данных для расчета потерь . Однако, если мы используем nn.NLLLLoss() в качестве функции потери, нам придется включить nn.LogSoftmax в конце nn.Sequential(..)
from torch import nn # Get cpu or gpu device for training. device = "cuda" if torch.cuda.is_available() else "cpu" print(f"Using {device} device") class MyOwnNeuralNetwork(nn.Module): def __init__(self): super(MyOwnNeuralNetwork, self).__init__() self.flatten = nn.Flatten() self.linear_relu_stack = nn.Sequential( nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, 10) ## Softmax layer ignored since the loss function defined is nn.CrossEntropy() ) def forward(self, x): x = self.flatten(x) logits = self.linear_relu_stack(x) return logits model = MyOwnNeuralNetwork().to(device) print(model) # model = model.cuda() # torch.backends.cudnn.benchmark=True # torch.cuda.set_device(0)
Using cpu device MyOwnNeuralNetwork( (flatten): Flatten(start_dim=1, end_dim=-1) (linear_relu_stack): Sequential( (0): Linear(in_features=784, out_features=128, bias=True) (1): ReLU() (2): Linear(in_features=128, out_features=64, bias=True) (3): ReLU() (4): Linear(in_features=64, out_features=10, bias=True) ) )
Определение оптимизаторов и функции потерь
Мы будем использовать перекрестную потерю энтропии в качестве функции потерь и стохастический градиентный спуск в качестве оптимизатора для этого конкретного упражнения со скоростью обучения 0,003 и импульсом 0,9.
## Defining optimizer and loss functions
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=3e-3, momentum=0.9)
Определение цикла поезда
В рамках тренировочного цикла мы делаем следующие вещи.
- Поместите модель в режим поезда
- перечислить содержимое загрузчика данных, который дает нам X, y в виде пакетов
- Для каждой партии
- Мы используем модель, чтобы делать прогнозы X
- Вычислите потери, используя предсказанные и фактические значения y
- Сбросьте значения градиента на 0 .
- Выполнить обратное распространение
- Обновите параметры, используя вычисленные градиенты обратного распространения.
- Вычислите точность обучения и потери с помощью предсказанного и фактического y .
from torch.autograd import Variable
from torch.utils.tensorboard import SummaryWriter
def train(dataloader, model, loss_fn, optimizer):
# Total size of dataset for reference
size = 0
# places your model into training mode
model.train()
# loss batch
batch_loss = {}
batch_accuracy = {}
correct = 0
_correct = 0
# Gives X , y for each batch
for batch, (X, y) in enumerate(dataloader):
# Converting device to cuda
X, y = X.to(device), y.to(device)
model.to(device)
# Compute prediction error / loss
# 1. Compute y_pred
# 2. Compute loss between y and y_pred using selectd loss function
y_pred = model(X)
loss = loss_fn(y_pred, y)
# Backpropagation on optimizing for loss
# 1. Sets gradients as 0
# 2. Compute the gradients using back_prop
# 3. update the parameters using the gradients from step 2
optimizer.zero_grad()
loss.backward()
optimizer.step()
_correct = (y_pred.argmax(1) == y).type(torch.float).sum().item()
_batch_size = len(X)
correct += _correct
# Updating loss_batch and batch_accuracy
batch_loss[batch] = loss.item()
batch_accuracy[batch] = _correct/_batch_size
size += _batch_size
if batch % 100 == 0:
loss, current = loss.item(), batch * len(X)
print(f"loss: {loss:>7f} [{current:>5d}]")
correct/=size
print(f"Train Accuracy: {(100*correct):>0.1f}%")
return batch_loss , batch_accuracy
Определение допустимого/тестового цикла
Цикл проверки аналогичен циклу обучения, игнорируя обновления от обратного распространения. Следовательно, нам нужно установить модель в режим оценки, чтобы избежать обновления каких-либо параметров. Затем мы вычисляем потери и точность так же, как и в тренировочном цикле.
def validation(dataloader, model, loss_fn):
# Total size of dataset for reference
size = 0
num_batches = len(dataloader)
# Setting the model under evaluation mode.
model.eval()
test_loss, correct = 0, 0
_correct = 0
_batch_size = 0
batch_loss = {}
batch_accuracy = {}
with torch.no_grad():
# Gives X , y for each batch
for batch , (X, y) in enumerate(dataloader):
X, y = X.to(device), y.to(device)
model.to(device)
pred = model(X)
batch_loss[batch] = loss_fn(pred, y).item()
test_loss += batch_loss[batch]
_batch_size = len(X)
_correct = (pred.argmax(1) == y).type(torch.float).sum().item()
correct += _correct
size+=_batch_size
batch_accuracy[batch] = _correct/_batch_size
## Calculating loss based on loss function defined
test_loss /= num_batches
## Calculating Accuracy based on how many y match with y_pred
correct /= size
print(f"Valid Error: \n Accuracy: {(100*correct):>0.1f}%, Avg loss: {test_loss:>8f} \n")
return batch_loss , batch_accuracy
Триггеры обучения и проверки
Этот шаг в основном предназначен для определения эпох (количество проходов, которое необходимо выполнить для всего набора данных) при измерении производительности модели. Мы также используем график для измерения потерь модели как для обучения, так и для теста.
train_batch_loss = [] train_batch_accuracy = [] valid_batch_accuracy = [] valid_batch_loss = [] train_epoch_no = [] valid_epoch_no = [] epochs = 10 for t in range(epochs): print(f"Epoch {t+1}\n-------------------------------") _train_batch_loss , _train_batch_accuracy = train(train_dataloader, model, loss_fn, optimizer) _valid_batch_loss , _valid_batch_accuracy = validation(valid_dataloader, model, loss_fn) for i in range(len(_train_batch_loss)): train_batch_loss.append(_train_batch_loss[i]) train_batch_accuracy.append(_train_batch_accuracy[i]) train_epoch_no.append( t + float((i+1)/len(_train_batch_loss))) for i in range(len(_valid_batch_loss)): valid_batch_loss.append(_valid_batch_loss[i]) valid_batch_accuracy.append(_valid_batch_accuracy[i]) valid_epoch_no.append( t + float((i+1)/len(_valid_batch_loss))) print("Done!")
Epoch 1 ------------------------------- loss: 2.319053 [ 0] loss: 1.471163 [ 6400] loss: 0.675529 [12800] loss: 0.590934 [19200] loss: 0.417268 [25600] loss: 0.295707 [32000] Train Accuracy: 78.2% Valid Error: Accuracy: 89.1%, Avg loss: 0.360749 Epoch 2 ------------------------------- loss: 0.281806 [ 0] loss: 0.343148 [ 6400] loss: 0.358678 [12800] loss: 0.313208 [19200] loss: 0.232435 [25600] loss: 0.229005 [32000] Train Accuracy: 90.4% Valid Error: Accuracy: 91.8%, Avg loss: 0.269360 Epoch 3 ------------------------------- loss: 0.225520 [ 0] loss: 0.309443 [ 6400] loss: 0.107599 [12800] loss: 0.335566 [19200] loss: 0.262280 [25600] loss: 0.398198 [32000] Train Accuracy: 92.0% Valid Error: Accuracy: 92.6%, Avg loss: 0.237691 Epoch 4 ------------------------------- loss: 0.217512 [ 0] loss: 0.286153 [ 6400] loss: 0.261390 [12800] loss: 0.193834 [19200] loss: 0.286631 [25600] loss: 0.260825 [32000] Train Accuracy: 93.1% Valid Error: Accuracy: 93.9%, Avg loss: 0.194569 Epoch 5 ------------------------------- loss: 0.279033 [ 0] loss: 0.147660 [ 6400] loss: 0.164245 [12800] loss: 0.217309 [19200] loss: 0.290423 [25600] loss: 0.254665 [32000] Train Accuracy: 93.9% Valid Error: Accuracy: 94.6%, Avg loss: 0.174962 Epoch 6 ------------------------------- loss: 0.228728 [ 0] loss: 0.287362 [ 6400] loss: 0.324856 [12800] loss: 0.186807 [19200] loss: 0.154871 [25600] loss: 0.200961 [32000] Train Accuracy: 94.8% Valid Error: Accuracy: 95.6%, Avg loss: 0.145897 Epoch 7 ------------------------------- loss: 0.131066 [ 0] loss: 0.056263 [ 6400] loss: 0.070663 [12800] loss: 0.347360 [19200] loss: 0.106663 [25600] loss: 0.289104 [32000] Train Accuracy: 95.4% Valid Error: Accuracy: 96.1%, Avg loss: 0.130608 Epoch 8 ------------------------------- loss: 0.106794 [ 0] loss: 0.047672 [ 6400] loss: 0.142277 [12800] loss: 0.091740 [19200] loss: 0.120352 [25600] loss: 0.260128 [32000] Train Accuracy: 95.9% Valid Error: Accuracy: 96.0%, Avg loss: 0.127156 Epoch 9 ------------------------------- loss: 0.147196 [ 0] loss: 0.108950 [ 6400] loss: 0.117143 [12800] loss: 0.199887 [19200] loss: 0.079983 [25600] loss: 0.113003 [32000] Train Accuracy: 96.3% Valid Error: Accuracy: 96.4%, Avg loss: 0.122171 Epoch 10 ------------------------------- loss: 0.060340 [ 0] loss: 0.395733 [ 6400] loss: 0.068480 [12800] loss: 0.184037 [19200] loss: 0.121428 [25600] loss: 0.268083 [32000] Train Accuracy: 96.7% Valid Error: Accuracy: 97.1%, Avg loss: 0.092871 Done!
Мы видим приличную точность проверки выше 95% только с использованием нейронной сети из 2 скрытых слоев с 128 и 64 единицами. Для дальнейшего повышения точности модели можно использовать сверточную нейронную сеть, поскольку она работает намного лучше, когда речь идет о наборах данных изображений.
figure = plt.figure(figsize=(16, 16))
figure.add_subplot(2, 2, 1)
plt.plot(train_epoch_no , train_batch_accuracy)
plt.title("Train Batch Accuracy")
plt.xlabel("Epochs")
plt.ylabel("Train Accuracy")
figure.add_subplot(2, 2, 2)
plt.plot(train_epoch_no , train_batch_loss)
plt.title("Train Batch Loss")
plt.xlabel("Epochs")
plt.ylabel("Train Loss")
figure.add_subplot(2, 2, 3)
plt.plot(valid_epoch_no , valid_batch_accuracy)
plt.title("Valid Batch Accuracy")
plt.xlabel("Epochs")
plt.ylabel("Train Accuracy")
figure.add_subplot(2, 2, 4)
plt.plot(valid_epoch_no , valid_batch_loss)
plt.title("Valid Batch Loss")
plt.xlabel("Epochs")
plt.ylabel("Train Loss")
plt.show()
Из графиков ясно видно, что как точность обучения, так и точность проверки продолжали увеличиваться с количеством эпох, в то время как обучение и действительные потери продолжали уменьшаться, что является ожидаемым поведением градиентного спуска!
Загрузка тестовых данных для прогнозов
from torchvision import transforms
test_csv_name = "test.csv"
img_dir = "/kaggle/input/digit-recognizer/"
# Converting X variables to Tensors
transforms = transforms.Compose( [transforms.ToTensor() , transforms.Normalize((0.5,), (0.5,)) , ] )
label_name = "label"
test_dataset = CustomMNISTDataset(csv_name = test_csv_name , img_dir = img_dir , transform = transforms , target_transform = None , label_name = label_name)
Создание прогнозов на тестовых данных и их сохранение
probas = [] predictions = [] for i in range(len(test_dataset)): _probas = model(test_dataset[i]) probas.append(_probas) predictions.append(_probas.argmax(1).item())
# having a look at the format of submission pd.read_csv("/kaggle/input/digit-recognizer/sample_submission.csv")
# Saving the dataframe for submission
df = pd.DataFrame({'ImageId':range(1,len(predictions)+1),'Label':predictions})
df.to_csv("submission.csv",index=False)
Если вы дочитали до конца, то вот ссылка на мой блокнот на kaggle. Kaggle — замечательная платформа, на которой пользователи могут экспериментировать с различными наборами данных и пробовать новые вещи. Лучшее в kaggle то, что вам не нужно запускать свои модели машинного обучения на своей собственной машине. Kaggle предоставляет свои ядра для запуска наших блокнотов и отправки материалов непосредственно из них. Не стесняйтесь просматривать блокноты и запускать их на ядрах Kaggle шаг за шагом, чтобы лучше понять, что происходит на каждом этапе. если вам понравился контент, не забудьте поставить лайк :). Спасибо и до свидания до следующего раза.