From bad9cb85922a13afaa856e9bc7f62ca36f3a046d Mon Sep 17 00:00:00 2001 From: Creylay Date: Mon, 25 May 2026 16:36:36 -0400 Subject: [PATCH 1/8] fix: update DESCRIPTION for ImageClassificationTask to provide concise information --- DashAI/back/tasks/image_classification_task.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/DashAI/back/tasks/image_classification_task.py b/DashAI/back/tasks/image_classification_task.py index 4601886f4..e063164cc 100644 --- a/DashAI/back/tasks/image_classification_task.py +++ b/DashAI/back/tasks/image_classification_task.py @@ -20,19 +20,8 @@ class ImageClassificationTask(ClassificationTask): """ DESCRIPTION: str = MultilingualString( - en=( - "Image classification in machine learning involves predicting " - "categorical labels for image data. Models are trained to learn " - "visual patterns and features in images, enabling accurate " - "classification of new instances." - ), - es=( - "La clasificación de imágenes en el aprendizaje automático implica " - "predecir etiquetas categóricas para datos de imágenes. Los modelos " - "se entrenan para aprender patrones visuales y características en " - "las imágenes, lo que permite una clasificación precisa de nuevas " - "instancias." - ), + en="Predict categorical labels from image data.", + es="Predice etiquetas categóricas a partir de datos de imágenes.", ) DISPLAY_NAME: str = MultilingualString( en="Image Classification", es="Clasificación de Imágenes" From c8b7a5cb33495aecbe10c13fdb3d7f392766ec3f Mon Sep 17 00:00:00 2001 From: Creylay Date: Mon, 25 May 2026 16:40:59 -0400 Subject: [PATCH 2/8] feat: add configuration parameters for batch size, image size, dropout rate, and weight decay in MLPImageClassifier --- DashAI/back/models/mlp_image_classifier.py | 144 ++++++++++++++++++--- 1 file changed, 126 insertions(+), 18 deletions(-) diff --git a/DashAI/back/models/mlp_image_classifier.py b/DashAI/back/models/mlp_image_classifier.py index 70775ea6a..ba735e1ce 100644 --- a/DashAI/back/models/mlp_image_classifier.py +++ b/DashAI/back/models/mlp_image_classifier.py @@ -1,5 +1,6 @@ """MLP-based image classifier for DashAI.""" +import numpy as np import torch import torch.nn as nn import torch.optim as optim @@ -65,16 +66,87 @@ class MLPImageClassifierSchema(BaseSchema): ), ) # type: ignore + batch_size: schema_field( + int_field(ge=1), + placeholder=32, + description=MultilingualString( + en=( + "Number of images processed together in each training step. " + "Larger values speed up training but require more memory." + ), + es=( + "Número de imágenes procesadas juntas en cada paso de entrenamiento. " + "Valores más grandes aceleran el entrenamiento " + "pero requieren más memoria." + ), + ), + alias=MultilingualString(en="Batch size", es="Tamaño de lote"), + ) # type: ignore + + image_size: schema_field( + int_field(ge=8), + placeholder=64, + description=MultilingualString( + en=( + "Images are resized to this value (in pixels) for both width " + "and height before training. Larger sizes preserve more detail " + "but increase training time." + ), + es=( + "Las imágenes se redimensionan a este valor (en píxeles) " + "tanto en ancho como en alto antes del entrenamiento. " + "Tamaños más grandes preservan más detalle " + "pero aumentan el tiempo de entrenamiento." + ), + ), + alias=MultilingualString(en="Image size", es="Tamaño de imagen"), + ) # type: ignore + + dropout_rate: schema_field( + float_field(ge=0.0, lt=1.0), + placeholder=0.0, + description=MultilingualString( + en=( + "Fraction of neurons randomly deactivated during each training step. " + "Values between 0.2 and 0.5 help prevent overfitting. " + "Use 0.0 to disable." + ), + es=( + "Fracción de neuronas desactivadas aleatoriamente en cada paso de " + "entrenamiento. Valores entre 0.2 y 0.5 ayudan a prevenir " + "el sobreajuste. Use 0.0 para desactivarlo." + ), + ), + alias=MultilingualString(en="Dropout rate", es="Tasa de dropout"), + ) # type: ignore + + weight_decay: schema_field( + float_field(ge=0.0), + placeholder=0.0, + description=MultilingualString( + en=( + "L2 regularization coefficient for the Adam optimizer. Penalizes large " + "weights to improve generalization. Typical values: 1e-4 to 1e-2." + ), + es=( + "Coeficiente de regularización L2 para el optimizador Adam. Penaliza " + "pesos grandes para mejorar la generalización. " + "Valores típicos: 1e-4 a 1e-2." + ), + ), + alias=MultilingualString(en="Weight decay", es="Decaimiento de pesos"), + ) # type: ignore + class _ImageDataset(torch.utils.data.Dataset): """Torch Dataset wrapper for DashAI image datasets.""" - def __init__(self, x_dataset, y_dataset=None): + def __init__(self, x_dataset, y_dataset=None, image_size=64): self.x_dataset = x_dataset self.y_dataset = y_dataset self.transforms = transforms.Compose( [ - transforms.Resize((30, 30)), + transforms.Resize((image_size, image_size)), transforms.ToTensor(), ] ) @@ -115,13 +187,15 @@ def __getitem__(self, idx): class _MLP(nn.Module): """Multi-Layer Perceptron for image classification.""" - def __init__(self, input_dim, output_dim, hidden_dims): + def __init__(self, input_dim, output_dim, hidden_dims, dropout_rate=0.0): super().__init__() self.hidden_layers = nn.ModuleList() + self.dropout_layers = nn.ModuleList() previous_dim = input_dim for hidden_dim in hidden_dims: self.hidden_layers.append(nn.Linear(previous_dim, hidden_dim)) + self.dropout_layers.append(nn.Dropout(dropout_rate)) previous_dim = hidden_dim self.output_layer = nn.Linear(previous_dim, output_dim) @@ -131,8 +205,8 @@ def forward(self, x: torch.Tensor): batch_size = x.shape[0] x = x.view(batch_size, -1) - for layer in self.hidden_layers: - x = self.relu(layer(x)) + for layer, dropout in zip(self.hidden_layers, self.dropout_layers): + x = dropout(self.relu(layer(x))) return self.output_layer(x) @@ -177,12 +251,26 @@ def _collate_fn_no_labels(batch): """Custom collate function for batches with only images.""" return torch.stack(batch) - def __init__(self, epochs=10, learning_rate=0.001, hidden_dims=None, **kwargs): + def __init__( + self, + epochs=10, + learning_rate=0.001, + hidden_dims=None, + batch_size=32, + image_size=64, + dropout_rate=0.0, + weight_decay=0.0, + **kwargs, + ): if hidden_dims is None: hidden_dims = [128, 64] self.epochs = epochs self.learning_rate = learning_rate self.hidden_dims = hidden_dims + self.batch_size = batch_size + self.image_size = image_size + self.dropout_rate = dropout_rate + self.weight_decay = weight_decay self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") self.model = None self.optimizer = None @@ -225,7 +313,9 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): MLPImageClassifier The trained model instance. """ - image_dataset = _ImageDataset(x_train, y_dataset=y_train) + image_dataset = _ImageDataset( + x_train, y_dataset=y_train, image_size=self.image_size + ) self.input_dim = ( image_dataset.tensor_shape[0] @@ -239,16 +329,20 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): train_loader = torch.utils.data.DataLoader( image_dataset, - batch_size=32, + batch_size=self.batch_size, shuffle=True, collate_fn=self._collate_fn_with_labels, ) - self.model = _MLP(self.input_dim, self.output_dim, self.hidden_dims).to( - self.device - ) + self.model = _MLP( + self.input_dim, self.output_dim, self.hidden_dims, self.dropout_rate + ).to(self.device) criterion = nn.CrossEntropyLoss() - self.optimizer = optim.Adam(self.model.parameters(), lr=self.learning_rate) + self.optimizer = optim.Adam( + self.model.parameters(), + lr=self.learning_rate, + weight_decay=self.weight_decay, + ) self.model.train() for _ in range(self.epochs): @@ -275,10 +369,10 @@ def predict(self, x): list of lists List of predicted probabilities for each class for each image. """ - image_dataset = _ImageDataset(x, y_dataset=None) + image_dataset = _ImageDataset(x, y_dataset=None, image_size=self.image_size) test_loader = torch.utils.data.DataLoader( image_dataset, - batch_size=32, + batch_size=self.batch_size, shuffle=False, collate_fn=self._collate_fn_no_labels, ) @@ -290,9 +384,9 @@ def predict(self, x): images = images.to(self.device) logits = self.model(images) probs = torch.softmax(logits, dim=1) - all_probs += probs.cpu().tolist() + all_probs.append(probs.cpu().numpy()) - return all_probs + return np.concatenate(all_probs, axis=0) def save(self, filename: str) -> None: """Save the model checkpoint to disk. @@ -308,6 +402,10 @@ def save(self, filename: str) -> None: "epochs": self.epochs, "learning_rate": self.learning_rate, "hidden_dims": self.hidden_dims, + "batch_size": self.batch_size, + "image_size": self.image_size, + "dropout_rate": self.dropout_rate, + "weight_decay": self.weight_decay, "input_dim": self.input_dim, "output_dim": self.output_dim, "idx_to_label": self.idx_to_label, @@ -334,14 +432,24 @@ def load(cls, filename: str): epochs=checkpoint["epochs"], learning_rate=checkpoint["learning_rate"], hidden_dims=checkpoint["hidden_dims"], + batch_size=checkpoint.get("batch_size", 32), + image_size=checkpoint.get("image_size", 64), + dropout_rate=checkpoint.get("dropout_rate", 0.0), + weight_decay=checkpoint.get("weight_decay", 0.0), ) instance.input_dim = checkpoint["input_dim"] instance.output_dim = checkpoint["output_dim"] instance.model = _MLP( - instance.input_dim, instance.output_dim, instance.hidden_dims + instance.input_dim, + instance.output_dim, + instance.hidden_dims, + instance.dropout_rate, ) instance.model.load_state_dict(checkpoint["model_state_dict"]) - instance.optimizer = optim.Adam(instance.model.parameters()) + instance.optimizer = optim.Adam( + instance.model.parameters(), + weight_decay=instance.weight_decay, + ) instance.optimizer.load_state_dict(checkpoint["optimizer_state_dict"]) instance.idx_to_label = checkpoint.get("idx_to_label", {}) instance.label_to_idx = checkpoint.get("label_to_idx", {}) From 06f0d39240cb9385233a6aed5c0374a5b1d1e0ac Mon Sep 17 00:00:00 2001 From: Creylay Date: Tue, 26 May 2026 16:09:41 -0400 Subject: [PATCH 3/8] Add image classification models: EfficientNet-B0, LeNet-5, ResNet-18, ResNet-50, VGG-16 - Implement EfficientNet-B0 image classifier with support for pretrained weights. - Implement LeNet-5 image classifier with configurable dropout and training parameters. - Implement ResNet-18 image classifier with skip connections and pretrained weights. - Implement ResNet-50 image classifier using bottleneck blocks and pretrained weights. - Implement VGG-16 image classifier with 3x3 convolutions and pretrained weights. - Add documentation for image classification models, including descriptions, architectures, and usage recommendations. --- DashAI/back/initial_components.py | 14 + .../base_torchvision_image_classifier.py | 429 +++++++++++++++ DashAI/back/models/cnn_image_classifier.py | 500 ++++++++++++++++++ .../efficientnet_b0_image_classifier.py | 53 ++ DashAI/back/models/lenet5_image_classifier.py | 425 +++++++++++++++ .../back/models/resnet18_image_classifier.py | 53 ++ .../back/models/resnet50_image_classifier.py | 54 ++ DashAI/back/models/vgg16_image_classifier.py | 52 ++ 8 files changed, 1580 insertions(+) create mode 100644 DashAI/back/models/base_torchvision_image_classifier.py create mode 100644 DashAI/back/models/cnn_image_classifier.py create mode 100644 DashAI/back/models/efficientnet_b0_image_classifier.py create mode 100644 DashAI/back/models/lenet5_image_classifier.py create mode 100644 DashAI/back/models/resnet18_image_classifier.py create mode 100644 DashAI/back/models/resnet50_image_classifier.py create mode 100644 DashAI/back/models/vgg16_image_classifier.py diff --git a/DashAI/back/initial_components.py b/DashAI/back/initial_components.py index 1063a11bd..9e9c5afcf 100644 --- a/DashAI/back/initial_components.py +++ b/DashAI/back/initial_components.py @@ -123,6 +123,10 @@ from DashAI.back.metrics.translation.bleu import Bleu from DashAI.back.metrics.translation.chrf import Chrf from DashAI.back.metrics.translation.ter import Ter +from DashAI.back.models.cnn_image_classifier import CNNImageClassifier +from DashAI.back.models.efficientnet_b0_image_classifier import ( + EfficientNetB0ImageClassifier, +) # Models from DashAI.back.models.hugging_face.albert_transformer import AlbertTransformer @@ -195,7 +199,10 @@ XlmRobertaTransformer, ) from DashAI.back.models.hugging_face.xlnet_transformer import XlnetTransformer +from DashAI.back.models.lenet5_image_classifier import LeNet5ImageClassifier from DashAI.back.models.mlp_image_classifier import MLPImageClassifier +from DashAI.back.models.resnet18_image_classifier import ResNet18ImageClassifier +from DashAI.back.models.resnet50_image_classifier import ResNet50ImageClassifier from DashAI.back.models.scikit_learn.adaboost_classifier import AdaBoostClassifier from DashAI.back.models.scikit_learn.adaboost_regression import AdaBoostRegression from DashAI.back.models.scikit_learn.bagging_classifier import BaggingClassifier @@ -250,6 +257,7 @@ from DashAI.back.models.scikit_learn.tfidf_logreg_text_classification_model import ( TfIdfLogRegTextClassificationModel, ) +from DashAI.back.models.vgg16_image_classifier import VGG16ImageClassifier # Optimizers from DashAI.back.optimizers.hyperopt_optimizer import HyperOptOptimizer @@ -372,6 +380,12 @@ def get_initial_components(): XlmRobertaTransformer, XlnetTransformer, MLPImageClassifier, + CNNImageClassifier, + LeNet5ImageClassifier, + VGG16ImageClassifier, + ResNet18ImageClassifier, + ResNet50ImageClassifier, + EfficientNetB0ImageClassifier, # Dataloaders ARFFDataLoader, CSVDataLoader, diff --git a/DashAI/back/models/base_torchvision_image_classifier.py b/DashAI/back/models/base_torchvision_image_classifier.py new file mode 100644 index 000000000..131d6a486 --- /dev/null +++ b/DashAI/back/models/base_torchvision_image_classifier.py @@ -0,0 +1,429 @@ +"""Shared base class for torchvision-based image classifiers.""" + +import abc + +import numpy as np +import torch +import torch.nn as nn +import torch.optim as optim +import torch.utils.data +from torchvision import transforms + +from DashAI.back.core.schema_fields import ( + BaseSchema, + bool_field, + float_field, + int_field, + schema_field, +) +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.base_model import BaseModel + + +class TorchvisionImageClassifierSchema(BaseSchema): + """Shared training parameters for torchvision-based image classifiers.""" + + epochs: schema_field( + int_field(ge=1), + placeholder=10, + description=MultilingualString( + en=( + "The number of epochs to train the model. An epoch is a full " + "iteration over the training data." + ), + es=( + "El número de épocas para entrenar el modelo. Una época es una " + "iteración completa sobre los datos de entrenamiento." + ), + ), + alias=MultilingualString(en="Epochs", es="Épocas"), + ) # type: ignore + + learning_rate: schema_field( + float_field(gt=0.0), + placeholder=0.001, + description=MultilingualString( + en="Learning rate for the Adam optimizer.", + es="Tasa de aprendizaje para el optimizador Adam.", + ), + alias=MultilingualString(en="Learning rate", es="Tasa de aprendizaje"), + ) # type: ignore + + batch_size: schema_field( + int_field(ge=1), + placeholder=32, + description=MultilingualString( + en=( + "Number of images processed together in each training step. " + "Larger values speed up training but require more memory." + ), + es=( + "Número de imágenes procesadas juntas en cada paso de " + "entrenamiento. Valores más grandes aceleran el entrenamiento " + "pero requieren más memoria." + ), + ), + alias=MultilingualString(en="Batch size", es="Tamaño de lote"), + ) # type: ignore + + image_size: schema_field( + int_field(ge=32), + placeholder=224, + description=MultilingualString( + en=( + "Images are resized to this value (in pixels) for both width " + "and height. Use 224 for ImageNet-pretrained models." + ), + es=( + "Las imágenes se redimensionan a este valor (en píxeles) tanto " + "en ancho como en alto. Use 224 para modelos preentrenados " + "en ImageNet." + ), + ), + alias=MultilingualString(en="Image size", es="Tamaño de imagen"), + ) # type: ignore + + dropout_rate: schema_field( + float_field(ge=0.0, lt=1.0), + placeholder=0.0, + description=MultilingualString( + en=( + "Dropout rate applied before the output layer. " + "Values between 0.2 and 0.5 help prevent overfitting." + ), + es=( + "Tasa de dropout aplicada antes de la capa de salida. " + "Valores entre 0.2 y 0.5 ayudan a prevenir el sobreajuste." + ), + ), + alias=MultilingualString(en="Dropout rate", es="Tasa de dropout"), + ) # type: ignore + + weight_decay: schema_field( + float_field(ge=0.0), + placeholder=0.0, + description=MultilingualString( + en=( + "L2 regularization coefficient for the Adam optimizer. " + "Typical values: 1e-4 to 1e-2." + ), + es=( + "Coeficiente de regularización L2 para el optimizador Adam. " + "Valores típicos: 1e-4 a 1e-2." + ), + ), + alias=MultilingualString(en="Weight decay", es="Decaimiento de pesos"), + ) # type: ignore + + pretrained: schema_field( + bool_field(), + placeholder=True, + description=MultilingualString( + en=( + "If True, loads weights pre-trained on ImageNet. " + "Recommended when your dataset is small or similar to natural images." + ), + es=( + "Si es True, carga pesos preentrenados en ImageNet. " + "Recomendado cuando el dataset es pequeño o similar " + "a imágenes naturales." + ), + ), + alias=MultilingualString(en="Pretrained", es="Preentrenado"), + ) # type: ignore + + freeze_backbone: schema_field( + bool_field(), + placeholder=False, + description=MultilingualString( + en=( + "If True, freezes the convolutional backbone and only trains " + "the classifier head. Useful for very small datasets." + ), + es=( + "Si es True, congela el backbone convolucional y solo entrena " + "el clasificador final. Útil para datasets muy pequeños." + ), + ), + alias=MultilingualString( + en="Freeze backbone", + es="Congelar backbone", + ), + ) # type: ignore + + +class _ImageDataset(torch.utils.data.Dataset): + """Torch Dataset with ImageNet normalization for torchvision models.""" + + def __init__(self, x_dataset, y_dataset=None, image_size=224): + self.x_dataset = x_dataset + self.y_dataset = y_dataset + self.transforms = transforms.Compose( + [ + transforms.Lambda(lambda img: img.convert("RGB")), + transforms.Resize((image_size, image_size)), + transforms.ToTensor(), + transforms.Normalize( + mean=[0.485, 0.456, 0.406], + std=[0.229, 0.224, 0.225], + ), + ] + ) + + self.image_col_name = list(x_dataset.features.keys())[0] + self.label_col_name = ( + list(y_dataset.features.keys())[0] if y_dataset is not None else None + ) + + self.label_to_idx = {} + self.idx_to_label = {} + if self.label_col_name: + unique_labels = sorted(set(self.y_dataset[self.label_col_name])) + self.label_to_idx = {label: idx for idx, label in enumerate(unique_labels)} + self.idx_to_label = {idx: label for label, idx in self.label_to_idx.items()} + + def num_classes(self): + if self.label_col_name is None: + return 0 + return len(self.label_to_idx) + + def __len__(self): + return len(self.x_dataset) + + def __getitem__(self, idx): + image = self.transforms(self.x_dataset[idx][self.image_col_name].to_pil()) + if self.label_col_name is None: + return image + label_str = self.y_dataset[idx][self.label_col_name] + return image, self.label_to_idx[label_str] + + +class TorchvisionImageClassifier(BaseModel, abc.ABC): + """Abstract base for torchvision image classifiers. + + Subclasses must implement: + - ``_build_backbone(num_classes, pretrained)`` — return the adapted model. + - ``_classifier_head()`` — return the head module unfrozen when + ``freeze_backbone=True``. + """ + + SCHEMA = TorchvisionImageClassifierSchema + COMPATIBLE_COMPONENTS = ["ImageClassificationTask"] + + @abc.abstractmethod + def _build_backbone(self, num_classes: int, pretrained: bool) -> nn.Module: + """Build and return the adapted torchvision model.""" + + @abc.abstractmethod + def _classifier_head(self) -> nn.Module: + """Return the classifier head module (kept trainable when freezing).""" + + @staticmethod + def _collate_fn_with_labels(batch): + images = torch.stack([item[0] for item in batch]) + labels = torch.tensor([item[1] for item in batch], dtype=torch.long) + return images, labels + + @staticmethod + def _collate_fn_no_labels(batch): + return torch.stack(batch) + + def __init__( + self, + epochs=10, + learning_rate=0.001, + batch_size=32, + image_size=224, + dropout_rate=0.0, + weight_decay=0.0, + pretrained=True, + freeze_backbone=False, + **kwargs, + ): + self.epochs = epochs + self.learning_rate = learning_rate + self.batch_size = batch_size + self.image_size = image_size + self.dropout_rate = dropout_rate + self.weight_decay = weight_decay + self.pretrained = pretrained + self.freeze_backbone = freeze_backbone + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self.model = None + self.optimizer = None + self.num_classes = None + self.idx_to_label = {} + self.label_to_idx = {} + + def _freeze_backbone_params(self): + for p in self.model.parameters(): + p.requires_grad = False + for p in self._classifier_head().parameters(): + p.requires_grad = True + + def prepare_output(self, dataset, is_fit=False): + """Encode string labels to integer indices matching the model's class order.""" + import pyarrow as pa + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + if not self.label_to_idx: + return dataset + + col_name = dataset.column_names[0] + encoded = [self.label_to_idx.get(lbl, lbl) for lbl in dataset[col_name]] + return DashAIDataset(pa.table({col_name: encoded})) + + def train(self, x_train, y_train, x_validation=None, y_validation=None): + """Fine-tune the backbone on the provided image dataset. + + Parameters + ---------- + x_train : DashAIDataset + Input dataset containing images. + y_train : DashAIDataset + Target dataset containing string labels. + x_validation : DashAIDataset, optional + Unused. Defaults to None. + y_validation : DashAIDataset, optional + Unused. Defaults to None. + + Returns + ------- + BaseTorchvisionImageClassifier + The trained model instance. + """ + image_dataset = _ImageDataset( + x_train, y_dataset=y_train, image_size=self.image_size + ) + self.num_classes = image_dataset.num_classes() + self.idx_to_label = image_dataset.idx_to_label + self.label_to_idx = image_dataset.label_to_idx + + train_loader = torch.utils.data.DataLoader( + image_dataset, + batch_size=self.batch_size, + shuffle=True, + collate_fn=self._collate_fn_with_labels, + ) + + self.model = self._build_backbone(self.num_classes, self.pretrained).to( + self.device + ) + + if self.freeze_backbone: + self._freeze_backbone_params() + + criterion = nn.CrossEntropyLoss() + self.optimizer = optim.Adam( + filter(lambda p: p.requires_grad, self.model.parameters()), + lr=self.learning_rate, + weight_decay=self.weight_decay, + ) + + self.model.train() + for _ in range(self.epochs): + for images, labels in train_loader: + images, labels = images.to(self.device), labels.to(self.device) + self.optimizer.zero_grad() + loss = criterion(self.model(images), labels) + loss.backward() + self.optimizer.step() + + return self + + def predict(self, x): + """Return per-class probability matrix for each image. + + Parameters + ---------- + x : DashAIDataset + Input dataset containing images. + + Returns + ------- + np.ndarray + Array of shape (n_samples, n_classes) with softmax probabilities. + """ + image_dataset = _ImageDataset(x, y_dataset=None, image_size=self.image_size) + loader = torch.utils.data.DataLoader( + image_dataset, + batch_size=self.batch_size, + shuffle=False, + collate_fn=self._collate_fn_no_labels, + ) + + self.model.eval() + all_probs = [] + with torch.no_grad(): + for images in loader: + logits = self.model(images.to(self.device)) + all_probs.append(torch.softmax(logits, dim=1).cpu().numpy()) + + return np.concatenate(all_probs, axis=0) + + def save(self, filename: str) -> None: + """Save the model checkpoint to disk. + + Parameters + ---------- + filename : str + Path where the checkpoint will be saved. + """ + torch.save( + { + "model_state_dict": self.model.state_dict(), + "optimizer_state_dict": self.optimizer.state_dict(), + "epochs": self.epochs, + "learning_rate": self.learning_rate, + "batch_size": self.batch_size, + "image_size": self.image_size, + "dropout_rate": self.dropout_rate, + "weight_decay": self.weight_decay, + "pretrained": self.pretrained, + "freeze_backbone": self.freeze_backbone, + "num_classes": self.num_classes, + "idx_to_label": self.idx_to_label, + "label_to_idx": self.label_to_idx, + }, + filename, + ) + + @classmethod + def load(cls, filename: str): + """Load a model checkpoint from disk. + + Parameters + ---------- + filename : str + Path to the checkpoint file. + + Returns + ------- + BaseTorchvisionImageClassifier + Instance with loaded weights. + """ + ckpt = torch.load(filename, map_location=torch.device("cpu")) + instance = cls( + epochs=ckpt["epochs"], + learning_rate=ckpt["learning_rate"], + batch_size=ckpt.get("batch_size", 32), + image_size=ckpt.get("image_size", 224), + dropout_rate=ckpt.get("dropout_rate", 0.0), + weight_decay=ckpt.get("weight_decay", 0.0), + pretrained=False, + freeze_backbone=ckpt.get("freeze_backbone", False), + ) + instance.num_classes = ckpt["num_classes"] + instance.idx_to_label = ckpt.get("idx_to_label", {}) + instance.label_to_idx = ckpt.get("label_to_idx", {}) + instance.model = instance._build_backbone( + instance.num_classes, pretrained=False + ) + instance.model.load_state_dict(ckpt["model_state_dict"]) + instance.optimizer = optim.Adam( + filter(lambda p: p.requires_grad, instance.model.parameters()), + weight_decay=instance.weight_decay, + ) + instance.optimizer.load_state_dict(ckpt["optimizer_state_dict"]) + return instance diff --git a/DashAI/back/models/cnn_image_classifier.py b/DashAI/back/models/cnn_image_classifier.py new file mode 100644 index 000000000..621872f4a --- /dev/null +++ b/DashAI/back/models/cnn_image_classifier.py @@ -0,0 +1,500 @@ +"""CNN-based image classifier for DashAI.""" + +import numpy as np +import torch +import torch.nn as nn +import torch.optim as optim +import torch.utils.data +from torchvision import transforms + +from DashAI.back.core.schema_fields import ( + BaseSchema, + float_field, + int_field, + schema_field, +) +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.base_model import BaseModel + + +class CNNImageClassifierSchema(BaseSchema): + """Configuration parameters for the CNN Image Classifier.""" + + epochs: schema_field( + int_field(ge=1), + placeholder=10, + description=MultilingualString( + en=( + "The number of epochs to train the model. An epoch is a full " + "iteration over the training data." + ), + es=( + "El número de épocas para entrenar el modelo. Una época es una " + "iteración completa sobre los datos de entrenamiento." + ), + ), + alias=MultilingualString(en="Epochs", es="Épocas"), + ) # type: ignore + + learning_rate: schema_field( + float_field(gt=0.0), + placeholder=0.001, + description=MultilingualString( + en="Learning rate for the Adam optimizer.", + es="Tasa de aprendizaje para el optimizador Adam.", + ), + alias=MultilingualString(en="Learning rate", es="Tasa de aprendizaje"), + ) # type: ignore + + batch_size: schema_field( + int_field(ge=1), + placeholder=32, + description=MultilingualString( + en=( + "Number of images processed together in each training step. " + "Larger values speed up training but require more memory." + ), + es=( + "Número de imágenes procesadas juntas en cada paso de " + "entrenamiento. Valores más grandes aceleran el entrenamiento " + "pero requieren más memoria." + ), + ), + alias=MultilingualString(en="Batch size", es="Tamaño de lote"), + ) # type: ignore + + image_size: schema_field( + int_field(ge=8), + placeholder=64, + description=MultilingualString( + en=( + "Images are resized to this value (in pixels) for both width " + "and height before training. Must be at least 2^num_conv_blocks." + ), + es=( + "Las imágenes se redimensionan a este valor (en píxeles) tanto " + "en ancho como en alto. Debe ser al menos 2^num_conv_blocks." + ), + ), + alias=MultilingualString(en="Image size", es="Tamaño de imagen"), + ) # type: ignore + + num_conv_blocks: schema_field( + int_field(ge=1, le=5), + placeholder=3, + description=MultilingualString( + en=( + "Number of convolutional blocks. Each block applies a " + "convolution, ReLU activation, and max-pooling that halves " + "the spatial dimensions." + ), + es=( + "Número de bloques convolucionales. Cada bloque aplica una " + "convolución, activación ReLU y max-pooling que reduce a la " + "mitad las dimensiones espaciales." + ), + ), + alias=MultilingualString( + en="Number of conv blocks", + es="Número de bloques conv", + ), + ) # type: ignore + + initial_filters: schema_field( + int_field(ge=8), + placeholder=32, + description=MultilingualString( + en=( + "Number of filters in the first convolutional block. " + "Each subsequent block doubles this number." + ), + es=( + "Número de filtros en el primer bloque convolucional. " + "Cada bloque siguiente duplica este número." + ), + ), + alias=MultilingualString(en="Initial filters", es="Filtros iniciales"), + ) # type: ignore + + dropout_rate: schema_field( + float_field(ge=0.0, lt=1.0), + placeholder=0.0, + description=MultilingualString( + en=( + "Fraction of neurons randomly deactivated before the output " + "layer. Values between 0.2 and 0.5 help prevent overfitting. " + "Use 0.0 to disable." + ), + es=( + "Fracción de neuronas desactivadas aleatoriamente antes de la " + "capa de salida. Valores entre 0.2 y 0.5 ayudan a prevenir el " + "sobreajuste. Use 0.0 para desactivarlo." + ), + ), + alias=MultilingualString(en="Dropout rate", es="Tasa de dropout"), + ) # type: ignore + + weight_decay: schema_field( + float_field(ge=0.0), + placeholder=0.0, + description=MultilingualString( + en=( + "L2 regularization coefficient for the Adam optimizer. " + "Typical values: 1e-4 to 1e-2." + ), + es=( + "Coeficiente de regularización L2 para el optimizador Adam. " + "Valores típicos: 1e-4 a 1e-2." + ), + ), + alias=MultilingualString(en="Weight decay", es="Decaimiento de pesos"), + ) # type: ignore + + +class _ImageDataset(torch.utils.data.Dataset): + """Torch Dataset wrapper for DashAI image datasets.""" + + def __init__(self, x_dataset, y_dataset=None, image_size=64): + self.x_dataset = x_dataset + self.y_dataset = y_dataset + self.transforms = transforms.Compose( + [ + transforms.Resize((image_size, image_size)), + transforms.ToTensor(), + ] + ) + + self.image_col_name = list(x_dataset.features.keys())[0] + self.label_col_name = ( + list(y_dataset.features.keys())[0] if y_dataset is not None else None + ) + + self.label_to_idx = {} + self.idx_to_label = {} + if self.label_col_name: + unique_labels = sorted(set(self.y_dataset[self.label_col_name])) + self.label_to_idx = {label: idx for idx, label in enumerate(unique_labels)} + self.idx_to_label = {idx: label for label, idx in self.label_to_idx.items()} + + self.tensor_shape = self.transforms( + self.x_dataset[0][self.image_col_name].to_pil() + ).shape + + def num_classes(self): + if self.label_col_name is None: + return 0 + return len(self.label_to_idx) + + def __len__(self): + return len(self.x_dataset) + + def __getitem__(self, idx): + image = self.transforms(self.x_dataset[idx][self.image_col_name].to_pil()) + if self.label_col_name is None: + return image + label_str = self.y_dataset[idx][self.label_col_name] + return image, self.label_to_idx[label_str] + + +class _CNNBlock(nn.Module): + """Single convolutional block: Conv2d → ReLU → MaxPool2d.""" + + def __init__(self, in_channels, out_channels): + super().__init__() + self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1) + self.relu = nn.ReLU() + self.pool = nn.MaxPool2d(kernel_size=2, stride=2) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.pool(self.relu(self.conv(x))) + + +class _CNN(nn.Module): + """CNN with configurable convolutional blocks followed by a linear classifier.""" + + def __init__( + self, + input_channels, + input_size, + num_classes, + num_conv_blocks, + initial_filters, + dropout_rate, + ): + super().__init__() + self.conv_blocks = nn.ModuleList() + in_ch = input_channels + out_ch = initial_filters + + for _ in range(num_conv_blocks): + self.conv_blocks.append(_CNNBlock(in_ch, out_ch)) + in_ch = out_ch + out_ch *= 2 + + final_spatial = input_size // (2**num_conv_blocks) + flat_dim = in_ch * final_spatial * final_spatial + + self.dropout = nn.Dropout(dropout_rate) + self.fc = nn.Linear(flat_dim, num_classes) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + for block in self.conv_blocks: + x = block(x) + x = x.view(x.size(0), -1) + return self.fc(self.dropout(x)) + + +class CNNImageClassifier(BaseModel): + """CNN-based image classifier. + + A convolutional neural network with configurable depth and width that + learns spatial features hierarchically via conv→ReLU→pool blocks, + followed by a dropout-regularized linear output layer. + """ + + SCHEMA = CNNImageClassifierSchema + COMPATIBLE_COMPONENTS = ["ImageClassificationTask"] + DISPLAY_NAME: str = MultilingualString( + en="CNN Image Classifier", + es="Clasificador de Imágenes CNN", + ) + DESCRIPTION: str = MultilingualString( + en=( + "A Convolutional Neural Network (CNN) image classifier that learns " + "spatial features through configurable conv→ReLU→pool blocks, " + "with filters doubling at each stage." + ), + es=( + "Un clasificador de imágenes basado en Red Neuronal Convolucional " + "(CNN) que aprende características espaciales mediante bloques " + "conv→ReLU→pool configurables, duplicando los filtros en cada etapa." + ), + ) + COLOR: str = "#1565C0" + ICON: str = "Layers" + + @staticmethod + def _collate_fn_with_labels(batch): + images = torch.stack([item[0] for item in batch]) + labels = torch.tensor([item[1] for item in batch], dtype=torch.long) + return images, labels + + @staticmethod + def _collate_fn_no_labels(batch): + return torch.stack(batch) + + def __init__( + self, + epochs=10, + learning_rate=0.001, + batch_size=32, + image_size=64, + num_conv_blocks=3, + initial_filters=32, + dropout_rate=0.0, + weight_decay=0.0, + **kwargs, + ): + self.epochs = epochs + self.learning_rate = learning_rate + self.batch_size = batch_size + self.image_size = image_size + self.num_conv_blocks = num_conv_blocks + self.initial_filters = initial_filters + self.dropout_rate = dropout_rate + self.weight_decay = weight_decay + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self.model = None + self.optimizer = None + self.input_channels = None + self.num_classes = None + self.idx_to_label = {} + self.label_to_idx = {} + + def _validate_architecture(self): + min_size = 2**self.num_conv_blocks + if self.image_size < min_size: + raise ValueError( + f"image_size ({self.image_size}) must be at least " + f"2^num_conv_blocks = {min_size} " + f"for {self.num_conv_blocks} convolutional block(s)." + ) + + def prepare_output(self, dataset, is_fit=False): + """Encode string labels to integer indices matching the model's class order.""" + import pyarrow as pa + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + if not self.label_to_idx: + return dataset + + col_name = dataset.column_names[0] + encoded = [self.label_to_idx.get(lbl, lbl) for lbl in dataset[col_name]] + return DashAIDataset(pa.table({col_name: encoded})) + + def train(self, x_train, y_train, x_validation=None, y_validation=None): + """Train the CNN on the provided image dataset. + + Parameters + ---------- + x_train : DashAIDataset + Input dataset containing images. + y_train : DashAIDataset + Target dataset containing labels. + x_validation : DashAIDataset, optional + Unused. Defaults to None. + y_validation : DashAIDataset, optional + Unused. Defaults to None. + + Returns + ------- + CNNImageClassifier + The trained model instance. + """ + self._validate_architecture() + + image_dataset = _ImageDataset( + x_train, y_dataset=y_train, image_size=self.image_size + ) + self.input_channels = image_dataset.tensor_shape[0] + self.num_classes = image_dataset.num_classes() + self.idx_to_label = image_dataset.idx_to_label + self.label_to_idx = image_dataset.label_to_idx + + train_loader = torch.utils.data.DataLoader( + image_dataset, + batch_size=self.batch_size, + shuffle=True, + collate_fn=self._collate_fn_with_labels, + ) + + self.model = _CNN( + self.input_channels, + self.image_size, + self.num_classes, + self.num_conv_blocks, + self.initial_filters, + self.dropout_rate, + ).to(self.device) + + criterion = nn.CrossEntropyLoss() + self.optimizer = optim.Adam( + self.model.parameters(), + lr=self.learning_rate, + weight_decay=self.weight_decay, + ) + + self.model.train() + for _ in range(self.epochs): + for images, labels in train_loader: + images, labels = images.to(self.device), labels.to(self.device) + self.optimizer.zero_grad() + loss = criterion(self.model(images), labels) + loss.backward() + self.optimizer.step() + + return self + + def predict(self, x): + """Return per-class probability matrix for each image. + + Parameters + ---------- + x : DashAIDataset + Input dataset containing images. + + Returns + ------- + np.ndarray + Array of shape (n_samples, n_classes) with softmax probabilities. + """ + image_dataset = _ImageDataset(x, y_dataset=None, image_size=self.image_size) + loader = torch.utils.data.DataLoader( + image_dataset, + batch_size=self.batch_size, + shuffle=False, + collate_fn=self._collate_fn_no_labels, + ) + + self.model.eval() + all_probs = [] + with torch.no_grad(): + for images in loader: + logits = self.model(images.to(self.device)) + all_probs.append(torch.softmax(logits, dim=1).cpu().numpy()) + + return np.concatenate(all_probs, axis=0) + + def save(self, filename: str) -> None: + """Save the model checkpoint to disk. + + Parameters + ---------- + filename : str + Path where the checkpoint will be saved. + """ + torch.save( + { + "model_state_dict": self.model.state_dict(), + "optimizer_state_dict": self.optimizer.state_dict(), + "epochs": self.epochs, + "learning_rate": self.learning_rate, + "batch_size": self.batch_size, + "image_size": self.image_size, + "num_conv_blocks": self.num_conv_blocks, + "initial_filters": self.initial_filters, + "dropout_rate": self.dropout_rate, + "weight_decay": self.weight_decay, + "input_channels": self.input_channels, + "num_classes": self.num_classes, + "idx_to_label": self.idx_to_label, + "label_to_idx": self.label_to_idx, + }, + filename, + ) + + @classmethod + def load(cls, filename: str): + """Load a model checkpoint from disk. + + Parameters + ---------- + filename : str + Path to the checkpoint file. + + Returns + ------- + CNNImageClassifier + Instance with loaded weights. + """ + ckpt = torch.load(filename, map_location=torch.device("cpu")) + instance = cls( + epochs=ckpt["epochs"], + learning_rate=ckpt["learning_rate"], + batch_size=ckpt.get("batch_size", 32), + image_size=ckpt.get("image_size", 64), + num_conv_blocks=ckpt.get("num_conv_blocks", 3), + initial_filters=ckpt.get("initial_filters", 32), + dropout_rate=ckpt.get("dropout_rate", 0.0), + weight_decay=ckpt.get("weight_decay", 0.0), + ) + instance.input_channels = ckpt["input_channels"] + instance.num_classes = ckpt["num_classes"] + instance.idx_to_label = ckpt.get("idx_to_label", {}) + instance.label_to_idx = ckpt.get("label_to_idx", {}) + instance.model = _CNN( + instance.input_channels, + instance.image_size, + instance.num_classes, + instance.num_conv_blocks, + instance.initial_filters, + instance.dropout_rate, + ) + instance.model.load_state_dict(ckpt["model_state_dict"]) + instance.optimizer = optim.Adam( + instance.model.parameters(), + weight_decay=instance.weight_decay, + ) + instance.optimizer.load_state_dict(ckpt["optimizer_state_dict"]) + return instance diff --git a/DashAI/back/models/efficientnet_b0_image_classifier.py b/DashAI/back/models/efficientnet_b0_image_classifier.py new file mode 100644 index 000000000..43b3ec14b --- /dev/null +++ b/DashAI/back/models/efficientnet_b0_image_classifier.py @@ -0,0 +1,53 @@ +"""EfficientNet-B0 image classifier for DashAI.""" + +import torch.nn as nn +from torchvision.models import EfficientNet_B0_Weights, efficientnet_b0 + +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.base_torchvision_image_classifier import ( + TorchvisionImageClassifier, + TorchvisionImageClassifierSchema, +) + + +class EfficientNetB0ImageClassifier(TorchvisionImageClassifier): + """EfficientNet-B0 image classifier (Tan & Le, 2019). + + Compact baseline of the EfficientNet family, which scales network width, + depth, and resolution jointly. The classifier head is replaced to match + the number of target classes. Supports ImageNet pre-trained weights. + """ + + SCHEMA = TorchvisionImageClassifierSchema + COMPATIBLE_COMPONENTS = ["ImageClassificationTask"] + DISPLAY_NAME: str = MultilingualString( + en="EfficientNet-B0", + es="EfficientNet-B0", + ) + DESCRIPTION: str = MultilingualString( + en=( + "EfficientNet-B0 (Tan & Le, 2019). Scales network width, depth, " + "and resolution jointly for the best accuracy/efficiency trade-off. " + "Smaller and faster than ResNet-18 at similar accuracy." + ), + es=( + "EfficientNet-B0 (Tan & Le, 2019). Escala ancho, profundidad y " + "resolución de la red de forma conjunta para el mejor balance entre " + "accuracy y eficiencia. Más pequeño y rápido que ResNet-18." + ), + ) + COLOR: str = "#00838F" + ICON: str = "Speed" + + def _build_backbone(self, num_classes: int, pretrained: bool) -> nn.Module: + weights = EfficientNet_B0_Weights.DEFAULT if pretrained else None + model = efficientnet_b0(weights=weights) + in_features = model.classifier[1].in_features + model.classifier = nn.Sequential( + nn.Dropout(self.dropout_rate), + nn.Linear(in_features, num_classes), + ) + return model + + def _classifier_head(self) -> nn.Module: + return self.model.classifier diff --git a/DashAI/back/models/lenet5_image_classifier.py b/DashAI/back/models/lenet5_image_classifier.py new file mode 100644 index 000000000..e5c3b85bb --- /dev/null +++ b/DashAI/back/models/lenet5_image_classifier.py @@ -0,0 +1,425 @@ +"""LeNet-5 image classifier for DashAI.""" + +import numpy as np +import torch +import torch.nn as nn +import torch.optim as optim +import torch.utils.data +from torchvision import transforms + +from DashAI.back.core.schema_fields import ( + BaseSchema, + float_field, + int_field, + schema_field, +) +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.base_model import BaseModel + + +class LeNet5ImageClassifierSchema(BaseSchema): + """Configuration parameters for the LeNet-5 Image Classifier.""" + + epochs: schema_field( + int_field(ge=1), + placeholder=10, + description=MultilingualString( + en=( + "The number of epochs to train the model. An epoch is a full " + "iteration over the training data." + ), + es=( + "El número de épocas para entrenar el modelo. Una época es una " + "iteración completa sobre los datos de entrenamiento." + ), + ), + alias=MultilingualString(en="Epochs", es="Épocas"), + ) # type: ignore + + learning_rate: schema_field( + float_field(gt=0.0), + placeholder=0.001, + description=MultilingualString( + en="Learning rate for the Adam optimizer.", + es="Tasa de aprendizaje para el optimizador Adam.", + ), + alias=MultilingualString(en="Learning rate", es="Tasa de aprendizaje"), + ) # type: ignore + + batch_size: schema_field( + int_field(ge=1), + placeholder=32, + description=MultilingualString( + en=( + "Number of images processed together in each training step. " + "Larger values speed up training but require more memory." + ), + es=( + "Número de imágenes procesadas juntas en cada paso de " + "entrenamiento. Valores más grandes aceleran el entrenamiento " + "pero requieren más memoria." + ), + ), + alias=MultilingualString(en="Batch size", es="Tamaño de lote"), + ) # type: ignore + + image_size: schema_field( + int_field(ge=16), + placeholder=32, + description=MultilingualString( + en=( + "Images are resized to this value (in pixels) for both width " + "and height. The original LeNet-5 uses 32×32." + ), + es=( + "Las imágenes se redimensionan a este valor (en píxeles) tanto " + "en ancho como en alto. El LeNet-5 original usa 32×32." + ), + ), + alias=MultilingualString(en="Image size", es="Tamaño de imagen"), + ) # type: ignore + + dropout_rate: schema_field( + float_field(ge=0.0, lt=1.0), + placeholder=0.0, + description=MultilingualString( + en=( + "Dropout rate applied between fully-connected layers. " + "Values between 0.2 and 0.5 help prevent overfitting. " + "Use 0.0 to reproduce the original LeNet-5." + ), + es=( + "Tasa de dropout entre las capas completamente conectadas. " + "Valores entre 0.2 y 0.5 ayudan a prevenir el sobreajuste. " + "Use 0.0 para reproducir el LeNet-5 original." + ), + ), + alias=MultilingualString(en="Dropout rate", es="Tasa de dropout"), + ) # type: ignore + + weight_decay: schema_field( + float_field(ge=0.0), + placeholder=0.0, + description=MultilingualString( + en=( + "L2 regularization coefficient for the Adam optimizer. " + "Typical values: 1e-4 to 1e-2." + ), + es=( + "Coeficiente de regularización L2 para el optimizador Adam. " + "Valores típicos: 1e-4 a 1e-2." + ), + ), + alias=MultilingualString(en="Weight decay", es="Decaimiento de pesos"), + ) # type: ignore + + +class _ImageDataset(torch.utils.data.Dataset): + """Torch Dataset wrapper for DashAI image datasets.""" + + def __init__(self, x_dataset, y_dataset=None, image_size=32): + self.x_dataset = x_dataset + self.y_dataset = y_dataset + self.transforms = transforms.Compose( + [ + transforms.Resize((image_size, image_size)), + transforms.ToTensor(), + ] + ) + + self.image_col_name = list(x_dataset.features.keys())[0] + self.label_col_name = ( + list(y_dataset.features.keys())[0] if y_dataset is not None else None + ) + + self.label_to_idx = {} + self.idx_to_label = {} + if self.label_col_name: + unique_labels = sorted(set(self.y_dataset[self.label_col_name])) + self.label_to_idx = {label: idx for idx, label in enumerate(unique_labels)} + self.idx_to_label = {idx: label for label, idx in self.label_to_idx.items()} + + self.tensor_shape = self.transforms( + self.x_dataset[0][self.image_col_name].to_pil() + ).shape + + def num_classes(self): + if self.label_col_name is None: + return 0 + return len(self.label_to_idx) + + def __len__(self): + return len(self.x_dataset) + + def __getitem__(self, idx): + image = self.transforms(self.x_dataset[idx][self.image_col_name].to_pil()) + if self.label_col_name is None: + return image + label_str = self.y_dataset[idx][self.label_col_name] + return image, self.label_to_idx[label_str] + + +class _LeNet5(nn.Module): + """LeNet-5 architecture (LeCun et al., 1998) with configurable dropout.""" + + def __init__(self, input_channels, input_size, num_classes, dropout_rate): + super().__init__() + self.conv_layers = nn.Sequential( + nn.Conv2d(input_channels, 6, kernel_size=5), + nn.Tanh(), + nn.AvgPool2d(kernel_size=2, stride=2), + nn.Conv2d(6, 16, kernel_size=5), + nn.Tanh(), + nn.AvgPool2d(kernel_size=2, stride=2), + ) + + # Compute flattened dimension dynamically for arbitrary input_size + dummy = torch.zeros(1, input_channels, input_size, input_size) + flat_dim = self.conv_layers(dummy).view(1, -1).shape[1] + + self.classifier = nn.Sequential( + nn.Linear(flat_dim, 120), + nn.Tanh(), + nn.Dropout(dropout_rate), + nn.Linear(120, 84), + nn.Tanh(), + nn.Dropout(dropout_rate), + nn.Linear(84, num_classes), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = self.conv_layers(x) + return self.classifier(x.view(x.size(0), -1)) + + +class LeNet5ImageClassifier(BaseModel): + """LeNet-5 image classifier (LeCun et al., 1998). + + The original convolutional neural network architecture, featuring two + conv→tanh→pool blocks followed by three fully-connected layers. + Uses Tanh activations and average pooling as in the original paper. + """ + + SCHEMA = LeNet5ImageClassifierSchema + COMPATIBLE_COMPONENTS = ["ImageClassificationTask"] + DISPLAY_NAME: str = MultilingualString( + en="LeNet-5", + es="LeNet-5", + ) + DESCRIPTION: str = MultilingualString( + en=( + "The original CNN architecture (LeCun et al., 1998). Two " + "conv→tanh→pool blocks followed by three fully-connected layers. " + "Ideal for small images and educational use." + ), + es=( + "La arquitectura CNN original (LeCun et al., 1998). Dos bloques " + "conv→tanh→pool seguidos de tres capas completamente conectadas. " + "Ideal para imágenes pequeñas y uso educativo." + ), + ) + COLOR: str = "#7B1FA2" + ICON: str = "History" + + @staticmethod + def _collate_fn_with_labels(batch): + images = torch.stack([item[0] for item in batch]) + labels = torch.tensor([item[1] for item in batch], dtype=torch.long) + return images, labels + + @staticmethod + def _collate_fn_no_labels(batch): + return torch.stack(batch) + + def __init__( + self, + epochs=10, + learning_rate=0.001, + batch_size=32, + image_size=32, + dropout_rate=0.0, + weight_decay=0.0, + **kwargs, + ): + self.epochs = epochs + self.learning_rate = learning_rate + self.batch_size = batch_size + self.image_size = image_size + self.dropout_rate = dropout_rate + self.weight_decay = weight_decay + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self.model = None + self.optimizer = None + self.input_channels = None + self.num_classes = None + self.idx_to_label = {} + self.label_to_idx = {} + + def prepare_output(self, dataset, is_fit=False): + """Encode string labels to integer indices matching the model's class order.""" + import pyarrow as pa + + from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset + + if not self.label_to_idx: + return dataset + + col_name = dataset.column_names[0] + encoded = [self.label_to_idx.get(lbl, lbl) for lbl in dataset[col_name]] + return DashAIDataset(pa.table({col_name: encoded})) + + def train(self, x_train, y_train, x_validation=None, y_validation=None): + """Train LeNet-5 on the provided image dataset. + + Parameters + ---------- + x_train : DashAIDataset + Input dataset containing images. + y_train : DashAIDataset + Target dataset containing string labels. + x_validation : DashAIDataset, optional + Unused. Defaults to None. + y_validation : DashAIDataset, optional + Unused. Defaults to None. + + Returns + ------- + LeNet5ImageClassifier + The trained model instance. + """ + image_dataset = _ImageDataset( + x_train, y_dataset=y_train, image_size=self.image_size + ) + self.input_channels = image_dataset.tensor_shape[0] + self.num_classes = image_dataset.num_classes() + self.idx_to_label = image_dataset.idx_to_label + self.label_to_idx = image_dataset.label_to_idx + + train_loader = torch.utils.data.DataLoader( + image_dataset, + batch_size=self.batch_size, + shuffle=True, + collate_fn=self._collate_fn_with_labels, + ) + + self.model = _LeNet5( + self.input_channels, + self.image_size, + self.num_classes, + self.dropout_rate, + ).to(self.device) + + criterion = nn.CrossEntropyLoss() + self.optimizer = optim.Adam( + self.model.parameters(), + lr=self.learning_rate, + weight_decay=self.weight_decay, + ) + + self.model.train() + for _ in range(self.epochs): + for images, labels in train_loader: + images, labels = images.to(self.device), labels.to(self.device) + self.optimizer.zero_grad() + loss = criterion(self.model(images), labels) + loss.backward() + self.optimizer.step() + + return self + + def predict(self, x): + """Return per-class probability matrix for each image. + + Parameters + ---------- + x : DashAIDataset + Input dataset containing images. + + Returns + ------- + np.ndarray + Array of shape (n_samples, n_classes) with softmax probabilities. + """ + image_dataset = _ImageDataset(x, y_dataset=None, image_size=self.image_size) + loader = torch.utils.data.DataLoader( + image_dataset, + batch_size=self.batch_size, + shuffle=False, + collate_fn=self._collate_fn_no_labels, + ) + + self.model.eval() + all_probs = [] + with torch.no_grad(): + for images in loader: + logits = self.model(images.to(self.device)) + all_probs.append(torch.softmax(logits, dim=1).cpu().numpy()) + + return np.concatenate(all_probs, axis=0) + + def save(self, filename: str) -> None: + """Save the model checkpoint to disk. + + Parameters + ---------- + filename : str + Path where the checkpoint will be saved. + """ + torch.save( + { + "model_state_dict": self.model.state_dict(), + "optimizer_state_dict": self.optimizer.state_dict(), + "epochs": self.epochs, + "learning_rate": self.learning_rate, + "batch_size": self.batch_size, + "image_size": self.image_size, + "dropout_rate": self.dropout_rate, + "weight_decay": self.weight_decay, + "input_channels": self.input_channels, + "num_classes": self.num_classes, + "idx_to_label": self.idx_to_label, + "label_to_idx": self.label_to_idx, + }, + filename, + ) + + @classmethod + def load(cls, filename: str): + """Load a model checkpoint from disk. + + Parameters + ---------- + filename : str + Path to the checkpoint file. + + Returns + ------- + LeNet5ImageClassifier + Instance with loaded weights. + """ + ckpt = torch.load(filename, map_location=torch.device("cpu")) + instance = cls( + epochs=ckpt["epochs"], + learning_rate=ckpt["learning_rate"], + batch_size=ckpt.get("batch_size", 32), + image_size=ckpt.get("image_size", 32), + dropout_rate=ckpt.get("dropout_rate", 0.0), + weight_decay=ckpt.get("weight_decay", 0.0), + ) + instance.input_channels = ckpt["input_channels"] + instance.num_classes = ckpt["num_classes"] + instance.idx_to_label = ckpt.get("idx_to_label", {}) + instance.label_to_idx = ckpt.get("label_to_idx", {}) + instance.model = _LeNet5( + instance.input_channels, + instance.image_size, + instance.num_classes, + instance.dropout_rate, + ) + instance.model.load_state_dict(ckpt["model_state_dict"]) + instance.optimizer = optim.Adam( + instance.model.parameters(), + weight_decay=instance.weight_decay, + ) + instance.optimizer.load_state_dict(ckpt["optimizer_state_dict"]) + return instance diff --git a/DashAI/back/models/resnet18_image_classifier.py b/DashAI/back/models/resnet18_image_classifier.py new file mode 100644 index 000000000..60ade3839 --- /dev/null +++ b/DashAI/back/models/resnet18_image_classifier.py @@ -0,0 +1,53 @@ +"""ResNet-18 image classifier for DashAI.""" + +import torch.nn as nn +from torchvision.models import ResNet18_Weights, resnet18 + +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.base_torchvision_image_classifier import ( + TorchvisionImageClassifier, + TorchvisionImageClassifierSchema, +) + + +class ResNet18ImageClassifier(TorchvisionImageClassifier): + """ResNet-18 image classifier (He et al., 2015). + + 18-layer residual network with skip connections that solve the vanishing + gradient problem. The final fully-connected layer is replaced to match the + number of target classes. Supports ImageNet pre-trained weights. + """ + + SCHEMA = TorchvisionImageClassifierSchema + COMPATIBLE_COMPONENTS = ["ImageClassificationTask"] + DISPLAY_NAME: str = MultilingualString( + en="ResNet-18", + es="ResNet-18", + ) + DESCRIPTION: str = MultilingualString( + en=( + "ResNet-18 (He et al., 2015). An 18-layer residual network with " + "skip connections that enable training very deep networks. " + "The most-cited CNN in academic literature." + ), + es=( + "ResNet-18 (He et al., 2015). Red residual de 18 capas con " + "conexiones de salto que permiten entrenar redes muy profundas. " + "La CNN más citada en la literatura académica." + ), + ) + COLOR: str = "#2E7D32" + ICON: str = "AccountTree" + + def _build_backbone(self, num_classes: int, pretrained: bool) -> nn.Module: + weights = ResNet18_Weights.DEFAULT if pretrained else None + model = resnet18(weights=weights) + in_features = model.fc.in_features + model.fc = nn.Sequential( + nn.Dropout(self.dropout_rate), + nn.Linear(in_features, num_classes), + ) + return model + + def _classifier_head(self) -> nn.Module: + return self.model.fc diff --git a/DashAI/back/models/resnet50_image_classifier.py b/DashAI/back/models/resnet50_image_classifier.py new file mode 100644 index 000000000..bba64ba1f --- /dev/null +++ b/DashAI/back/models/resnet50_image_classifier.py @@ -0,0 +1,54 @@ +"""ResNet-50 image classifier for DashAI.""" + +import torch.nn as nn +from torchvision.models import ResNet50_Weights, resnet50 + +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.base_torchvision_image_classifier import ( + TorchvisionImageClassifier, + TorchvisionImageClassifierSchema, +) + + +class ResNet50ImageClassifier(TorchvisionImageClassifier): + """ResNet-50 image classifier (He et al., 2015). + + 50-layer residual network using bottleneck blocks. Deeper and more + accurate than ResNet-18, and the most-cited CNN variant in the academic + literature. The final FC layer is replaced to match the target classes. + Supports ImageNet pre-trained weights. + """ + + SCHEMA = TorchvisionImageClassifierSchema + COMPATIBLE_COMPONENTS = ["ImageClassificationTask"] + DISPLAY_NAME: str = MultilingualString( + en="ResNet-50", + es="ResNet-50", + ) + DESCRIPTION: str = MultilingualString( + en=( + "ResNet-50 (He et al., 2015). A 50-layer residual network with " + "bottleneck blocks and skip connections. The most-cited CNN variant " + "in academic papers; supports ImageNet pre-trained weights." + ), + es=( + "ResNet-50 (He et al., 2015). Red residual de 50 capas con bloques " + "bottleneck y conexiones de salto. La variante CNN más citada en " + "papers académicos; soporta pesos preentrenados en ImageNet." + ), + ) + COLOR: str = "#1B5E20" + ICON: str = "AccountTree" + + def _build_backbone(self, num_classes: int, pretrained: bool) -> nn.Module: + weights = ResNet50_Weights.DEFAULT if pretrained else None + model = resnet50(weights=weights) + in_features = model.fc.in_features + model.fc = nn.Sequential( + nn.Dropout(self.dropout_rate), + nn.Linear(in_features, num_classes), + ) + return model + + def _classifier_head(self) -> nn.Module: + return self.model.fc diff --git a/DashAI/back/models/vgg16_image_classifier.py b/DashAI/back/models/vgg16_image_classifier.py new file mode 100644 index 000000000..c6c378a34 --- /dev/null +++ b/DashAI/back/models/vgg16_image_classifier.py @@ -0,0 +1,52 @@ +"""VGG-16 image classifier for DashAI.""" + +import torch.nn as nn +from torchvision.models import VGG16_Weights, vgg16 + +from DashAI.back.core.utils import MultilingualString +from DashAI.back.models.base_torchvision_image_classifier import ( + TorchvisionImageClassifier, + TorchvisionImageClassifierSchema, +) + + +class VGG16ImageClassifier(TorchvisionImageClassifier): + """VGG-16 image classifier (Simonyan & Zisserman, 2014). + + 16-layer deep network using exclusively 3×3 convolutions. The classifier + head (last FC layer) is replaced to match the number of target classes. + Supports ImageNet pre-trained weights for transfer learning. + """ + + SCHEMA = TorchvisionImageClassifierSchema + COMPATIBLE_COMPONENTS = ["ImageClassificationTask"] + DISPLAY_NAME: str = MultilingualString( + en="VGG-16", + es="VGG-16", + ) + DESCRIPTION: str = MultilingualString( + en=( + "VGG-16 (Simonyan & Zisserman, 2014). A 16-layer deep network " + "built exclusively with 3×3 convolutions. Standard academic " + "baseline; supports ImageNet pre-trained weights." + ), + es=( + "VGG-16 (Simonyan & Zisserman, 2014). Red profunda de 16 capas " + "construida exclusivamente con convoluciones 3×3. Baseline académico " + "estándar; soporta pesos preentrenados en ImageNet." + ), + ) + COLOR: str = "#E65100" + ICON: str = "AccountTree" + + def _build_backbone(self, num_classes: int, pretrained: bool) -> nn.Module: + weights = VGG16_Weights.DEFAULT if pretrained else None + model = vgg16(weights=weights) + model.classifier[6] = nn.Sequential( + nn.Dropout(self.dropout_rate), + nn.Linear(4096, num_classes), + ) + return model + + def _classifier_head(self) -> nn.Module: + return self.model.classifier From eef5c7814f1253cb92972020d6b22a6d11ccf76e Mon Sep 17 00:00:00 2001 From: Creylay Date: Tue, 26 May 2026 16:14:28 -0400 Subject: [PATCH 4/8] feat: add logging for non-finite metric scores in BaseModel --- DashAI/back/models/base_model.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/DashAI/back/models/base_model.py b/DashAI/back/models/base_model.py index 38877388e..a7a1d4808 100644 --- a/DashAI/back/models/base_model.py +++ b/DashAI/back/models/base_model.py @@ -1,5 +1,7 @@ """Base Model abstract class.""" +import logging +import math from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING, Any, Dict, Final, final @@ -12,6 +14,8 @@ if TYPE_CHECKING: from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset +logger = logging.getLogger(__name__) + class BaseModel(ConfigObject, metaclass=ABCMeta): """Abstract base class for all machine learning models in DashAI. @@ -277,6 +281,15 @@ def calculate_metrics( results = {} for metric in metrics: score = metric.score(y_transformed, y_pred) + if not math.isfinite(score): + logger.warning( + "Metric %s returned a non-finite value (%s) for split %s " + "(e.g. only one class present in the split). Skipping.", + metric.__name__, + score, + split, + ) + continue results[metric.__name__] = score # Save to database From 4c259b4308f55dd29b3284694a1e62916cc8c13c Mon Sep 17 00:00:00 2001 From: Creylay Date: Thu, 28 May 2026 09:56:33 -0400 Subject: [PATCH 5/8] refactor: remove VGG16 image classifier from initial components --- DashAI/back/initial_components.py | 2 - DashAI/back/models/vgg16_image_classifier.py | 52 -------------------- 2 files changed, 54 deletions(-) delete mode 100644 DashAI/back/models/vgg16_image_classifier.py diff --git a/DashAI/back/initial_components.py b/DashAI/back/initial_components.py index 9e9c5afcf..00025c152 100644 --- a/DashAI/back/initial_components.py +++ b/DashAI/back/initial_components.py @@ -257,7 +257,6 @@ from DashAI.back.models.scikit_learn.tfidf_logreg_text_classification_model import ( TfIdfLogRegTextClassificationModel, ) -from DashAI.back.models.vgg16_image_classifier import VGG16ImageClassifier # Optimizers from DashAI.back.optimizers.hyperopt_optimizer import HyperOptOptimizer @@ -382,7 +381,6 @@ def get_initial_components(): MLPImageClassifier, CNNImageClassifier, LeNet5ImageClassifier, - VGG16ImageClassifier, ResNet18ImageClassifier, ResNet50ImageClassifier, EfficientNetB0ImageClassifier, diff --git a/DashAI/back/models/vgg16_image_classifier.py b/DashAI/back/models/vgg16_image_classifier.py deleted file mode 100644 index c6c378a34..000000000 --- a/DashAI/back/models/vgg16_image_classifier.py +++ /dev/null @@ -1,52 +0,0 @@ -"""VGG-16 image classifier for DashAI.""" - -import torch.nn as nn -from torchvision.models import VGG16_Weights, vgg16 - -from DashAI.back.core.utils import MultilingualString -from DashAI.back.models.base_torchvision_image_classifier import ( - TorchvisionImageClassifier, - TorchvisionImageClassifierSchema, -) - - -class VGG16ImageClassifier(TorchvisionImageClassifier): - """VGG-16 image classifier (Simonyan & Zisserman, 2014). - - 16-layer deep network using exclusively 3×3 convolutions. The classifier - head (last FC layer) is replaced to match the number of target classes. - Supports ImageNet pre-trained weights for transfer learning. - """ - - SCHEMA = TorchvisionImageClassifierSchema - COMPATIBLE_COMPONENTS = ["ImageClassificationTask"] - DISPLAY_NAME: str = MultilingualString( - en="VGG-16", - es="VGG-16", - ) - DESCRIPTION: str = MultilingualString( - en=( - "VGG-16 (Simonyan & Zisserman, 2014). A 16-layer deep network " - "built exclusively with 3×3 convolutions. Standard academic " - "baseline; supports ImageNet pre-trained weights." - ), - es=( - "VGG-16 (Simonyan & Zisserman, 2014). Red profunda de 16 capas " - "construida exclusivamente con convoluciones 3×3. Baseline académico " - "estándar; soporta pesos preentrenados en ImageNet." - ), - ) - COLOR: str = "#E65100" - ICON: str = "AccountTree" - - def _build_backbone(self, num_classes: int, pretrained: bool) -> nn.Module: - weights = VGG16_Weights.DEFAULT if pretrained else None - model = vgg16(weights=weights) - model.classifier[6] = nn.Sequential( - nn.Dropout(self.dropout_rate), - nn.Linear(4096, num_classes), - ) - return model - - def _classifier_head(self) -> nn.Module: - return self.model.classifier From 1a7fd24cb82e23173ed12b11e8c9cff7030231f4 Mon Sep 17 00:00:00 2001 From: Creylay Date: Thu, 28 May 2026 17:49:15 -0400 Subject: [PATCH 6/8] Refactor image classifier models to use a unified dataset creation function - Replaced individual dataset classes with a common `_make_image_dataset` function across multiple classifiers (Torchvision, CNN, LeNet5, MLP). - Updated model definitions to utilize the new dataset function. - Simplified model architecture definitions by introducing `_build_*_model` functions for CNN, LeNet5, and MLP. - Removed redundant imports and organized code for better readability. - Enhanced training loops to include evaluation metrics for both training and validation datasets. --- .../base_torchvision_image_classifier.py | 162 +++++++----- DashAI/back/models/cnn_image_classifier.py | 239 +++++++++++------- .../efficientnet_b0_image_classifier.py | 10 +- DashAI/back/models/lenet5_image_classifier.py | 227 ++++++++++------- DashAI/back/models/mlp_image_classifier.py | 222 +++++++++------- .../back/models/resnet18_image_classifier.py | 10 +- .../back/models/resnet50_image_classifier.py | 10 +- 7 files changed, 530 insertions(+), 350 deletions(-) diff --git a/DashAI/back/models/base_torchvision_image_classifier.py b/DashAI/back/models/base_torchvision_image_classifier.py index 131d6a486..31addac40 100644 --- a/DashAI/back/models/base_torchvision_image_classifier.py +++ b/DashAI/back/models/base_torchvision_image_classifier.py @@ -1,13 +1,8 @@ """Shared base class for torchvision-based image classifiers.""" -import abc +from __future__ import annotations -import numpy as np -import torch -import torch.nn as nn -import torch.optim as optim -import torch.utils.data -from torchvision import transforms +import abc from DashAI.back.core.schema_fields import ( BaseSchema, @@ -152,50 +147,58 @@ class TorchvisionImageClassifierSchema(BaseSchema): ) # type: ignore -class _ImageDataset(torch.utils.data.Dataset): - """Torch Dataset with ImageNet normalization for torchvision models.""" - - def __init__(self, x_dataset, y_dataset=None, image_size=224): - self.x_dataset = x_dataset - self.y_dataset = y_dataset - self.transforms = transforms.Compose( - [ - transforms.Lambda(lambda img: img.convert("RGB")), - transforms.Resize((image_size, image_size)), - transforms.ToTensor(), - transforms.Normalize( - mean=[0.485, 0.456, 0.406], - std=[0.229, 0.224, 0.225], - ), - ] - ) - - self.image_col_name = list(x_dataset.features.keys())[0] - self.label_col_name = ( - list(y_dataset.features.keys())[0] if y_dataset is not None else None - ) - - self.label_to_idx = {} - self.idx_to_label = {} - if self.label_col_name: - unique_labels = sorted(set(self.y_dataset[self.label_col_name])) - self.label_to_idx = {label: idx for idx, label in enumerate(unique_labels)} - self.idx_to_label = {idx: label for label, idx in self.label_to_idx.items()} - - def num_classes(self): - if self.label_col_name is None: - return 0 - return len(self.label_to_idx) - - def __len__(self): - return len(self.x_dataset) - - def __getitem__(self, idx): - image = self.transforms(self.x_dataset[idx][self.image_col_name].to_pil()) - if self.label_col_name is None: - return image - label_str = self.y_dataset[idx][self.label_col_name] - return image, self.label_to_idx[label_str] +def _make_image_dataset(x_dataset, y_dataset=None, image_size=224): + import torch.utils.data + from torchvision import transforms + + class _ImageDataset(torch.utils.data.Dataset): + def __init__(self, x_ds, y_ds, img_size): + self.x_dataset = x_ds + self.y_dataset = y_ds + self.transforms = transforms.Compose( + [ + transforms.Lambda(lambda img: img.convert("RGB")), + transforms.Resize((img_size, img_size)), + transforms.ToTensor(), + transforms.Normalize( + mean=[0.485, 0.456, 0.406], + std=[0.229, 0.224, 0.225], + ), + ] + ) + + self.image_col_name = list(x_ds.features.keys())[0] + self.label_col_name = ( + list(y_ds.features.keys())[0] if y_ds is not None else None + ) + + self.label_to_idx = {} + self.idx_to_label = {} + if self.label_col_name: + unique_labels = sorted(set(self.y_dataset[self.label_col_name])) + self.label_to_idx = { + label: idx for idx, label in enumerate(unique_labels) + } + self.idx_to_label = { + idx: label for label, idx in self.label_to_idx.items() + } + + def num_classes(self): + if self.label_col_name is None: + return 0 + return len(self.label_to_idx) + + def __len__(self): + return len(self.x_dataset) + + def __getitem__(self, idx): + image = self.transforms(self.x_dataset[idx][self.image_col_name].to_pil()) + if self.label_col_name is None: + return image + label_str = self.y_dataset[idx][self.label_col_name] + return image, self.label_to_idx[label_str] + + return _ImageDataset(x_dataset, y_dataset, image_size) class TorchvisionImageClassifier(BaseModel, abc.ABC): @@ -211,21 +214,25 @@ class TorchvisionImageClassifier(BaseModel, abc.ABC): COMPATIBLE_COMPONENTS = ["ImageClassificationTask"] @abc.abstractmethod - def _build_backbone(self, num_classes: int, pretrained: bool) -> nn.Module: + def _build_backbone(self, num_classes: int, pretrained: bool): """Build and return the adapted torchvision model.""" @abc.abstractmethod - def _classifier_head(self) -> nn.Module: + def _classifier_head(self): """Return the classifier head module (kept trainable when freezing).""" @staticmethod def _collate_fn_with_labels(batch): + import torch + images = torch.stack([item[0] for item in batch]) labels = torch.tensor([item[1] for item in batch], dtype=torch.long) return images, labels @staticmethod def _collate_fn_no_labels(batch): + import torch + return torch.stack(batch) def __init__( @@ -240,6 +247,8 @@ def __init__( freeze_backbone=False, **kwargs, ): + import torch + self.epochs = epochs self.learning_rate = learning_rate self.batch_size = batch_size @@ -284,16 +293,23 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): y_train : DashAIDataset Target dataset containing string labels. x_validation : DashAIDataset, optional - Unused. Defaults to None. + Validation input features. Defaults to None. y_validation : DashAIDataset, optional - Unused. Defaults to None. + Validation target labels. Defaults to None. Returns ------- BaseTorchvisionImageClassifier The trained model instance. """ - image_dataset = _ImageDataset( + import torch + import torch.nn as nn + import torch.optim as optim + import torch.utils.data + + from DashAI.back.core.enums.metrics import LevelEnum, SplitEnum + + image_dataset = _make_image_dataset( x_train, y_dataset=y_train, image_size=self.image_size ) self.num_classes = image_dataset.num_classes() @@ -321,8 +337,8 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): weight_decay=self.weight_decay, ) - self.model.train() - for _ in range(self.epochs): + for epoch in range(self.epochs): + self.model.train() for images, labels in train_loader: images, labels = images.to(self.device), labels.to(self.device) self.optimizer.zero_grad() @@ -330,6 +346,23 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): loss.backward() self.optimizer.step() + self.model.eval() + self.calculate_metrics( + split=SplitEnum.TRAIN, + level=LevelEnum.EPOCH, + x_data=x_train, + y_data=y_train, + log_index=epoch + 1, + ) + if x_validation is not None: + self.calculate_metrics( + split=SplitEnum.VALIDATION, + level=LevelEnum.EPOCH, + x_data=x_validation, + y_data=y_validation, + log_index=epoch + 1, + ) + return self def predict(self, x): @@ -345,7 +378,13 @@ def predict(self, x): np.ndarray Array of shape (n_samples, n_classes) with softmax probabilities. """ - image_dataset = _ImageDataset(x, y_dataset=None, image_size=self.image_size) + import numpy as np + import torch + import torch.utils.data + + image_dataset = _make_image_dataset( + x, y_dataset=None, image_size=self.image_size + ) loader = torch.utils.data.DataLoader( image_dataset, batch_size=self.batch_size, @@ -370,6 +409,8 @@ def save(self, filename: str) -> None: filename : str Path where the checkpoint will be saved. """ + import torch + torch.save( { "model_state_dict": self.model.state_dict(), @@ -403,6 +444,9 @@ def load(cls, filename: str): BaseTorchvisionImageClassifier Instance with loaded weights. """ + import torch + import torch.optim as optim + ckpt = torch.load(filename, map_location=torch.device("cpu")) instance = cls( epochs=ckpt["epochs"], diff --git a/DashAI/back/models/cnn_image_classifier.py b/DashAI/back/models/cnn_image_classifier.py index 621872f4a..5cc785099 100644 --- a/DashAI/back/models/cnn_image_classifier.py +++ b/DashAI/back/models/cnn_image_classifier.py @@ -1,11 +1,6 @@ """CNN-based image classifier for DashAI.""" -import numpy as np -import torch -import torch.nn as nn -import torch.optim as optim -import torch.utils.data -from torchvision import transforms +from __future__ import annotations from DashAI.back.core.schema_fields import ( BaseSchema, @@ -151,97 +146,108 @@ class CNNImageClassifierSchema(BaseSchema): ) # type: ignore -class _ImageDataset(torch.utils.data.Dataset): - """Torch Dataset wrapper for DashAI image datasets.""" - - def __init__(self, x_dataset, y_dataset=None, image_size=64): - self.x_dataset = x_dataset - self.y_dataset = y_dataset - self.transforms = transforms.Compose( - [ - transforms.Resize((image_size, image_size)), - transforms.ToTensor(), - ] - ) - - self.image_col_name = list(x_dataset.features.keys())[0] - self.label_col_name = ( - list(y_dataset.features.keys())[0] if y_dataset is not None else None - ) - - self.label_to_idx = {} - self.idx_to_label = {} - if self.label_col_name: - unique_labels = sorted(set(self.y_dataset[self.label_col_name])) - self.label_to_idx = {label: idx for idx, label in enumerate(unique_labels)} - self.idx_to_label = {idx: label for label, idx in self.label_to_idx.items()} - - self.tensor_shape = self.transforms( - self.x_dataset[0][self.image_col_name].to_pil() - ).shape - - def num_classes(self): - if self.label_col_name is None: - return 0 - return len(self.label_to_idx) - - def __len__(self): - return len(self.x_dataset) - - def __getitem__(self, idx): - image = self.transforms(self.x_dataset[idx][self.image_col_name].to_pil()) - if self.label_col_name is None: - return image - label_str = self.y_dataset[idx][self.label_col_name] - return image, self.label_to_idx[label_str] - - -class _CNNBlock(nn.Module): - """Single convolutional block: Conv2d → ReLU → MaxPool2d.""" - - def __init__(self, in_channels, out_channels): - super().__init__() - self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1) - self.relu = nn.ReLU() - self.pool = nn.MaxPool2d(kernel_size=2, stride=2) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - return self.pool(self.relu(self.conv(x))) +def _make_image_dataset(x_dataset, y_dataset=None, image_size=64): + import torch.utils.data + from torchvision import transforms + class _ImageDataset(torch.utils.data.Dataset): + def __init__(self, x_ds, y_ds, img_size): + self.x_dataset = x_ds + self.y_dataset = y_ds + self.transforms = transforms.Compose( + [ + transforms.Resize((img_size, img_size)), + transforms.ToTensor(), + ] + ) -class _CNN(nn.Module): - """CNN with configurable convolutional blocks followed by a linear classifier.""" + self.image_col_name = list(x_ds.features.keys())[0] + self.label_col_name = ( + list(y_ds.features.keys())[0] if y_ds is not None else None + ) - def __init__( - self, + self.label_to_idx = {} + self.idx_to_label = {} + if self.label_col_name: + unique_labels = sorted(set(self.y_dataset[self.label_col_name])) + self.label_to_idx = { + label: idx for idx, label in enumerate(unique_labels) + } + self.idx_to_label = { + idx: label for label, idx in self.label_to_idx.items() + } + + self.tensor_shape = self.transforms( + self.x_dataset[0][self.image_col_name].to_pil() + ).shape + + def num_classes(self): + if self.label_col_name is None: + return 0 + return len(self.label_to_idx) + + def __len__(self): + return len(self.x_dataset) + + def __getitem__(self, idx): + image = self.transforms(self.x_dataset[idx][self.image_col_name].to_pil()) + if self.label_col_name is None: + return image + label_str = self.y_dataset[idx][self.label_col_name] + return image, self.label_to_idx[label_str] + + return _ImageDataset(x_dataset, y_dataset, image_size) + + +def _build_cnn_model( + input_channels, + input_size, + num_classes, + num_conv_blocks, + initial_filters, + dropout_rate, +): + import torch.nn as nn + + class _CNNBlock(nn.Module): + def __init__(self, in_channels, out_channels): + super().__init__() + self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1) + self.relu = nn.ReLU() + self.pool = nn.MaxPool2d(kernel_size=2, stride=2) + + def forward(self, x): + return self.pool(self.relu(self.conv(x))) + + class _CNN(nn.Module): + def __init__(self, in_ch, in_sz, n_cls, n_blocks, init_f, drop_r): + super().__init__() + self.conv_blocks = nn.ModuleList() + out_ch = init_f + for _ in range(n_blocks): + self.conv_blocks.append(_CNNBlock(in_ch, out_ch)) + in_ch = out_ch + out_ch *= 2 + + final_spatial = in_sz // (2**n_blocks) + flat_dim = in_ch * final_spatial * final_spatial + self.dropout = nn.Dropout(drop_r) + self.fc = nn.Linear(flat_dim, n_cls) + + def forward(self, x): + for block in self.conv_blocks: + x = block(x) + x = x.view(x.size(0), -1) + return self.fc(self.dropout(x)) + + return _CNN( input_channels, input_size, num_classes, num_conv_blocks, initial_filters, dropout_rate, - ): - super().__init__() - self.conv_blocks = nn.ModuleList() - in_ch = input_channels - out_ch = initial_filters - - for _ in range(num_conv_blocks): - self.conv_blocks.append(_CNNBlock(in_ch, out_ch)) - in_ch = out_ch - out_ch *= 2 - - final_spatial = input_size // (2**num_conv_blocks) - flat_dim = in_ch * final_spatial * final_spatial - - self.dropout = nn.Dropout(dropout_rate) - self.fc = nn.Linear(flat_dim, num_classes) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - for block in self.conv_blocks: - x = block(x) - x = x.view(x.size(0), -1) - return self.fc(self.dropout(x)) + ) class CNNImageClassifier(BaseModel): @@ -275,12 +281,16 @@ class CNNImageClassifier(BaseModel): @staticmethod def _collate_fn_with_labels(batch): + import torch + images = torch.stack([item[0] for item in batch]) labels = torch.tensor([item[1] for item in batch], dtype=torch.long) return images, labels @staticmethod def _collate_fn_no_labels(batch): + import torch + return torch.stack(batch) def __init__( @@ -295,6 +305,8 @@ def __init__( weight_decay=0.0, **kwargs, ): + import torch + self.epochs = epochs self.learning_rate = learning_rate self.batch_size = batch_size @@ -343,18 +355,25 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): y_train : DashAIDataset Target dataset containing labels. x_validation : DashAIDataset, optional - Unused. Defaults to None. + Validation input features. Defaults to None. y_validation : DashAIDataset, optional - Unused. Defaults to None. + Validation target labels. Defaults to None. Returns ------- CNNImageClassifier The trained model instance. """ + import torch + import torch.nn as nn + import torch.optim as optim + import torch.utils.data + + from DashAI.back.core.enums.metrics import LevelEnum, SplitEnum + self._validate_architecture() - image_dataset = _ImageDataset( + image_dataset = _make_image_dataset( x_train, y_dataset=y_train, image_size=self.image_size ) self.input_channels = image_dataset.tensor_shape[0] @@ -369,7 +388,7 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): collate_fn=self._collate_fn_with_labels, ) - self.model = _CNN( + self.model = _build_cnn_model( self.input_channels, self.image_size, self.num_classes, @@ -385,8 +404,8 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): weight_decay=self.weight_decay, ) - self.model.train() - for _ in range(self.epochs): + for epoch in range(self.epochs): + self.model.train() for images, labels in train_loader: images, labels = images.to(self.device), labels.to(self.device) self.optimizer.zero_grad() @@ -394,6 +413,23 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): loss.backward() self.optimizer.step() + self.model.eval() + self.calculate_metrics( + split=SplitEnum.TRAIN, + level=LevelEnum.EPOCH, + x_data=x_train, + y_data=y_train, + log_index=epoch + 1, + ) + if x_validation is not None: + self.calculate_metrics( + split=SplitEnum.VALIDATION, + level=LevelEnum.EPOCH, + x_data=x_validation, + y_data=y_validation, + log_index=epoch + 1, + ) + return self def predict(self, x): @@ -409,7 +445,13 @@ def predict(self, x): np.ndarray Array of shape (n_samples, n_classes) with softmax probabilities. """ - image_dataset = _ImageDataset(x, y_dataset=None, image_size=self.image_size) + import numpy as np + import torch + import torch.utils.data + + image_dataset = _make_image_dataset( + x, y_dataset=None, image_size=self.image_size + ) loader = torch.utils.data.DataLoader( image_dataset, batch_size=self.batch_size, @@ -434,6 +476,8 @@ def save(self, filename: str) -> None: filename : str Path where the checkpoint will be saved. """ + import torch + torch.save( { "model_state_dict": self.model.state_dict(), @@ -468,6 +512,9 @@ def load(cls, filename: str): CNNImageClassifier Instance with loaded weights. """ + import torch + import torch.optim as optim + ckpt = torch.load(filename, map_location=torch.device("cpu")) instance = cls( epochs=ckpt["epochs"], @@ -483,7 +530,7 @@ def load(cls, filename: str): instance.num_classes = ckpt["num_classes"] instance.idx_to_label = ckpt.get("idx_to_label", {}) instance.label_to_idx = ckpt.get("label_to_idx", {}) - instance.model = _CNN( + instance.model = _build_cnn_model( instance.input_channels, instance.image_size, instance.num_classes, diff --git a/DashAI/back/models/efficientnet_b0_image_classifier.py b/DashAI/back/models/efficientnet_b0_image_classifier.py index 43b3ec14b..a39fbe609 100644 --- a/DashAI/back/models/efficientnet_b0_image_classifier.py +++ b/DashAI/back/models/efficientnet_b0_image_classifier.py @@ -1,8 +1,5 @@ """EfficientNet-B0 image classifier for DashAI.""" -import torch.nn as nn -from torchvision.models import EfficientNet_B0_Weights, efficientnet_b0 - from DashAI.back.core.utils import MultilingualString from DashAI.back.models.base_torchvision_image_classifier import ( TorchvisionImageClassifier, @@ -39,7 +36,10 @@ class EfficientNetB0ImageClassifier(TorchvisionImageClassifier): COLOR: str = "#00838F" ICON: str = "Speed" - def _build_backbone(self, num_classes: int, pretrained: bool) -> nn.Module: + def _build_backbone(self, num_classes: int, pretrained: bool): + import torch.nn as nn + from torchvision.models import EfficientNet_B0_Weights, efficientnet_b0 + weights = EfficientNet_B0_Weights.DEFAULT if pretrained else None model = efficientnet_b0(weights=weights) in_features = model.classifier[1].in_features @@ -49,5 +49,5 @@ def _build_backbone(self, num_classes: int, pretrained: bool) -> nn.Module: ) return model - def _classifier_head(self) -> nn.Module: + def _classifier_head(self): return self.model.classifier diff --git a/DashAI/back/models/lenet5_image_classifier.py b/DashAI/back/models/lenet5_image_classifier.py index e5c3b85bb..6499c3685 100644 --- a/DashAI/back/models/lenet5_image_classifier.py +++ b/DashAI/back/models/lenet5_image_classifier.py @@ -1,11 +1,6 @@ """LeNet-5 image classifier for DashAI.""" -import numpy as np -import torch -import torch.nn as nn -import torch.optim as optim -import torch.utils.data -from torchvision import transforms +from __future__ import annotations from DashAI.back.core.schema_fields import ( BaseSchema, @@ -114,82 +109,93 @@ class LeNet5ImageClassifierSchema(BaseSchema): ) # type: ignore -class _ImageDataset(torch.utils.data.Dataset): - """Torch Dataset wrapper for DashAI image datasets.""" - - def __init__(self, x_dataset, y_dataset=None, image_size=32): - self.x_dataset = x_dataset - self.y_dataset = y_dataset - self.transforms = transforms.Compose( - [ - transforms.Resize((image_size, image_size)), - transforms.ToTensor(), - ] - ) - - self.image_col_name = list(x_dataset.features.keys())[0] - self.label_col_name = ( - list(y_dataset.features.keys())[0] if y_dataset is not None else None - ) - - self.label_to_idx = {} - self.idx_to_label = {} - if self.label_col_name: - unique_labels = sorted(set(self.y_dataset[self.label_col_name])) - self.label_to_idx = {label: idx for idx, label in enumerate(unique_labels)} - self.idx_to_label = {idx: label for label, idx in self.label_to_idx.items()} - - self.tensor_shape = self.transforms( - self.x_dataset[0][self.image_col_name].to_pil() - ).shape - - def num_classes(self): - if self.label_col_name is None: - return 0 - return len(self.label_to_idx) - - def __len__(self): - return len(self.x_dataset) - - def __getitem__(self, idx): - image = self.transforms(self.x_dataset[idx][self.image_col_name].to_pil()) - if self.label_col_name is None: - return image - label_str = self.y_dataset[idx][self.label_col_name] - return image, self.label_to_idx[label_str] - - -class _LeNet5(nn.Module): - """LeNet-5 architecture (LeCun et al., 1998) with configurable dropout.""" - - def __init__(self, input_channels, input_size, num_classes, dropout_rate): - super().__init__() - self.conv_layers = nn.Sequential( - nn.Conv2d(input_channels, 6, kernel_size=5), - nn.Tanh(), - nn.AvgPool2d(kernel_size=2, stride=2), - nn.Conv2d(6, 16, kernel_size=5), - nn.Tanh(), - nn.AvgPool2d(kernel_size=2, stride=2), - ) - - # Compute flattened dimension dynamically for arbitrary input_size - dummy = torch.zeros(1, input_channels, input_size, input_size) - flat_dim = self.conv_layers(dummy).view(1, -1).shape[1] - - self.classifier = nn.Sequential( - nn.Linear(flat_dim, 120), - nn.Tanh(), - nn.Dropout(dropout_rate), - nn.Linear(120, 84), - nn.Tanh(), - nn.Dropout(dropout_rate), - nn.Linear(84, num_classes), - ) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - x = self.conv_layers(x) - return self.classifier(x.view(x.size(0), -1)) +def _make_image_dataset(x_dataset, y_dataset=None, image_size=32): + import torch.utils.data + from torchvision import transforms + + class _ImageDataset(torch.utils.data.Dataset): + def __init__(self, x_ds, y_ds, img_size): + self.x_dataset = x_ds + self.y_dataset = y_ds + self.transforms = transforms.Compose( + [ + transforms.Resize((img_size, img_size)), + transforms.ToTensor(), + ] + ) + + self.image_col_name = list(x_ds.features.keys())[0] + self.label_col_name = ( + list(y_ds.features.keys())[0] if y_ds is not None else None + ) + + self.label_to_idx = {} + self.idx_to_label = {} + if self.label_col_name: + unique_labels = sorted(set(self.y_dataset[self.label_col_name])) + self.label_to_idx = { + label: idx for idx, label in enumerate(unique_labels) + } + self.idx_to_label = { + idx: label for label, idx in self.label_to_idx.items() + } + + self.tensor_shape = self.transforms( + self.x_dataset[0][self.image_col_name].to_pil() + ).shape + + def num_classes(self): + if self.label_col_name is None: + return 0 + return len(self.label_to_idx) + + def __len__(self): + return len(self.x_dataset) + + def __getitem__(self, idx): + image = self.transforms(self.x_dataset[idx][self.image_col_name].to_pil()) + if self.label_col_name is None: + return image + label_str = self.y_dataset[idx][self.label_col_name] + return image, self.label_to_idx[label_str] + + return _ImageDataset(x_dataset, y_dataset, image_size) + + +def _build_lenet5_model(input_channels, input_size, num_classes, dropout_rate): + import torch + import torch.nn as nn + + class _LeNet5(nn.Module): + def __init__(self, in_ch, in_sz, n_cls, drop_r): + super().__init__() + self.conv_layers = nn.Sequential( + nn.Conv2d(in_ch, 6, kernel_size=5), + nn.Tanh(), + nn.AvgPool2d(kernel_size=2, stride=2), + nn.Conv2d(6, 16, kernel_size=5), + nn.Tanh(), + nn.AvgPool2d(kernel_size=2, stride=2), + ) + + dummy = torch.zeros(1, in_ch, in_sz, in_sz) + flat_dim = self.conv_layers(dummy).view(1, -1).shape[1] + + self.classifier = nn.Sequential( + nn.Linear(flat_dim, 120), + nn.Tanh(), + nn.Dropout(drop_r), + nn.Linear(120, 84), + nn.Tanh(), + nn.Dropout(drop_r), + nn.Linear(84, n_cls), + ) + + def forward(self, x): + x = self.conv_layers(x) + return self.classifier(x.view(x.size(0), -1)) + + return _LeNet5(input_channels, input_size, num_classes, dropout_rate) class LeNet5ImageClassifier(BaseModel): @@ -223,12 +229,16 @@ class LeNet5ImageClassifier(BaseModel): @staticmethod def _collate_fn_with_labels(batch): + import torch + images = torch.stack([item[0] for item in batch]) labels = torch.tensor([item[1] for item in batch], dtype=torch.long) return images, labels @staticmethod def _collate_fn_no_labels(batch): + import torch + return torch.stack(batch) def __init__( @@ -241,6 +251,8 @@ def __init__( weight_decay=0.0, **kwargs, ): + import torch + self.epochs = epochs self.learning_rate = learning_rate self.batch_size = batch_size @@ -278,16 +290,23 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): y_train : DashAIDataset Target dataset containing string labels. x_validation : DashAIDataset, optional - Unused. Defaults to None. + Validation input features. Defaults to None. y_validation : DashAIDataset, optional - Unused. Defaults to None. + Validation target labels. Defaults to None. Returns ------- LeNet5ImageClassifier The trained model instance. """ - image_dataset = _ImageDataset( + import torch + import torch.nn as nn + import torch.optim as optim + import torch.utils.data + + from DashAI.back.core.enums.metrics import LevelEnum, SplitEnum + + image_dataset = _make_image_dataset( x_train, y_dataset=y_train, image_size=self.image_size ) self.input_channels = image_dataset.tensor_shape[0] @@ -302,7 +321,7 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): collate_fn=self._collate_fn_with_labels, ) - self.model = _LeNet5( + self.model = _build_lenet5_model( self.input_channels, self.image_size, self.num_classes, @@ -316,8 +335,8 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): weight_decay=self.weight_decay, ) - self.model.train() - for _ in range(self.epochs): + for epoch in range(self.epochs): + self.model.train() for images, labels in train_loader: images, labels = images.to(self.device), labels.to(self.device) self.optimizer.zero_grad() @@ -325,6 +344,23 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): loss.backward() self.optimizer.step() + self.model.eval() + self.calculate_metrics( + split=SplitEnum.TRAIN, + level=LevelEnum.EPOCH, + x_data=x_train, + y_data=y_train, + log_index=epoch + 1, + ) + if x_validation is not None: + self.calculate_metrics( + split=SplitEnum.VALIDATION, + level=LevelEnum.EPOCH, + x_data=x_validation, + y_data=y_validation, + log_index=epoch + 1, + ) + return self def predict(self, x): @@ -340,7 +376,13 @@ def predict(self, x): np.ndarray Array of shape (n_samples, n_classes) with softmax probabilities. """ - image_dataset = _ImageDataset(x, y_dataset=None, image_size=self.image_size) + import numpy as np + import torch + import torch.utils.data + + image_dataset = _make_image_dataset( + x, y_dataset=None, image_size=self.image_size + ) loader = torch.utils.data.DataLoader( image_dataset, batch_size=self.batch_size, @@ -365,6 +407,8 @@ def save(self, filename: str) -> None: filename : str Path where the checkpoint will be saved. """ + import torch + torch.save( { "model_state_dict": self.model.state_dict(), @@ -397,6 +441,9 @@ def load(cls, filename: str): LeNet5ImageClassifier Instance with loaded weights. """ + import torch + import torch.optim as optim + ckpt = torch.load(filename, map_location=torch.device("cpu")) instance = cls( epochs=ckpt["epochs"], @@ -410,7 +457,7 @@ def load(cls, filename: str): instance.num_classes = ckpt["num_classes"] instance.idx_to_label = ckpt.get("idx_to_label", {}) instance.label_to_idx = ckpt.get("label_to_idx", {}) - instance.model = _LeNet5( + instance.model = _build_lenet5_model( instance.input_channels, instance.image_size, instance.num_classes, diff --git a/DashAI/back/models/mlp_image_classifier.py b/DashAI/back/models/mlp_image_classifier.py index c54c721d9..c8e3e2d56 100644 --- a/DashAI/back/models/mlp_image_classifier.py +++ b/DashAI/back/models/mlp_image_classifier.py @@ -1,11 +1,6 @@ """MLP-based image classifier for DashAI.""" -import numpy as np -import torch -import torch.nn as nn -import torch.optim as optim -import torch.utils.data -from torchvision import transforms +from __future__ import annotations from DashAI.back.core.schema_fields import ( BaseSchema, @@ -138,77 +133,85 @@ class MLPImageClassifierSchema(BaseSchema): ) # type: ignore -class _ImageDataset(torch.utils.data.Dataset): - """Torch Dataset wrapper for DashAI image datasets.""" - - def __init__(self, x_dataset, y_dataset=None, image_size=64): - self.x_dataset = x_dataset - self.y_dataset = y_dataset - self.transforms = transforms.Compose( - [ - transforms.Resize((image_size, image_size)), - transforms.ToTensor(), - ] - ) - - self.image_col_name = list(x_dataset.features.keys())[0] - self.label_col_name = ( - list(y_dataset.features.keys())[0] if y_dataset is not None else None - ) - - self.label_to_idx = {} - self.idx_to_label = {} - if self.label_col_name: - unique_labels = sorted(set(self.y_dataset[self.label_col_name])) - self.label_to_idx = {label: idx for idx, label in enumerate(unique_labels)} - self.idx_to_label = {idx: label for label, idx in self.label_to_idx.items()} - - self.tensor_shape = self.transforms( - self.x_dataset[0][self.image_col_name].to_pil() - ).shape - - def num_classes(self): - if self.label_col_name is None: - return 0 - return len(self.label_to_idx) - - def __len__(self): - return len(self.x_dataset) - - def __getitem__(self, idx): - image = self.transforms(self.x_dataset[idx][self.image_col_name].to_pil()) - if self.label_col_name is None: - return image - label_str = self.y_dataset[idx][self.label_col_name] - label_idx = self.label_to_idx[label_str] - return image, label_idx - - -class _MLP(nn.Module): - """Multi-Layer Perceptron for image classification.""" - - def __init__(self, input_dim, output_dim, hidden_dims, dropout_rate=0.0): - super().__init__() - self.hidden_layers = nn.ModuleList() - self.dropout_layers = nn.ModuleList() - previous_dim = input_dim - - for hidden_dim in hidden_dims: - self.hidden_layers.append(nn.Linear(previous_dim, hidden_dim)) - self.dropout_layers.append(nn.Dropout(dropout_rate)) - previous_dim = hidden_dim - - self.output_layer = nn.Linear(previous_dim, output_dim) - self.relu = nn.ReLU() - - def forward(self, x: torch.Tensor): - batch_size = x.shape[0] - x = x.view(batch_size, -1) - - for layer, dropout in zip(self.hidden_layers, self.dropout_layers): - x = dropout(self.relu(layer(x))) - - return self.output_layer(x) +def _make_image_dataset(x_dataset, y_dataset=None, image_size=64): + import torch.utils.data + from torchvision import transforms + + class _ImageDataset(torch.utils.data.Dataset): + def __init__(self, x_ds, y_ds, img_size): + self.x_dataset = x_ds + self.y_dataset = y_ds + self.transforms = transforms.Compose( + [ + transforms.Resize((img_size, img_size)), + transforms.ToTensor(), + ] + ) + + self.image_col_name = list(x_ds.features.keys())[0] + self.label_col_name = ( + list(y_ds.features.keys())[0] if y_ds is not None else None + ) + + self.label_to_idx = {} + self.idx_to_label = {} + if self.label_col_name: + unique_labels = sorted(set(self.y_dataset[self.label_col_name])) + self.label_to_idx = { + label: idx for idx, label in enumerate(unique_labels) + } + self.idx_to_label = { + idx: label for label, idx in self.label_to_idx.items() + } + + self.tensor_shape = self.transforms( + self.x_dataset[0][self.image_col_name].to_pil() + ).shape + + def num_classes(self): + if self.label_col_name is None: + return 0 + return len(self.label_to_idx) + + def __len__(self): + return len(self.x_dataset) + + def __getitem__(self, idx): + image = self.transforms(self.x_dataset[idx][self.image_col_name].to_pil()) + if self.label_col_name is None: + return image + label_str = self.y_dataset[idx][self.label_col_name] + return image, self.label_to_idx[label_str] + + return _ImageDataset(x_dataset, y_dataset, image_size) + + +def _build_mlp_model(input_dim, output_dim, hidden_dims, dropout_rate=0.0): + import torch.nn as nn + + class _MLP(nn.Module): + def __init__(self, in_dim, out_dim, h_dims, drop_r): + super().__init__() + self.hidden_layers = nn.ModuleList() + self.dropout_layers = nn.ModuleList() + prev_dim = in_dim + for h_dim in h_dims: + self.hidden_layers.append(nn.Linear(prev_dim, h_dim)) + self.dropout_layers.append(nn.Dropout(drop_r)) + prev_dim = h_dim + self.output_layer = nn.Linear(prev_dim, out_dim) + self.relu = nn.ReLU() + + def forward(self, x): + batch_size = x.shape[0] + x = x.view(batch_size, -1) + for layer, dropout in zip( + self.hidden_layers, self.dropout_layers, strict=True + ): + x = dropout(self.relu(layer(x))) + return self.output_layer(x) + + return _MLP(input_dim, output_dim, hidden_dims, dropout_rate) class MLPImageClassifier(BaseModel): @@ -241,14 +244,16 @@ class MLPImageClassifier(BaseModel): @staticmethod def _collate_fn_with_labels(batch): - """Custom collate function for batches with (image, label) tuples.""" + import torch + images = torch.stack([item[0] for item in batch]) labels = torch.tensor([item[1] for item in batch], dtype=torch.long) return images, labels @staticmethod def _collate_fn_no_labels(batch): - """Custom collate function for batches with only images.""" + import torch + return torch.stack(batch) def __init__( @@ -262,6 +267,8 @@ def __init__( weight_decay=0.0, **kwargs, ): + import torch + if hidden_dims is None: hidden_dims = [128, 64] self.epochs = epochs @@ -304,16 +311,23 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): y_train : DashAIDataset Target dataset containing labels. x_validation : DashAIDataset, optional - Validation input features (unused). Defaults to None. + Validation input features. Defaults to None. y_validation : DashAIDataset, optional - Validation target labels (unused). Defaults to None. + Validation target labels. Defaults to None. Returns ------- MLPImageClassifier The trained model instance. """ - image_dataset = _ImageDataset( + import torch + import torch.nn as nn + import torch.optim as optim + import torch.utils.data + + from DashAI.back.core.enums.metrics import LevelEnum, SplitEnum + + image_dataset = _make_image_dataset( x_train, y_dataset=y_train, image_size=self.image_size ) @@ -323,7 +337,6 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): * image_dataset.tensor_shape[2] ) self.output_dim = image_dataset.num_classes() - self.idx_to_label = image_dataset.idx_to_label self.label_to_idx = image_dataset.label_to_idx @@ -334,9 +347,10 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): collate_fn=self._collate_fn_with_labels, ) - self.model = _MLP( + self.model = _build_mlp_model( self.input_dim, self.output_dim, self.hidden_dims, self.dropout_rate ).to(self.device) + criterion = nn.CrossEntropyLoss() self.optimizer = optim.Adam( self.model.parameters(), @@ -344,8 +358,8 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): weight_decay=self.weight_decay, ) - self.model.train() - for _ in range(self.epochs): + for epoch in range(self.epochs): + self.model.train() for images, labels in train_loader: images, labels = images.to(self.device), labels.to(self.device) self.optimizer.zero_grad() @@ -354,6 +368,23 @@ def train(self, x_train, y_train, x_validation=None, y_validation=None): loss.backward() self.optimizer.step() + self.model.eval() + self.calculate_metrics( + split=SplitEnum.TRAIN, + level=LevelEnum.EPOCH, + x_data=x_train, + y_data=y_train, + log_index=epoch + 1, + ) + if x_validation is not None: + self.calculate_metrics( + split=SplitEnum.VALIDATION, + level=LevelEnum.EPOCH, + x_data=x_validation, + y_data=y_validation, + log_index=epoch + 1, + ) + return self def predict(self, x): @@ -366,10 +397,16 @@ def predict(self, x): Returns ------- - list of lists - List of predicted probabilities for each class for each image. + np.ndarray + Array of shape (n_samples, n_classes) with softmax probabilities. """ - image_dataset = _ImageDataset(x, y_dataset=None, image_size=self.image_size) + import numpy as np + import torch + import torch.utils.data + + image_dataset = _make_image_dataset( + x, y_dataset=None, image_size=self.image_size + ) test_loader = torch.utils.data.DataLoader( image_dataset, batch_size=self.batch_size, @@ -400,6 +437,8 @@ def save(self, filename: str) -> None: filename : str Path where the checkpoint will be saved. """ + import torch + checkpoint = { "model_state_dict": self.model.state_dict(), "optimizer_state_dict": self.optimizer.state_dict(), @@ -431,6 +470,9 @@ def load(cls, filename: str): MLPImageClassifier Instance with loaded weights. """ + import torch + import torch.optim as optim + checkpoint = torch.load(filename, map_location=torch.device("cpu")) instance = cls( epochs=checkpoint["epochs"], @@ -443,7 +485,7 @@ def load(cls, filename: str): ) instance.input_dim = checkpoint["input_dim"] instance.output_dim = checkpoint["output_dim"] - instance.model = _MLP( + instance.model = _build_mlp_model( instance.input_dim, instance.output_dim, instance.hidden_dims, diff --git a/DashAI/back/models/resnet18_image_classifier.py b/DashAI/back/models/resnet18_image_classifier.py index 60ade3839..61fb38151 100644 --- a/DashAI/back/models/resnet18_image_classifier.py +++ b/DashAI/back/models/resnet18_image_classifier.py @@ -1,8 +1,5 @@ """ResNet-18 image classifier for DashAI.""" -import torch.nn as nn -from torchvision.models import ResNet18_Weights, resnet18 - from DashAI.back.core.utils import MultilingualString from DashAI.back.models.base_torchvision_image_classifier import ( TorchvisionImageClassifier, @@ -39,7 +36,10 @@ class ResNet18ImageClassifier(TorchvisionImageClassifier): COLOR: str = "#2E7D32" ICON: str = "AccountTree" - def _build_backbone(self, num_classes: int, pretrained: bool) -> nn.Module: + def _build_backbone(self, num_classes: int, pretrained: bool): + import torch.nn as nn + from torchvision.models import ResNet18_Weights, resnet18 + weights = ResNet18_Weights.DEFAULT if pretrained else None model = resnet18(weights=weights) in_features = model.fc.in_features @@ -49,5 +49,5 @@ def _build_backbone(self, num_classes: int, pretrained: bool) -> nn.Module: ) return model - def _classifier_head(self) -> nn.Module: + def _classifier_head(self): return self.model.fc diff --git a/DashAI/back/models/resnet50_image_classifier.py b/DashAI/back/models/resnet50_image_classifier.py index bba64ba1f..62b28b0f2 100644 --- a/DashAI/back/models/resnet50_image_classifier.py +++ b/DashAI/back/models/resnet50_image_classifier.py @@ -1,8 +1,5 @@ """ResNet-50 image classifier for DashAI.""" -import torch.nn as nn -from torchvision.models import ResNet50_Weights, resnet50 - from DashAI.back.core.utils import MultilingualString from DashAI.back.models.base_torchvision_image_classifier import ( TorchvisionImageClassifier, @@ -40,7 +37,10 @@ class ResNet50ImageClassifier(TorchvisionImageClassifier): COLOR: str = "#1B5E20" ICON: str = "AccountTree" - def _build_backbone(self, num_classes: int, pretrained: bool) -> nn.Module: + def _build_backbone(self, num_classes: int, pretrained: bool): + import torch.nn as nn + from torchvision.models import ResNet50_Weights, resnet50 + weights = ResNet50_Weights.DEFAULT if pretrained else None model = resnet50(weights=weights) in_features = model.fc.in_features @@ -50,5 +50,5 @@ def _build_backbone(self, num_classes: int, pretrained: bool) -> nn.Module: ) return model - def _classifier_head(self) -> nn.Module: + def _classifier_head(self): return self.model.fc From 43080b42774b054c048cf5ce9df7e8acc974d89d Mon Sep 17 00:00:00 2001 From: Creylay Date: Thu, 28 May 2026 18:25:36 -0400 Subject: [PATCH 7/8] feat: add device configuration to image classifier schemas and models --- .../base_torchvision_image_classifier.py | 22 ++++++++++++++++++- DashAI/back/models/cnn_image_classifier.py | 22 ++++++++++++++++++- DashAI/back/models/lenet5_image_classifier.py | 22 ++++++++++++++++++- DashAI/back/models/mlp_image_classifier.py | 22 ++++++++++++++++++- 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/DashAI/back/models/base_torchvision_image_classifier.py b/DashAI/back/models/base_torchvision_image_classifier.py index 31addac40..c146aa259 100644 --- a/DashAI/back/models/base_torchvision_image_classifier.py +++ b/DashAI/back/models/base_torchvision_image_classifier.py @@ -7,12 +7,14 @@ from DashAI.back.core.schema_fields import ( BaseSchema, bool_field, + enum_field, float_field, int_field, schema_field, ) from DashAI.back.core.utils import MultilingualString from DashAI.back.models.base_model import BaseModel +from DashAI.back.models.utils import DEVICE_ENUM, DEVICE_PLACEHOLDER, DEVICE_TO_IDX class TorchvisionImageClassifierSchema(BaseSchema): @@ -146,6 +148,16 @@ class TorchvisionImageClassifierSchema(BaseSchema): ), ) # type: ignore + device: schema_field( + enum_field(enum=DEVICE_ENUM), + placeholder=DEVICE_PLACEHOLDER, + description=MultilingualString( + en="Hardware device used for training and inference (CPU/GPU).", + es="Dispositivo de hardware para entrenamiento e inferencia (CPU/GPU).", + ), + alias=MultilingualString(en="Device", es="Dispositivo"), + ) # type: ignore + def _make_image_dataset(x_dataset, y_dataset=None, image_size=224): import torch.utils.data @@ -245,6 +257,7 @@ def __init__( weight_decay=0.0, pretrained=True, freeze_backbone=False, + device=DEVICE_PLACEHOLDER, **kwargs, ): import torch @@ -257,7 +270,12 @@ def __init__( self.weight_decay = weight_decay self.pretrained = pretrained self.freeze_backbone = freeze_backbone - self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self._device_name = device + self.device = torch.device( + f"cuda:{DEVICE_TO_IDX.get(device)}" + if DEVICE_TO_IDX.get(device, -1) >= 0 + else "cpu" + ) self.model = None self.optimizer = None self.num_classes = None @@ -423,6 +441,7 @@ def save(self, filename: str) -> None: "weight_decay": self.weight_decay, "pretrained": self.pretrained, "freeze_backbone": self.freeze_backbone, + "device_name": self._device_name, "num_classes": self.num_classes, "idx_to_label": self.idx_to_label, "label_to_idx": self.label_to_idx, @@ -457,6 +476,7 @@ def load(cls, filename: str): weight_decay=ckpt.get("weight_decay", 0.0), pretrained=False, freeze_backbone=ckpt.get("freeze_backbone", False), + device=ckpt.get("device_name", DEVICE_PLACEHOLDER), ) instance.num_classes = ckpt["num_classes"] instance.idx_to_label = ckpt.get("idx_to_label", {}) diff --git a/DashAI/back/models/cnn_image_classifier.py b/DashAI/back/models/cnn_image_classifier.py index 5cc785099..8bccd9065 100644 --- a/DashAI/back/models/cnn_image_classifier.py +++ b/DashAI/back/models/cnn_image_classifier.py @@ -4,12 +4,14 @@ from DashAI.back.core.schema_fields import ( BaseSchema, + enum_field, float_field, int_field, schema_field, ) from DashAI.back.core.utils import MultilingualString from DashAI.back.models.base_model import BaseModel +from DashAI.back.models.utils import DEVICE_ENUM, DEVICE_PLACEHOLDER, DEVICE_TO_IDX class CNNImageClassifierSchema(BaseSchema): @@ -145,6 +147,16 @@ class CNNImageClassifierSchema(BaseSchema): alias=MultilingualString(en="Weight decay", es="Decaimiento de pesos"), ) # type: ignore + device: schema_field( + enum_field(enum=DEVICE_ENUM), + placeholder=DEVICE_PLACEHOLDER, + description=MultilingualString( + en="Hardware device used for training and inference (CPU/GPU).", + es="Dispositivo de hardware para entrenamiento e inferencia (CPU/GPU).", + ), + alias=MultilingualString(en="Device", es="Dispositivo"), + ) # type: ignore + def _make_image_dataset(x_dataset, y_dataset=None, image_size=64): import torch.utils.data @@ -303,6 +315,7 @@ def __init__( initial_filters=32, dropout_rate=0.0, weight_decay=0.0, + device=DEVICE_PLACEHOLDER, **kwargs, ): import torch @@ -315,7 +328,12 @@ def __init__( self.initial_filters = initial_filters self.dropout_rate = dropout_rate self.weight_decay = weight_decay - self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self._device_name = device + self.device = torch.device( + f"cuda:{DEVICE_TO_IDX.get(device)}" + if DEVICE_TO_IDX.get(device, -1) >= 0 + else "cpu" + ) self.model = None self.optimizer = None self.input_channels = None @@ -490,6 +508,7 @@ def save(self, filename: str) -> None: "initial_filters": self.initial_filters, "dropout_rate": self.dropout_rate, "weight_decay": self.weight_decay, + "device_name": self._device_name, "input_channels": self.input_channels, "num_classes": self.num_classes, "idx_to_label": self.idx_to_label, @@ -525,6 +544,7 @@ def load(cls, filename: str): initial_filters=ckpt.get("initial_filters", 32), dropout_rate=ckpt.get("dropout_rate", 0.0), weight_decay=ckpt.get("weight_decay", 0.0), + device=ckpt.get("device_name", DEVICE_PLACEHOLDER), ) instance.input_channels = ckpt["input_channels"] instance.num_classes = ckpt["num_classes"] diff --git a/DashAI/back/models/lenet5_image_classifier.py b/DashAI/back/models/lenet5_image_classifier.py index 6499c3685..a2ebd5753 100644 --- a/DashAI/back/models/lenet5_image_classifier.py +++ b/DashAI/back/models/lenet5_image_classifier.py @@ -4,12 +4,14 @@ from DashAI.back.core.schema_fields import ( BaseSchema, + enum_field, float_field, int_field, schema_field, ) from DashAI.back.core.utils import MultilingualString from DashAI.back.models.base_model import BaseModel +from DashAI.back.models.utils import DEVICE_ENUM, DEVICE_PLACEHOLDER, DEVICE_TO_IDX class LeNet5ImageClassifierSchema(BaseSchema): @@ -108,6 +110,16 @@ class LeNet5ImageClassifierSchema(BaseSchema): alias=MultilingualString(en="Weight decay", es="Decaimiento de pesos"), ) # type: ignore + device: schema_field( + enum_field(enum=DEVICE_ENUM), + placeholder=DEVICE_PLACEHOLDER, + description=MultilingualString( + en="Hardware device used for training and inference (CPU/GPU).", + es="Dispositivo de hardware para entrenamiento e inferencia (CPU/GPU).", + ), + alias=MultilingualString(en="Device", es="Dispositivo"), + ) # type: ignore + def _make_image_dataset(x_dataset, y_dataset=None, image_size=32): import torch.utils.data @@ -249,6 +261,7 @@ def __init__( image_size=32, dropout_rate=0.0, weight_decay=0.0, + device=DEVICE_PLACEHOLDER, **kwargs, ): import torch @@ -259,7 +272,12 @@ def __init__( self.image_size = image_size self.dropout_rate = dropout_rate self.weight_decay = weight_decay - self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self._device_name = device + self.device = torch.device( + f"cuda:{DEVICE_TO_IDX.get(device)}" + if DEVICE_TO_IDX.get(device, -1) >= 0 + else "cpu" + ) self.model = None self.optimizer = None self.input_channels = None @@ -419,6 +437,7 @@ def save(self, filename: str) -> None: "image_size": self.image_size, "dropout_rate": self.dropout_rate, "weight_decay": self.weight_decay, + "device_name": self._device_name, "input_channels": self.input_channels, "num_classes": self.num_classes, "idx_to_label": self.idx_to_label, @@ -452,6 +471,7 @@ def load(cls, filename: str): image_size=ckpt.get("image_size", 32), dropout_rate=ckpt.get("dropout_rate", 0.0), weight_decay=ckpt.get("weight_decay", 0.0), + device=ckpt.get("device_name", DEVICE_PLACEHOLDER), ) instance.input_channels = ckpt["input_channels"] instance.num_classes = ckpt["num_classes"] diff --git a/DashAI/back/models/mlp_image_classifier.py b/DashAI/back/models/mlp_image_classifier.py index c8e3e2d56..e916ea76c 100644 --- a/DashAI/back/models/mlp_image_classifier.py +++ b/DashAI/back/models/mlp_image_classifier.py @@ -4,6 +4,7 @@ from DashAI.back.core.schema_fields import ( BaseSchema, + enum_field, float_field, int_field, list_field, @@ -11,6 +12,7 @@ ) from DashAI.back.core.utils import MultilingualString from DashAI.back.models.base_model import BaseModel +from DashAI.back.models.utils import DEVICE_ENUM, DEVICE_PLACEHOLDER, DEVICE_TO_IDX class MLPImageClassifierSchema(BaseSchema): @@ -132,6 +134,16 @@ class MLPImageClassifierSchema(BaseSchema): alias=MultilingualString(en="Weight decay", es="Decaimiento de pesos"), ) # type: ignore + device: schema_field( + enum_field(enum=DEVICE_ENUM), + placeholder=DEVICE_PLACEHOLDER, + description=MultilingualString( + en="Hardware device used for training and inference (CPU/GPU).", + es="Dispositivo de hardware para entrenamiento e inferencia (CPU/GPU).", + ), + alias=MultilingualString(en="Device", es="Dispositivo"), + ) # type: ignore + def _make_image_dataset(x_dataset, y_dataset=None, image_size=64): import torch.utils.data @@ -265,6 +277,7 @@ def __init__( image_size=64, dropout_rate=0.0, weight_decay=0.0, + device=DEVICE_PLACEHOLDER, **kwargs, ): import torch @@ -278,7 +291,12 @@ def __init__( self.image_size = image_size self.dropout_rate = dropout_rate self.weight_decay = weight_decay - self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + self._device_name = device + self.device = torch.device( + f"cuda:{DEVICE_TO_IDX.get(device)}" + if DEVICE_TO_IDX.get(device, -1) >= 0 + else "cpu" + ) self.model = None self.optimizer = None self.input_dim = None @@ -449,6 +467,7 @@ def save(self, filename: str) -> None: "image_size": self.image_size, "dropout_rate": self.dropout_rate, "weight_decay": self.weight_decay, + "device_name": self._device_name, "input_dim": self.input_dim, "output_dim": self.output_dim, "idx_to_label": self.idx_to_label, @@ -482,6 +501,7 @@ def load(cls, filename: str): image_size=checkpoint.get("image_size", 64), dropout_rate=checkpoint.get("dropout_rate", 0.0), weight_decay=checkpoint.get("weight_decay", 0.0), + device=checkpoint.get("device_name", DEVICE_PLACEHOLDER), ) instance.input_dim = checkpoint["input_dim"] instance.output_dim = checkpoint["output_dim"] From 891b7c6e5939a8183022052a0497cbe309d4db5f Mon Sep 17 00:00:00 2001 From: Creylay Date: Thu, 28 May 2026 18:35:05 -0400 Subject: [PATCH 8/8] feat: add Portuguese translations for image classifier schemas and models --- .../base_torchvision_image_classifier.py | 62 +++++++++++++--- DashAI/back/models/cnn_image_classifier.py | 70 ++++++++++++++++--- .../efficientnet_b0_image_classifier.py | 6 ++ DashAI/back/models/lenet5_image_classifier.py | 56 +++++++++++++-- DashAI/back/models/mlp_image_classifier.py | 64 +++++++++++++++-- .../back/models/resnet18_image_classifier.py | 6 ++ .../back/models/resnet50_image_classifier.py | 6 ++ 7 files changed, 240 insertions(+), 30 deletions(-) diff --git a/DashAI/back/models/base_torchvision_image_classifier.py b/DashAI/back/models/base_torchvision_image_classifier.py index c146aa259..d93fa688c 100644 --- a/DashAI/back/models/base_torchvision_image_classifier.py +++ b/DashAI/back/models/base_torchvision_image_classifier.py @@ -32,8 +32,12 @@ class TorchvisionImageClassifierSchema(BaseSchema): "El número de épocas para entrenar el modelo. Una época es una " "iteración completa sobre los datos de entrenamiento." ), + pt=( + "O número de épocas para treinar o modelo. Uma época é uma " + "iteração completa sobre os dados de treinamento." + ), ), - alias=MultilingualString(en="Epochs", es="Épocas"), + alias=MultilingualString(en="Epochs", es="Épocas", pt="Épocas"), ) # type: ignore learning_rate: schema_field( @@ -42,8 +46,13 @@ class TorchvisionImageClassifierSchema(BaseSchema): description=MultilingualString( en="Learning rate for the Adam optimizer.", es="Tasa de aprendizaje para el optimizador Adam.", + pt="Taxa de aprendizado para o otimizador Adam.", + ), + alias=MultilingualString( + en="Learning rate", + es="Tasa de aprendizaje", + pt="Taxa de aprendizado", ), - alias=MultilingualString(en="Learning rate", es="Tasa de aprendizaje"), ) # type: ignore batch_size: schema_field( @@ -59,8 +68,15 @@ class TorchvisionImageClassifierSchema(BaseSchema): "entrenamiento. Valores más grandes aceleran el entrenamiento " "pero requieren más memoria." ), + pt=( + "Número de imagens processadas juntas em cada etapa de " + "treinamento. Valores maiores aceleram o treinamento " + "mas requerem mais memória." + ), + ), + alias=MultilingualString( + en="Batch size", es="Tamaño de lote", pt="Tamanho do lote" ), - alias=MultilingualString(en="Batch size", es="Tamaño de lote"), ) # type: ignore image_size: schema_field( @@ -76,8 +92,15 @@ class TorchvisionImageClassifierSchema(BaseSchema): "en ancho como en alto. Use 224 para modelos preentrenados " "en ImageNet." ), + pt=( + "As imagens são redimensionadas para este valor (em pixels) tanto " + "em largura quanto em altura. Use 224 para modelos pré-treinados " + "no ImageNet." + ), + ), + alias=MultilingualString( + en="Image size", es="Tamaño de imagen", pt="Tamanho da imagem" ), - alias=MultilingualString(en="Image size", es="Tamaño de imagen"), ) # type: ignore dropout_rate: schema_field( @@ -92,8 +115,14 @@ class TorchvisionImageClassifierSchema(BaseSchema): "Tasa de dropout aplicada antes de la capa de salida. " "Valores entre 0.2 y 0.5 ayudan a prevenir el sobreajuste." ), + pt=( + "Taxa de dropout aplicada antes da camada de saída. " + "Valores entre 0.2 e 0.5 ajudam a prevenir o sobreajuste." + ), + ), + alias=MultilingualString( + en="Dropout rate", es="Tasa de dropout", pt="Taxa de dropout" ), - alias=MultilingualString(en="Dropout rate", es="Tasa de dropout"), ) # type: ignore weight_decay: schema_field( @@ -108,8 +137,14 @@ class TorchvisionImageClassifierSchema(BaseSchema): "Coeficiente de regularización L2 para el optimizador Adam. " "Valores típicos: 1e-4 a 1e-2." ), + pt=( + "Coeficiente de regularização L2 para o otimizador Adam. " + "Valores típicos: 1e-4 a 1e-2." + ), + ), + alias=MultilingualString( + en="Weight decay", es="Decaimiento de pesos", pt="Decaimento de pesos" ), - alias=MultilingualString(en="Weight decay", es="Decaimiento de pesos"), ) # type: ignore pretrained: schema_field( @@ -125,8 +160,13 @@ class TorchvisionImageClassifierSchema(BaseSchema): "Recomendado cuando el dataset es pequeño o similar " "a imágenes naturales." ), + pt=( + "Se True, carrega pesos pré-treinados no ImageNet. " + "Recomendado quando o conjunto de dados é pequeno ou similar " + "a imagens naturais." + ), ), - alias=MultilingualString(en="Pretrained", es="Preentrenado"), + alias=MultilingualString(en="Pretrained", es="Preentrenado", pt="Pré-treinado"), ) # type: ignore freeze_backbone: schema_field( @@ -141,10 +181,15 @@ class TorchvisionImageClassifierSchema(BaseSchema): "Si es True, congela el backbone convolucional y solo entrena " "el clasificador final. Útil para datasets muy pequeños." ), + pt=( + "Se True, congela o backbone convolucional e treina apenas " + "o classificador final. Útil para conjuntos de dados muito pequenos." + ), ), alias=MultilingualString( en="Freeze backbone", es="Congelar backbone", + pt="Congelar backbone", ), ) # type: ignore @@ -154,8 +199,9 @@ class TorchvisionImageClassifierSchema(BaseSchema): description=MultilingualString( en="Hardware device used for training and inference (CPU/GPU).", es="Dispositivo de hardware para entrenamiento e inferencia (CPU/GPU).", + pt="Dispositivo de hardware usado para treinamento e inferência (CPU/GPU).", ), - alias=MultilingualString(en="Device", es="Dispositivo"), + alias=MultilingualString(en="Device", es="Dispositivo", pt="Dispositivo"), ) # type: ignore diff --git a/DashAI/back/models/cnn_image_classifier.py b/DashAI/back/models/cnn_image_classifier.py index 8bccd9065..a7d529ecb 100644 --- a/DashAI/back/models/cnn_image_classifier.py +++ b/DashAI/back/models/cnn_image_classifier.py @@ -29,8 +29,12 @@ class CNNImageClassifierSchema(BaseSchema): "El número de épocas para entrenar el modelo. Una época es una " "iteración completa sobre los datos de entrenamiento." ), + pt=( + "O número de épocas para treinar o modelo. Uma época é uma " + "iteração completa sobre os dados de treinamento." + ), ), - alias=MultilingualString(en="Epochs", es="Épocas"), + alias=MultilingualString(en="Epochs", es="Épocas", pt="Épocas"), ) # type: ignore learning_rate: schema_field( @@ -39,8 +43,13 @@ class CNNImageClassifierSchema(BaseSchema): description=MultilingualString( en="Learning rate for the Adam optimizer.", es="Tasa de aprendizaje para el optimizador Adam.", + pt="Taxa de aprendizado para o otimizador Adam.", + ), + alias=MultilingualString( + en="Learning rate", + es="Tasa de aprendizaje", + pt="Taxa de aprendizado", ), - alias=MultilingualString(en="Learning rate", es="Tasa de aprendizaje"), ) # type: ignore batch_size: schema_field( @@ -56,8 +65,15 @@ class CNNImageClassifierSchema(BaseSchema): "entrenamiento. Valores más grandes aceleran el entrenamiento " "pero requieren más memoria." ), + pt=( + "Número de imagens processadas juntas em cada etapa de " + "treinamento. Valores maiores aceleram o treinamento " + "mas requerem mais memória." + ), + ), + alias=MultilingualString( + en="Batch size", es="Tamaño de lote", pt="Tamanho do lote" ), - alias=MultilingualString(en="Batch size", es="Tamaño de lote"), ) # type: ignore image_size: schema_field( @@ -72,8 +88,14 @@ class CNNImageClassifierSchema(BaseSchema): "Las imágenes se redimensionan a este valor (en píxeles) tanto " "en ancho como en alto. Debe ser al menos 2^num_conv_blocks." ), + pt=( + "As imagens são redimensionadas para este valor (em pixels) tanto " + "em largura quanto em altura. Deve ser pelo menos 2^num_conv_blocks." + ), + ), + alias=MultilingualString( + en="Image size", es="Tamaño de imagen", pt="Tamanho da imagem" ), - alias=MultilingualString(en="Image size", es="Tamaño de imagen"), ) # type: ignore num_conv_blocks: schema_field( @@ -90,10 +112,16 @@ class CNNImageClassifierSchema(BaseSchema): "convolución, activación ReLU y max-pooling que reduce a la " "mitad las dimensiones espaciales." ), + pt=( + "Número de blocos convolucionais. Cada bloco aplica uma " + "convolução, ativação ReLU e max-pooling que reduz à metade " + "as dimensões espaciais." + ), ), alias=MultilingualString( en="Number of conv blocks", es="Número de bloques conv", + pt="Número de blocos conv", ), ) # type: ignore @@ -109,8 +137,14 @@ class CNNImageClassifierSchema(BaseSchema): "Número de filtros en el primer bloque convolucional. " "Cada bloque siguiente duplica este número." ), + pt=( + "Número de filtros no primeiro bloco convolucional. " + "Cada bloco subsequente dobra este número." + ), + ), + alias=MultilingualString( + en="Initial filters", es="Filtros iniciales", pt="Filtros iniciais" ), - alias=MultilingualString(en="Initial filters", es="Filtros iniciales"), ) # type: ignore dropout_rate: schema_field( @@ -127,8 +161,15 @@ class CNNImageClassifierSchema(BaseSchema): "capa de salida. Valores entre 0.2 y 0.5 ayudan a prevenir el " "sobreajuste. Use 0.0 para desactivarlo." ), + pt=( + "Fração de neurônios desativados aleatoriamente antes da " + "camada de saída. Valores entre 0.2 e 0.5 ajudam a prevenir o " + "sobreajuste. Use 0.0 para desativar." + ), + ), + alias=MultilingualString( + en="Dropout rate", es="Tasa de dropout", pt="Taxa de dropout" ), - alias=MultilingualString(en="Dropout rate", es="Tasa de dropout"), ) # type: ignore weight_decay: schema_field( @@ -143,8 +184,14 @@ class CNNImageClassifierSchema(BaseSchema): "Coeficiente de regularización L2 para el optimizador Adam. " "Valores típicos: 1e-4 a 1e-2." ), + pt=( + "Coeficiente de regularização L2 para o otimizador Adam. " + "Valores típicos: 1e-4 a 1e-2." + ), + ), + alias=MultilingualString( + en="Weight decay", es="Decaimiento de pesos", pt="Decaimento de pesos" ), - alias=MultilingualString(en="Weight decay", es="Decaimiento de pesos"), ) # type: ignore device: schema_field( @@ -153,8 +200,9 @@ class CNNImageClassifierSchema(BaseSchema): description=MultilingualString( en="Hardware device used for training and inference (CPU/GPU).", es="Dispositivo de hardware para entrenamiento e inferencia (CPU/GPU).", + pt="Dispositivo de hardware usado para treinamento e inferência (CPU/GPU).", ), - alias=MultilingualString(en="Device", es="Dispositivo"), + alias=MultilingualString(en="Device", es="Dispositivo", pt="Dispositivo"), ) # type: ignore @@ -275,6 +323,7 @@ class CNNImageClassifier(BaseModel): DISPLAY_NAME: str = MultilingualString( en="CNN Image Classifier", es="Clasificador de Imágenes CNN", + pt="Classificador de Imagens CNN", ) DESCRIPTION: str = MultilingualString( en=( @@ -287,6 +336,11 @@ class CNNImageClassifier(BaseModel): "(CNN) que aprende características espaciales mediante bloques " "conv→ReLU→pool configurables, duplicando los filtros en cada etapa." ), + pt=( + "Um classificador de imagens baseado em Rede Neural Convolucional " + "(CNN) que aprende características espaciais por meio de blocos " + "conv→ReLU→pool configuráveis, dobrando os filtros em cada etapa." + ), ) COLOR: str = "#1565C0" ICON: str = "Layers" diff --git a/DashAI/back/models/efficientnet_b0_image_classifier.py b/DashAI/back/models/efficientnet_b0_image_classifier.py index a39fbe609..e8033aca0 100644 --- a/DashAI/back/models/efficientnet_b0_image_classifier.py +++ b/DashAI/back/models/efficientnet_b0_image_classifier.py @@ -20,6 +20,7 @@ class EfficientNetB0ImageClassifier(TorchvisionImageClassifier): DISPLAY_NAME: str = MultilingualString( en="EfficientNet-B0", es="EfficientNet-B0", + pt="EfficientNet-B0", ) DESCRIPTION: str = MultilingualString( en=( @@ -32,6 +33,11 @@ class EfficientNetB0ImageClassifier(TorchvisionImageClassifier): "resolución de la red de forma conjunta para el mejor balance entre " "accuracy y eficiencia. Más pequeño y rápido que ResNet-18." ), + pt=( + "EfficientNet-B0 (Tan & Le, 2019). Escala largura, profundidade e " + "resolução da rede de forma conjunta para o melhor equilíbrio entre " + "acurácia e eficiência. Menor e mais rápido que o ResNet-18." + ), ) COLOR: str = "#00838F" ICON: str = "Speed" diff --git a/DashAI/back/models/lenet5_image_classifier.py b/DashAI/back/models/lenet5_image_classifier.py index a2ebd5753..97de14f51 100644 --- a/DashAI/back/models/lenet5_image_classifier.py +++ b/DashAI/back/models/lenet5_image_classifier.py @@ -29,8 +29,12 @@ class LeNet5ImageClassifierSchema(BaseSchema): "El número de épocas para entrenar el modelo. Una época es una " "iteración completa sobre los datos de entrenamiento." ), + pt=( + "O número de épocas para treinar o modelo. Uma época é uma " + "iteração completa sobre os dados de treinamento." + ), ), - alias=MultilingualString(en="Epochs", es="Épocas"), + alias=MultilingualString(en="Epochs", es="Épocas", pt="Épocas"), ) # type: ignore learning_rate: schema_field( @@ -39,8 +43,13 @@ class LeNet5ImageClassifierSchema(BaseSchema): description=MultilingualString( en="Learning rate for the Adam optimizer.", es="Tasa de aprendizaje para el optimizador Adam.", + pt="Taxa de aprendizado para o otimizador Adam.", + ), + alias=MultilingualString( + en="Learning rate", + es="Tasa de aprendizaje", + pt="Taxa de aprendizado", ), - alias=MultilingualString(en="Learning rate", es="Tasa de aprendizaje"), ) # type: ignore batch_size: schema_field( @@ -56,8 +65,15 @@ class LeNet5ImageClassifierSchema(BaseSchema): "entrenamiento. Valores más grandes aceleran el entrenamiento " "pero requieren más memoria." ), + pt=( + "Número de imagens processadas juntas em cada etapa de " + "treinamento. Valores maiores aceleram o treinamento " + "mas requerem mais memória." + ), + ), + alias=MultilingualString( + en="Batch size", es="Tamaño de lote", pt="Tamanho do lote" ), - alias=MultilingualString(en="Batch size", es="Tamaño de lote"), ) # type: ignore image_size: schema_field( @@ -72,8 +88,14 @@ class LeNet5ImageClassifierSchema(BaseSchema): "Las imágenes se redimensionan a este valor (en píxeles) tanto " "en ancho como en alto. El LeNet-5 original usa 32×32." ), + pt=( + "As imagens são redimensionadas para este valor (em pixels) tanto " + "em largura quanto em altura. O LeNet-5 original usa 32×32." + ), + ), + alias=MultilingualString( + en="Image size", es="Tamaño de imagen", pt="Tamanho da imagem" ), - alias=MultilingualString(en="Image size", es="Tamaño de imagen"), ) # type: ignore dropout_rate: schema_field( @@ -90,8 +112,15 @@ class LeNet5ImageClassifierSchema(BaseSchema): "Valores entre 0.2 y 0.5 ayudan a prevenir el sobreajuste. " "Use 0.0 para reproducir el LeNet-5 original." ), + pt=( + "Taxa de dropout aplicada entre as camadas completamente conectadas. " + "Valores entre 0.2 e 0.5 ajudam a prevenir o sobreajuste. " + "Use 0.0 para reproduzir o LeNet-5 original." + ), + ), + alias=MultilingualString( + en="Dropout rate", es="Tasa de dropout", pt="Taxa de dropout" ), - alias=MultilingualString(en="Dropout rate", es="Tasa de dropout"), ) # type: ignore weight_decay: schema_field( @@ -106,8 +135,14 @@ class LeNet5ImageClassifierSchema(BaseSchema): "Coeficiente de regularización L2 para el optimizador Adam. " "Valores típicos: 1e-4 a 1e-2." ), + pt=( + "Coeficiente de regularização L2 para o otimizador Adam. " + "Valores típicos: 1e-4 a 1e-2." + ), + ), + alias=MultilingualString( + en="Weight decay", es="Decaimiento de pesos", pt="Decaimento de pesos" ), - alias=MultilingualString(en="Weight decay", es="Decaimiento de pesos"), ) # type: ignore device: schema_field( @@ -116,8 +151,9 @@ class LeNet5ImageClassifierSchema(BaseSchema): description=MultilingualString( en="Hardware device used for training and inference (CPU/GPU).", es="Dispositivo de hardware para entrenamiento e inferencia (CPU/GPU).", + pt="Dispositivo de hardware usado para treinamento e inferência (CPU/GPU).", ), - alias=MultilingualString(en="Device", es="Dispositivo"), + alias=MultilingualString(en="Device", es="Dispositivo", pt="Dispositivo"), ) # type: ignore @@ -223,6 +259,7 @@ class LeNet5ImageClassifier(BaseModel): DISPLAY_NAME: str = MultilingualString( en="LeNet-5", es="LeNet-5", + pt="LeNet-5", ) DESCRIPTION: str = MultilingualString( en=( @@ -235,6 +272,11 @@ class LeNet5ImageClassifier(BaseModel): "conv→tanh→pool seguidos de tres capas completamente conectadas. " "Ideal para imágenes pequeñas y uso educativo." ), + pt=( + "A arquitetura CNN original (LeCun et al., 1998). Dois blocos " + "conv→tanh→pool seguidos de três camadas completamente conectadas. " + "Ideal para imagens pequenas e uso educacional." + ), ) COLOR: str = "#7B1FA2" ICON: str = "History" diff --git a/DashAI/back/models/mlp_image_classifier.py b/DashAI/back/models/mlp_image_classifier.py index e916ea76c..efe3447b6 100644 --- a/DashAI/back/models/mlp_image_classifier.py +++ b/DashAI/back/models/mlp_image_classifier.py @@ -30,8 +30,12 @@ class MLPImageClassifierSchema(BaseSchema): "El número de épocas para entrenar el modelo. Una época es una " "iteración completa sobre los datos de entrenamiento." ), + pt=( + "O número de épocas para treinar o modelo. Uma época é uma " + "iteração completa sobre os dados de treinamento." + ), ), - alias=MultilingualString(en="Epochs", es="Épocas"), + alias=MultilingualString(en="Epochs", es="Épocas", pt="Épocas"), ) # type: ignore learning_rate: schema_field( @@ -40,8 +44,13 @@ class MLPImageClassifierSchema(BaseSchema): description=MultilingualString( en="Learning rate for the Adam optimizer.", es="Tasa de aprendizaje para el optimizador Adam.", + pt="Taxa de aprendizado para o otimizador Adam.", + ), + alias=MultilingualString( + en="Learning rate", + es="Tasa de aprendizaje", + pt="Taxa de aprendizado", ), - alias=MultilingualString(en="Learning rate", es="Tasa de aprendizaje"), ) # type: ignore hidden_dims: schema_field( @@ -56,10 +65,15 @@ class MLPImageClassifierSchema(BaseSchema): "Las capas ocultas y sus dimensiones. Especifique el número " "de unidades de cada capa separadas por comas." ), + pt=( + "As camadas ocultas e suas dimensões. Especifique o número " + "de unidades de cada camada separadas por vírgulas." + ), ), alias=MultilingualString( en="Hidden layer dimensions", es="Dimensiones de capas ocultas", + pt="Dimensões das camadas ocultas", ), ) # type: ignore @@ -76,8 +90,15 @@ class MLPImageClassifierSchema(BaseSchema): "Valores más grandes aceleran el entrenamiento " "pero requieren más memoria." ), + pt=( + "Número de imagens processadas juntas em cada etapa de treinamento. " + "Valores maiores aceleram o treinamento " + "mas requerem mais memória." + ), + ), + alias=MultilingualString( + en="Batch size", es="Tamaño de lote", pt="Tamanho do lote" ), - alias=MultilingualString(en="Batch size", es="Tamaño de lote"), ) # type: ignore image_size: schema_field( @@ -95,8 +116,16 @@ class MLPImageClassifierSchema(BaseSchema): "Tamaños más grandes preservan más detalle " "pero aumentan el tiempo de entrenamiento." ), + pt=( + "As imagens são redimensionadas para este valor (em pixels) " + "tanto em largura quanto em altura antes do treinamento. " + "Tamanhos maiores preservam mais detalhes " + "mas aumentam o tempo de treinamento." + ), + ), + alias=MultilingualString( + en="Image size", es="Tamaño de imagen", pt="Tamanho da imagem" ), - alias=MultilingualString(en="Image size", es="Tamaño de imagen"), ) # type: ignore dropout_rate: schema_field( @@ -113,8 +142,15 @@ class MLPImageClassifierSchema(BaseSchema): "entrenamiento. Valores entre 0.2 y 0.5 ayudan a prevenir " "el sobreajuste. Use 0.0 para desactivarlo." ), + pt=( + "Fração de neurônios desativados aleatoriamente em cada etapa de " + "treinamento. Valores entre 0.2 e 0.5 ajudam a prevenir " + "o sobreajuste. Use 0.0 para desativar." + ), + ), + alias=MultilingualString( + en="Dropout rate", es="Tasa de dropout", pt="Taxa de dropout" ), - alias=MultilingualString(en="Dropout rate", es="Tasa de dropout"), ) # type: ignore weight_decay: schema_field( @@ -130,8 +166,15 @@ class MLPImageClassifierSchema(BaseSchema): "pesos grandes para mejorar la generalización. " "Valores típicos: 1e-4 a 1e-2." ), + pt=( + "Coeficiente de regularização L2 para o otimizador Adam. Penaliza " + "pesos grandes para melhorar a generalização. " + "Valores típicos: 1e-4 a 1e-2." + ), + ), + alias=MultilingualString( + en="Weight decay", es="Decaimiento de pesos", pt="Decaimento de pesos" ), - alias=MultilingualString(en="Weight decay", es="Decaimiento de pesos"), ) # type: ignore device: schema_field( @@ -140,8 +183,9 @@ class MLPImageClassifierSchema(BaseSchema): description=MultilingualString( en="Hardware device used for training and inference (CPU/GPU).", es="Dispositivo de hardware para entrenamiento e inferencia (CPU/GPU).", + pt="Dispositivo de hardware usado para treinamento e inferência (CPU/GPU).", ), - alias=MultilingualString(en="Device", es="Dispositivo"), + alias=MultilingualString(en="Device", es="Dispositivo", pt="Dispositivo"), ) # type: ignore @@ -238,6 +282,7 @@ class MLPImageClassifier(BaseModel): DISPLAY_NAME: str = MultilingualString( en="MLP Image Classifier", es="Clasificador de Imágenes MLP", + pt="Classificador de Imagens MLP", ) DESCRIPTION: str = MultilingualString( en=( @@ -250,6 +295,11 @@ class MLPImageClassifier(BaseModel): "que aplana los píxeles de la imagen y los pasa por capas ocultas " "completamente conectadas con activación ReLU para clasificación." ), + pt=( + "Um classificador de imagens baseado em Perceptron Multicamada (MLP) " + "que achata os pixels da imagem e os passa por camadas ocultas " + "completamente conectadas com ativação ReLU para classificação." + ), ) COLOR: str = "#E91E63" ICON: str = "ImageSearch" diff --git a/DashAI/back/models/resnet18_image_classifier.py b/DashAI/back/models/resnet18_image_classifier.py index 61fb38151..5c196262a 100644 --- a/DashAI/back/models/resnet18_image_classifier.py +++ b/DashAI/back/models/resnet18_image_classifier.py @@ -20,6 +20,7 @@ class ResNet18ImageClassifier(TorchvisionImageClassifier): DISPLAY_NAME: str = MultilingualString( en="ResNet-18", es="ResNet-18", + pt="ResNet-18", ) DESCRIPTION: str = MultilingualString( en=( @@ -32,6 +33,11 @@ class ResNet18ImageClassifier(TorchvisionImageClassifier): "conexiones de salto que permiten entrenar redes muy profundas. " "La CNN más citada en la literatura académica." ), + pt=( + "ResNet-18 (He et al., 2015). Rede residual de 18 camadas com " + "conexões de salto que permitem treinar redes muito profundas. " + "A CNN mais citada na literatura acadêmica." + ), ) COLOR: str = "#2E7D32" ICON: str = "AccountTree" diff --git a/DashAI/back/models/resnet50_image_classifier.py b/DashAI/back/models/resnet50_image_classifier.py index 62b28b0f2..33017cf52 100644 --- a/DashAI/back/models/resnet50_image_classifier.py +++ b/DashAI/back/models/resnet50_image_classifier.py @@ -21,6 +21,7 @@ class ResNet50ImageClassifier(TorchvisionImageClassifier): DISPLAY_NAME: str = MultilingualString( en="ResNet-50", es="ResNet-50", + pt="ResNet-50", ) DESCRIPTION: str = MultilingualString( en=( @@ -33,6 +34,11 @@ class ResNet50ImageClassifier(TorchvisionImageClassifier): "bottleneck y conexiones de salto. La variante CNN más citada en " "papers académicos; soporta pesos preentrenados en ImageNet." ), + pt=( + "ResNet-50 (He et al., 2015). Rede residual de 50 camadas com blocos " + "bottleneck e conexões de salto. A variante CNN mais citada em " + "artigos acadêmicos; suporta pesos pré-treinados no ImageNet." + ), ) COLOR: str = "#1B5E20" ICON: str = "AccountTree"