- BrainTools - https://www.braintools.ru -

Разнообразие нейронных сетей: Обзор основных задач

Введение

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

  • Компьютерное зрение [1] (CV)

  • Обработка естественного языка (NLP)

  • Работа с табличными данными (Tabular Data)

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

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

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

Подготовка

Импортируем все необходимые библиотеки
from typing import Dict, Optional           # Type hints для читаемости кода
import datetime                             # Модуль с функциями для работы с датой и временем

import numpy as np                          # Численные вычисления, массивы
import pandas as pd                         # Табличные данные, DataFrame
import matplotlib.pyplot as plt             # Визуализация графиков и изображений

import torch                                # Основной фреймворк PyTorch
import torch.nn as nn                       # Нейронные сети (модели, слои)
import torch.optim as optim                 # Оптимизаторы
from torch.utils.data import DataLoader, Dataset as TorchDataset  # Поставщик батчей, контейнер данных

import torchvision                          # Обработка изображений
from torchvision import transforms  # Нормализация, ToTensor, аугментации

from datasets import load_dataset, Dataset as HFDataset  # HuggingFace датасеты
from transformers import AutoTokenizer      # Токенизаторы

from sklearn.datasets import load_iris      # Пример датасета (iris)
from sklearn.model_selection import train_test_split  # Разделение train/val
from sklearn.preprocessing import StandardScaler     # Нормализация фич
Выберем устройство для обучения [2] (GPU, если доступен, иначе CPU)
device = (torch.device('cuda') if torch.cuda.is_available()
          else torch.device('cpu'))
print(f"Training and evaluating on device {device}.")

Чтобы не дублировать каждый раз код, создадим универсальный шаблон для тренировок.

1. Создадим класс ModelEvaluator, c помощью которого будем оценивать наши модели.

Он понадобится нам для оценки качества PyTorch моделей на датасетах.
Принимает модель, устройство и тип задачи (CVNLPTABULAR), автоматически парсит батчи соответствующим образом и вычисляет accuracy на train/val выборках. Включает model.eval() и torch.no_grad() для корректной работы и экономии памяти [3], возвращает метрики качества.

Оценка моделей
class ModelEvaluator:
    """
    Универсальный оценщик PyTorch моделей на датасетах.
    Автоматически вычисляет accuracy для train/val выборок.
    """

    def __init__(self, model: nn.Module, device: torch.device, task_type: str):
        """
        Инициализация ModelEvaluator.

        Args:
            model: PyTorch модель для оценки
            device: Устройство вычислений ('cuda' или 'cpu')
            task_type: Тип задачи ('CV', 'NLP', 'TABULAR')
        """
        self.model = model
        self.device = device
        self.task_type = task_type

        self.model.to(self.device)

        available_task_types = ['CV', 'NLP', 'TABULAR']
        if self.task_type not in available_task_types:
            raise ValueError(f"task_type can be only: {available_task_types}")

    def evaluate_dataset(self, loader: DataLoader) -> Dict[str, float]:
        """
        Оценка модели на датасете. Возвращает словарь метрик.

        Args:
            loader: DataLoader с данными

        Returns:
            Метрики, пример: {"accuracy": 0.95}
        """
        self.model.eval()
        correct = 0
        total = 0

        with torch.no_grad():
            for batch in loader:
                if self.task_type in ['CV', 'TABULAR']:
                    inputs, target = batch
                    inputs = inputs.to(self.device)
                    target = target.to(self.device)
                    outputs = self.model(inputs)
                elif self.task_type == 'NLP':
                    inputs = batch['input_ids'].to(self.device)
                    target = batch['label'].to(self.device)
                    attention_mask = batch['attention_mask'].to(self.device)
                    outputs = self.model(inputs, attention_mask)

                _, predicted = torch.max(outputs, dim=1)
                correct += (predicted == target).sum().item()
                total += target.size(0)

        accuracy: float = correct / total
        return {"accuracy": accuracy}

    def evaluate(self, train_loader: DataLoader, val_loader: DataLoader) -> Dict[str, Dict[str, float]]:
        """
        Полная оценка: train + val выборки одним вызовом.

        Args:
            train_loader: Обучающая выборка
            val_loader: Валидационная выборка

        Returns:
            Пример: {"train": {"accuracy": 0.90}, "val": {"accuracy": 0.95}}
        """
        print(f"Evaluating on device {self.device}.")
        metrics = {}
        metrics["train"] = self.evaluate_dataset(train_loader)
        metrics["val"] = self.evaluate_dataset(val_loader)
        return metrics

2. Создадим класс Trainer, который будет обучать наши модели.

Этот класс сможет автоматизировать весь цикл обучения PyTorch моделей.
Принимает модель, оптимизатор, функцию потерь, данные, тип задачи (CVNLPTABULAR) и парсит батчи соответствующим образом. Выполняет forward/backward проходы, градиентный спуск, логирует loss по эпохам.

Обучение моделей
class Trainer:
    """
    Универсальный класс для обучения PyTorch моделей.
    """

    def __init__(
        self,
        model: nn.Module,
        optimizer: optim.Optimizer,
        loss_fn: nn.Module,
        train_loader: DataLoader,
        device: torch.device,
        task_type: str,
        clip_grad_norm: Optional[float] = None,
        print_interval: int = 10,
    ):
        """
        Инициализация Trainer.

        Args:
            model: Нейронная сеть для обучения
            optimizer: Оптимизатор
            loss_fn: Функция потерь
            train_loader: Данные для обучения
            device: Устройство для тренировки: 'cuda' или 'cpu'
            task_type: Тип задачи: 'CV', 'TABULAR' или 'NLP'
            clip_grad_norm: Максимальная норма градиента (защита от взрыва)
            print_interval: Печатать loss каждые N эпох
        """
        self.model = model
        self.optimizer = optimizer
        self.loss_fn = loss_fn
        self.train_loader = train_loader
        self.device = device
        self.task_type = task_type
        self.clip_grad_norm = clip_grad_norm
        self.print_interval = print_interval

        self.model.to(self.device)

        available_task_types = ['CV','NLP','TABULAR']
        if self.task_type not in available_task_types:
            raise ValueError(f"task_type can be only: {available_task_types}")

    def train_one_batch(self, inputs: torch.Tensor, target: torch.Tensor, attention_mask: Optional[torch.Tensor] = None) -> float:
        """
        Обучение на одном батче.

        Args:
            inputs: Входные данные
            target: Целевые метки
            attention_mask: Маска внимания для NLP (опционально)

        Returns:
            Значение loss для батча
        """
        self.model.train()

        self.optimizer.zero_grad(set_to_none=True)

        if attention_mask is None:
            outputs = self.model(inputs)
        else:
            outputs = self.model(inputs, attention_mask=attention_mask)
        loss = self.loss_fn(outputs, target)

        loss.backward()

        if self.clip_grad_norm is not None:
            torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.clip_grad_norm)

        self.optimizer.step()

        return loss.item()

    def train_epoch(self, epoch_num: int = 0) -> float:
        """
        Тренировка модели на одной эпохе.

        Args:
            epoch_num: Номер эпохи (для логирования)

        Returns:
            Средний loss по эпохе
        """
        self.model.train()
        loss_train = 0.0
        num_batches = len(self.train_loader)
        for batch in self.train_loader:
            if self.task_type in ['CV','TABULAR']:
                inputs, target = batch
                inputs = inputs.to(self.device)
                target = target.to(self.device)
                loss_train += self.train_one_batch(inputs, target)
            elif self.task_type == 'NLP':
                inputs = batch['input_ids'].to(self.device)
                target = batch['label'].to(self.device)
                attention_mask = batch['attention_mask'].to(self.device)
                loss_train += self.train_one_batch(inputs, target, attention_mask)

        return loss_train / num_batches

    def training_loop(self, n_epochs: int) -> None:
        """
        Основной цикл обучения.

        Args:
            n_epochs: Количество эпох
        """

        print(f"Training on device {self.device}.")

        for epoch in range(1, n_epochs + 1):
            avg_loss = self.train_epoch(epoch)

            if epoch == 1 or epoch % self.print_interval == 0:
                print(f"{datetime.datetime.now()} Epoch {epoch}, Training loss: {avg_loss:.4f}")

1. Компьютерное зрение (Computer Vision – CV)

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

Для примера рассмотрим задачу, в которой необходимо, исходя из изображения, классифицировать, к какой категории одежды относится изображение вещи. Для этого используем уже готовый набор данных Fashion MNIST [4]. Из особенностей этого набора данных следует отметить, что изображения имеют размерность (1,28,28), то есть за цвет отвечает только один канал (изображения черно-белые), часто в подобных задачах еще можно встретить 3 канала (RGB – red, green, blue)

Создание модели

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

Функция compute_mean_and_std
def compute_mean_and_std(loader: DataLoader, dim: list, device="cpu") -> tuple[torch.Tensor, torch.Tensor]:
    """
    Вычисление среднего значения и стандартного отклонения в наборе данных.

    Args:
        loader (DataLoader): DataLoader для итерации по набору данных
        dim (list): Размерности, по которым будут считатьcя значения
        device (str): Устройство, на котором выполняются вычисления ("cpu" или "cuda").

    Returns:
        tuple[torch.Tensor, torch.Tensor]: Кортеж, содержащий тензоры среднего значения и стандартного отклонения.
    """
    # 1 Создаем список из всех тензоров
    inputs_list = []
    for inputs, _ in loader:
        inputs_list.append(inputs)

    # 2 Соединяем все тензоры в один большой тензор
    all_inputs = torch.cat(inputs_list, dim=0).float().to(device)  # Преобразуем в float и перемещаем на устройство

    # 3 Вычисляем mean и std
    mean = torch.mean(all_inputs, dim=dim)
    std = torch.std(all_inputs, dim=dim)

    return mean, std

Теперь мы можем создать модель.

Computer Vision model
# 1 Создаем словарь с классами
class_names = ['T-shirt/top', 'Trouser', 'Pullover', 'Dress', 'Coat',
               'Sandal', 'Shirt', 'Sneaker', 'Bag', 'Ankle boot']
class_names_dict = dict(zip(range(len(class_names)), class_names))

# 2 Загрузка и подготовка данных (без нормализации), получение Mean и Std
trainset = torchvision.datasets.FashionMNIST(root='./data', train=True, download=True,
                                             transform=transforms.Compose([
                                                 transforms.ToTensor()
                                             ]))
# 2.1 создаем dataloader
dataloader = DataLoader(trainset, batch_size=64, shuffle=False)
# 2.2 считаем mean и std
mean, std = compute_mean_and_std(dataloader, [0, 2, 3])
print(f"Mean: {mean}")
print(f"Std Dev: {std}")

# 3 Загрузка и подготовка данных (с нормализацией)
trainset = torchvision.datasets.FashionMNIST(
    root='./data', train=True, download=True,
    transform=transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize(mean, std)
    ]))  # Тренировочный набор
valset = torchvision.datasets.FashionMNIST(
    root='./data', train=False, download=True,
    transform=transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize(mean, std)
    ]))  # Валидационный набор

# 4 Создаем dataloders
batch_size = 256
train_loader = DataLoader(trainset, batch_size=batch_size,
                          shuffle=True)
val_loader = DataLoader(valset, batch_size=batch_size,
                        shuffle=False)


# 5 Определяем архитектуру нейронной сети
class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(1, 28, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(28, 56, kernel_size=3, padding=1)
        self.dropout1 = nn.Dropout(0.1)
        self.dropout2 = nn.Dropout(0.2)
        self.fc1 = nn.Linear(56 * 14 * 14, 112)
        self.fc2 = nn.Linear(112, 10)

    def forward(self, x):
        x = nn.functional.relu(self.conv1(x))
        x = nn.functional.relu(self.conv2(x))
        x = nn.functional.max_pool2d(self.dropout1(x), 2)
        x = torch.flatten(x, 1)
        x = nn.functional.relu(self.fc1(self.dropout2(x)))
        x = self.fc2(x)
        return x


# 6 Настройка модели, оптимизатора и функции потерь
model = Net().to(device)
optimizer = optim.Adam(model.parameters(), lr=1e-2)
loss_fn = nn.CrossEntropyLoss()

# 7.1 Создание и настройка Trainer
task_type = 'CV'
trainer = Trainer(
    model=model,
    optimizer=optimizer,
    loss_fn=loss_fn,
    train_loader=train_loader,
    device=device,
    task_type=task_type,
    clip_grad_norm=1.0,
    print_interval=1,
)
# 7.2 Запуск процесса обучения
trainer.training_loop(n_epochs=10)

# 8 Оценка обученной модели
evaluator = ModelEvaluator(model, device, task_type)
metrics = evaluator.evaluate(train_loader, val_loader)
print(metrics)
Вывод

Вывод
Пример работы модели
number = 10
data, label = valset[number]
plt.imshow(data.squeeze(), cmap='gray')
plt.show()

# predict
model.eval()
outputs = model(data.to(device).unsqueeze(0))
_, predicted = torch.max(outputs, dim=1)

# validate
real_answer = class_names_dict[label]
model_answer = class_names_dict[predicted.item()]
if real_answer == model_answer:
    result = 'correct'
else:
    result = 'incorrect'

print(f"""
Real answer: {real_answer}
Model answer: {model_answer}

Result:
Model response is {result}
""")
Вывод

Вывод

2. Обработка естественного языка (Natural Language Processing – NLP)

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

В этом разделе мы рассмотрим одну из задач NLP: анализ тональности.
Для этого мы будем использовать IMDB датасет [5]. В нём содержится 50 000 кино-отзывов (25K train и 25K test), размечены на  negative (0) / positive (1).

Начнем создание модели

Natural Language Processing model
# 1 Создаем словарь с классами
class_names = ['Negative', 'Positive']
class_names_dict = dict(zip(range(len(class_names)), class_names))

def tokenize_dataset(dataset):
    # Инициализация токенизатора (использовал BERT)
    tokenizer = AutoTokenizer.from_pretrained('bert-base-uncased')

    # Функция для токенизации и подготовки данных
    def tokenize_function(examples):
        return tokenizer(examples["text"], padding="max_length", truncation=True, max_length=256)

    #  Токенизация всего датасета
    tokenized_dataset = dataset.map(tokenize_function, batched=True)

    tokenized_dataset.set_format("torch", columns=["input_ids", "attention_mask", "label"])
    return tokenized_dataset

# 2 Загрузка данных и создание Dataloader
batch_size = 64
# 2.1 Train
trainset = load_dataset("imdb", split="train", cache_dir="./data")
train_loader = DataLoader(tokenize_dataset(trainset), batch_size=batch_size, shuffle=True)
# 2.2 Test
valset = load_dataset("imdb", split="test", cache_dir="./data")
val_loader = DataLoader(tokenize_dataset(valset), batch_size=batch_size, shuffle=False)

# 3 Определение модели (LSTM)
class LSTMClassifier(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_classes, lstm_layers, dropout):
        super(LSTMClassifier, self).__init__()
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.lstm = nn.LSTM(embedding_dim, hidden_dim, num_layers=lstm_layers, batch_first=True, dropout=dropout,
                            bidirectional=True)
        self.dropout = nn.Dropout(dropout)
        self.linear = nn.Linear(hidden_dim * 2, num_classes)

    def forward(self, input_ids, attention_mask):
        # attention_mask присутствует, но не используется в LSTM

        embedded = self.embedding(input_ids)
        lstm_out, _ = self.lstm(embedded)

        pooled_output = torch.mean(lstm_out, dim=1)

        pooled_output = self.dropout(pooled_output)
        logits = self.linear(pooled_output)
        return logits

# 4 Параметры модели
vocab_size = AutoTokenizer.from_pretrained('bert-base-uncased').vocab_size
embedding_dim = 128
hidden_dim = 64
num_classes = 2
lstm_layers = 2
dropout = 0.5

# 5 Настройка модели, оптимизатора и функции потерь
model = LSTMClassifier(vocab_size, embedding_dim, hidden_dim, num_classes, lstm_layers, dropout).to(device)
optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-2)
loss_fn = nn.CrossEntropyLoss()

# 6.1 Создание и настройка тренера (Trainer)
task_type = 'NLP'
trainer = Trainer(
    model=model,
    optimizer=optimizer,
    loss_fn=loss_fn,
    train_loader=train_loader,
    device=device,
    task_type=task_type,
    clip_grad_norm=1.0,
    print_interval=1,
)
# 6.2 Запуск процесса обучения
trainer.training_loop(n_epochs=10)

# 7 Оценка обученной модели
evaluator = ModelEvaluator(model, device, task_type)
metrics = evaluator.evaluate(train_loader, val_loader)
print(metrics)
Вывод

Вывод
Пример работы модели
number = 49
data = valset[number]
print(data['text'])
dataset_dict = {
    "text": [data["text"]],
    "label": [data["label"]]}
single_dataset = HFDataset.from_dict(dataset_dict)
single_dataset = tokenize_dataset(single_dataset)

inputs = single_dataset['input_ids'].to(device)
target = single_dataset['label'].to(device)
attention_mask = single_dataset['attention_mask'].to(device)

# predict
model.eval()
outputs = model(inputs, attention_mask)
_, predicted = torch.max(outputs, dim=1)

# validate
real_answer = class_names_dict[int(target[0])]
model_answer = class_names_dict[int(predicted[0])]
if real_answer == model_answer:
    result = 'correct'
else:
    result = 'incorrect'

print(f"""
Real answer: {real_answer}
Model answer: {model_answer}

Result:
Model response is {result}
""")
Вывод

Вывод

3. Работа с табличными данными (Tabular Data)

Табличные данные, представленные в виде структурированных таблиц со строками и столбцами, являются одним из наиболее распространенных форматов данных в бизнесе, науке [6] и других областях. Нейросети (MLP, TabNet) решают здесь задачи классификации, регрессии и находят нелинейные паттерны, но часто уступают градиентному бустингу (XGBoost/LightGBM) по скорости и точности на малых датасетах.

Работать будем с классическим датасетом Iris [7]: 150 семплов, 4 числовые фичи, 3 класса ириса.

Tabular Data model
# 1 Создаем словарь с классами
class_names = ['setosa', 'versicolor', 'virginica']
class_names_dict = dict(zip(range(len(class_names)), class_names))

# 2 Загрузка и подготовка данных
iris = load_iris()
iris_data = iris['data']
iris_target = iris['target']

# 3 Разделение на train и val
train_data, val_data, train_target, val_target = train_test_split(
    iris_data, iris_target, test_size=0.2, random_state=42
)

# 4 Нормализация данных (StandardScaler)
scaler = StandardScaler()
train_data_normalized = scaler.fit_transform(train_data)
val_data_normalized = scaler.transform(val_data)

# 5 Создание кастомного Dataset
class IrisDataset(TorchDataset):
    def __init__(self, X, y, transform=None):
        self.X = X.astype(np.float32)
        self.y = y.astype(np.int64)
        self.transform = transform

    def __len__(self):
        return len(self.X)

    def __getitem__(self, idx):
        sample = self.X[idx]
        label = self.y[idx]

        if self.transform:
            sample = self.transform(sample)

        return sample, label


# 6 Создание Dataset
trainset = IrisDataset(train_data_normalized, train_target)
valset = IrisDataset(val_data_normalized, val_target)

# 7 Создание DataLoaders
batch_size = 32
train_loader = DataLoader(trainset, batch_size=batch_size, shuffle=True)
val_loader = DataLoader(valset, batch_size=batch_size, shuffle=False)

# 8 Определяем архитектуру нейронной сети
class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.fc1 = nn.Linear(4, 8)
        self.fc2 = nn.Linear(8, 16)
        self.fc3 = nn.Linear(16, 3)
        self.dropout = nn.Dropout(0.1)

    def forward(self, x):
        x = nn.functional.relu(self.fc1(x))
        x = self.dropout(x)
        x = nn.functional.relu(self.fc2(x))
        x = self.dropout(x)
        x = self.fc3(x)
        return x


# 9 Настройка модели, оптимизатора и функции потерь
model = Net().to(device)
optimizer = optim.Adam(model.parameters(), lr=1e-2)
loss_fn = nn.CrossEntropyLoss()

# 10.1 Создание и настройка тренера (Trainer)
task_type = 'TABULAR'
trainer = Trainer(
    model=model,
    optimizer=optimizer,
    loss_fn=loss_fn,
    train_loader=train_loader,
    device=device,
    task_type=task_type,
    clip_grad_norm=1.0,
    print_interval=1,
)
# 10.2 Запуск процесса обучения
trainer.training_loop(n_epochs=10)

# 11 Оценка обученной модели
evaluator = ModelEvaluator(model, device, task_type)
metrics = evaluator.evaluate(train_loader, val_loader)
print(metrics)
Вывод

Вывод
Пример работы модели
number = 10
data, label = valset[number]
df = pd.DataFrame([data], columns=iris['feature_names'])
print(df)

# predict
model.eval()
with torch.no_grad():
    data = torch.from_numpy(data).float()
    outputs = model(data.to(device).unsqueeze(0))
    _, predicted = torch.max(outputs, dim=1)

# validate
real_answer = class_names_dict[label]
model_answer = class_names_dict[predicted.item()]
if real_answer == model_answer:
    result = 'correct'
else:
    result = 'incorrect'

print(f"""
Real answer: {real_answer}
Model answer: {model_answer}

Result:
Model response is {result}
""")
Вывод

Вывод

Заключение

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

Компьютерное зрение (CV)
CNN на FashionMNIST демонстрирует результат ~91% accuracy. Нейросети здесь извлекают сложные визуальные паттерны (текстуру, форму), недоступные классическому ML.

Обработка естественного языка (NLP)
LSTM на IMDB sentiment analysis даёт неплохие ~83% accuracy. BERT токенизатор разбивает отзывы на слова, двунаправленная LSTM понимает контекст с обеих сторон, а усреднение превращает весь текст в один вектор настроения.

Табличные данные (Tabular Data)
MLP на Iris показывает ~84% accuracy. Нейросети полезны для нелинейных взаимодействий фич, но на малых датасетах зачастую проигрывают tree-based методам.

Автор: pavel_shunkevich

Источник [8]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/27321

URLs in this post:

[1] зрение: http://www.braintools.ru/article/6238

[2] обучения: http://www.braintools.ru/article/5125

[3] памяти: http://www.braintools.ru/article/4140

[4] Fashion MNIST: https://github.com/zalandoresearch/fashion-mnist?ysclid=m793jiwwbm474653048

[5] IMDB датасет: https://huggingface.co/datasets/stanfordnlp/imdb

[6] науке: http://www.braintools.ru/article/7634

[7] Iris: https://scikit-learn.org/stable/modules/generated/sklearn.datasets.load_iris.html

[8] Источник: https://habr.com/ru/articles/883186/?utm_source=habrahabr&utm_medium=rss&utm_campaign=883186

www.BrainTools.ru

Rambler's Top100