| """
|
| Implementação concreta (Keras/TensorFlow) do modelo treinado pelo pipeline.
|
|
|
| Arquitetura: MLP simples para dados tabulares. A configuração de
|
| camadas, dropout, otimizador, etc. vem de ``config`` (lido do
|
| ``config.json`` do treino) ou de defaults razoáveis.
|
|
|
| A classe ``TabularMLP`` herda de ``BaseModel`` (em ``docker/base_model.py``)
|
| para manter a API uniforme entre frameworks. Uma função ``create_model``
|
| fábrica é exposta no fim do arquivo para que ``main.py`` continue
|
| funcionando sem mudanças.
|
| """
|
|
|
| from __future__ import annotations
|
|
|
| import os
|
| import sys
|
| from typing import Any, Dict, List, Optional, Tuple
|
|
|
| import numpy as np
|
| import tensorflow as tf
|
| from tensorflow import keras
|
| from tensorflow.keras import layers
|
|
|
|
|
| sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| from base_model import BaseModel
|
| from utils import emit_progress
|
|
|
|
|
|
|
|
|
|
|
| class TabularMLP(BaseModel):
|
| """MLP tabular em Keras/TensorFlow, plugável no pipeline."""
|
|
|
| def build_model(self) -> keras.Model:
|
| hp = self.config.get("hyperparameters", {})
|
| hidden_dims = self.config.get("hidden_dims") or hp.get("hidden_dims") or [
|
| self.config.get("hidden_dim1", 32),
|
| self.config.get("hidden_dim2", 16),
|
| ]
|
| dropout = float(hp.get("dropout", self.config.get("dropout", 0.0)))
|
|
|
|
|
| model = keras.Sequential()
|
| model.add(layers.InputLayer(input_shape=(self.input_size,)))
|
|
|
|
|
| for h in hidden_dims:
|
| model.add(layers.Dense(h, activation="relu"))
|
| if dropout and dropout > 0:
|
| model.add(layers.Dropout(dropout))
|
|
|
|
|
| model.add(layers.Dense(self.num_classes, activation="softmax"))
|
|
|
| self._hidden_dims = list(hidden_dims)
|
| self._dropout = dropout
|
| return model
|
|
|
|
|
| def train_model(
|
| self,
|
| train_loader: Any,
|
| val_loader: Optional[Any],
|
| epochs: int,
|
| lr: float,
|
| **kwargs: Any,
|
| ) -> Dict[str, list]:
|
| hp = self.config.get("hyperparameters", {})
|
| epochs = int(hp.get("epochs", epochs))
|
| lr = float(hp.get("learning_rate", lr))
|
| weight_decay = float(hp.get("weight_decay", 0.0))
|
| optimizer_name = str(hp.get("optimizer", "adam")).lower()
|
|
|
|
|
| if isinstance(train_loader, tuple) and len(train_loader) == 2:
|
| X_train, y_train = train_loader
|
| else:
|
| raise ValueError(f"train_loader deve ser tupla (X, y), recebeu {type(train_loader)}")
|
|
|
| if isinstance(val_loader, tuple) and len(val_loader) == 2:
|
| X_val, y_val = val_loader
|
| else:
|
| raise ValueError(f"val_loader deve ser tupla (X, y), recebeu {type(val_loader)}")
|
|
|
|
|
| optimizer = self._build_optimizer(optimizer_name, lr, weight_decay)
|
| self.model.compile(
|
| optimizer=optimizer,
|
| loss="sparse_categorical_crossentropy",
|
| metrics=["accuracy"],
|
| )
|
|
|
| history = {
|
| "train_loss": [],
|
| "train_acc": [],
|
| "val_loss": [],
|
| "val_acc": [],
|
| }
|
|
|
| n_batches = max((len(X_train) + 31) // 32, 1)
|
| total_train_steps = max(epochs * n_batches, 1)
|
| batch_size = int(hp.get("batch_size", 32))
|
| best_val_acc = -float("inf")
|
| best_epoch = -1
|
|
|
| for epoch in range(1, epochs + 1):
|
|
|
| hist = self.model.fit(
|
| X_train,
|
| y_train,
|
| batch_size=batch_size,
|
| epochs=1,
|
| validation_data=(X_val, y_val),
|
| verbose=0,
|
| )
|
|
|
|
|
| train_loss = float(hist.history["loss"][0])
|
| train_acc = float(hist.history["accuracy"][0])
|
| val_loss = float(hist.history["val_loss"][0])
|
| val_acc = float(hist.history["val_accuracy"][0])
|
|
|
| history["train_loss"].append(round(train_loss, 6))
|
| history["train_acc"].append(round(train_acc, 6))
|
| history["val_loss"].append(round(val_loss, 6))
|
| history["val_acc"].append(round(val_acc, 6))
|
|
|
|
|
| for batch_idx in range(1, n_batches + 1):
|
| global_step = (epoch - 1) * n_batches + batch_idx
|
| inner_pct = int(global_step * 100 / total_train_steps)
|
| emit_progress(inner_pct, 1)
|
|
|
|
|
| if val_acc > best_val_acc:
|
| best_val_acc = val_acc
|
| best_epoch = epoch
|
|
|
| print(
|
| f"Epoch [{epoch:>3}/{epochs}] "
|
| f"train_loss={train_loss:.4f} train_acc={train_acc:.4f} "
|
| f"val_loss={val_loss:.4f} val_acc={val_acc:.4f}"
|
| )
|
|
|
| history["best_epoch"] = best_epoch
|
| history["best_val_acc"] = round(best_val_acc, 6) if best_epoch > 0 else None
|
| self.history = history
|
| return history
|
|
|
|
|
| def evaluate(self, data_loader: Any) -> Dict[str, float]:
|
| if isinstance(data_loader, tuple) and len(data_loader) == 2:
|
| X_val, y_val = data_loader
|
| else:
|
| X_val, y_val = data_loader[2], data_loader[3]
|
|
|
| loss, accuracy = self.model.evaluate(X_val, y_val, verbose=0)
|
| return {
|
| "loss": float(loss),
|
| "accuracy": float(accuracy),
|
| "n": len(X_val),
|
| }
|
|
|
|
|
| def predict(self, inputs: Any) -> np.ndarray:
|
| if isinstance(inputs, tuple):
|
|
|
| X = inputs[2].astype(np.float32)
|
| elif isinstance(inputs, np.ndarray):
|
| X = inputs.astype(np.float32)
|
| else:
|
| X = np.array(inputs, dtype=np.float32)
|
|
|
| if X.ndim == 1:
|
| X = X.reshape(1, -1)
|
|
|
|
|
| logits = self.model.predict(X, verbose=0)
|
| return np.argmax(logits, axis=1)
|
|
|
|
|
| def save_model(self, filename: str) -> str:
|
| path = os.path.join(self.model_dir, filename)
|
|
|
| if path.endswith(".pt"):
|
| path = path[:-3] + ".keras"
|
|
|
| self.model.save(path)
|
| print(f"✓ Model saved to {path}")
|
| return path
|
|
|
| def load_model(self, filename: str) -> None:
|
| path = filename if os.path.isabs(filename) else os.path.join(
|
| self.model_dir, filename
|
| )
|
|
|
| if path.endswith(".pt"):
|
| path = path[:-3] + ".keras"
|
|
|
| self.model = keras.models.load_model(path)
|
|
|
|
|
| def _build_optimizer(
|
| self, name: str, lr: float, weight_decay: float
|
| ) -> keras.optimizers.Optimizer:
|
| if name == "sgd":
|
| return keras.optimizers.SGD(learning_rate=lr, weight_decay=weight_decay)
|
| if name == "rmsprop":
|
| return keras.optimizers.RMSprop(learning_rate=lr, weight_decay=weight_decay)
|
|
|
| return keras.optimizers.Adam(learning_rate=lr, weight_decay=weight_decay)
|
|
|
|
|
|
|
|
|
|
|
| def create_model(
|
| input_size: int,
|
| num_classes: int,
|
| project_name: str,
|
| base_path: str,
|
| config: Optional[Dict[str, Any]] = None,
|
| ) -> TabularMLP:
|
| """Cria um ``TabularMLP`` pronto pra uso pelo main.py."""
|
| return TabularMLP(
|
| input_size=input_size,
|
| num_classes=num_classes,
|
| project_name=project_name,
|
| base_path=base_path,
|
| framework="keras",
|
| config=config or {},
|
| )
|
|
|