Give me suggestions for all new ideas to add to this code, maybe gamifiying it ? making it more useful ? every suggestion is valid: training.py: import json
import joblib
import random

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split

# --- Set Random Seed for Reproducibility ---
def set_seed(seed):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False

set_seed(42)

# --- Load Data from CSV ---
def load_data(csv_file):
    return pd.read_csv(csv_file)

# --- Data Preprocessing ---
def preprocess_data(df):
    numeric_features = [
        "Num Shaders",
        "Clock Speed (GHz)",
        "ROPs",
        "TMUs",
        "L2 Cache (MB)",
        "VRAM (GB)",
        "Bandwidth (GB/s)",
        "TFlops",
    ]
    categorical_features = ["Architecture"]
    ARCHITECTURE_MAPPING = {
        "PASCAL": 0,
        "TURING": 1,
        "AMPERE": 2,
        "ADA": 3,
        "GCN5": 4,
        "RDNA": 5,
        "RDNA2": 6,
        "RDNA3": 7,
    }

    df["Architecture"] = df["Architecture"].map(ARCHITECTURE_MAPPING).astype(int)
    target = "Relative Performance"

    # Standardize numerical features
    scaler = StandardScaler()
    X_num = scaler.fit_transform(df[numeric_features])
    X_cat = df[categorical_features].values

    y = df[target].values

    # Convert to tensors
    X_num = torch.tensor(X_num, dtype=torch.float32)
    X_cat = torch.tensor(X_cat, dtype=torch.long).squeeze()
    y = torch.tensor(y, dtype=torch.float32).unsqueeze(1)  # Unsqueeze for single target

    return X_num, X_cat, y, scaler

# --- Custom Dataset ---
class GPUDataset(Dataset):
    def __init__(self, X_num, X_cat, y):
        self.X_num = X_num
        self.X_cat = X_cat
        self.y = y

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

    def __getitem__(self, idx):
        return self.X_num[idx], self.X_cat[idx], self.y[idx]

# --- Model Architecture ---
class GPUPerformanceModel(nn.Module):
    def __init__(self, input_size, num_architectures):
        super(GPUPerformanceModel, self).__init__()
        self.arch_embedding = nn.Embedding(num_architectures, embedding_dim=4)
        self.model = nn.Sequential(
            nn.Linear(input_size + 4, 128),
            nn.ReLU(),
            nn.Dropout(0.125),
            nn.Linear(128, 64),
            nn.ReLU(),
            nn.Dropout(0.125),
            nn.Linear(64, 1)
        )

    def forward(self, x_num, x_cat):
        x_cat = self.arch_embedding(x_cat)
        x = torch.cat([x_num, x_cat], dim=1)
        return self.model(x)

# --- Early Stopping ---
class EarlyStopping:
    def __init__(self, patience=50, min_delta=0):
        self.patience = patience
        self.min_delta = min_delta
        self.counter = 0
        self.best_loss = None
        self.early_stop = False

    def call(self, val_loss):
        if self.best_loss is None:
            self.best_loss = val_loss
        elif val_loss > self.best_loss - self.min_delta:
            self.counter += 1
            if self.counter >= self.patience:
                self.early_stop = True
        else:
            self.best_loss = val_loss
            self.counter = 0

# --- Model Training ---
def train_model(model, train_loader, val_loader, epochs, learning_rate, device):
    criterion = nn.MSELoss()
    mae_loss = nn.L1Loss()
    optimizer = optim.Adam(model.parameters(), lr=learning_rate)
    scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=20)

    early_stopping = EarlyStopping(patience=50)

    train_losses = []
    val_losses = []
    train_mae = []
    val_mae = []
    train_rmse = []
    val_rmse = []

    for epoch in range(epochs):
        # Training
        model.train()
        epoch_train_loss = 0
        epoch_train_mae = 0
        epoch_train_rmse = 0
        for X_num_batch, X_cat_batch, y_batch in train_loader:
            X_num_batch = X_num_batch.to(device)
            X_cat_batch = X_cat_batch.to(device)
            y_batch = y_batch.to(device)

            optimizer.zero_grad()
            outputs = model(X_num_batch, X_cat_batch)
            loss = criterion(outputs, y_batch)
            loss.backward()
            optimizer.step()

            epoch_train_loss += loss.item() * X_num_batch.size(0)
            epoch_train_mae += mae_loss(outputs, y_batch).item() * X_num_batch.size(0)
            epoch_train_rmse += torch.sqrt(criterion(outputs, y_batch)).item() * X_num_batch.size(0)

        train_losses.append(epoch_train_loss / len(train_loader.dataset))
        train_mae.append(epoch_train_mae / len(train_loader.dataset))
        train_rmse.append(epoch_train_rmse / len(train_loader.dataset))

        # Validation
        model.eval()
        epoch_val_loss = 0
        epoch_val_mae = 0
        epoch_val_rmse = 0
        with torch.no_grad():
            for X_num_batch, X_cat_batch, y_batch in val_loader:
                X_num_batch = X_num_batch.to(device)
                X_cat_batch = X_cat_batch.to(device)
                y_batch = y_batch.to(device)

                outputs = model(X_num_batch, X_cat_batch)
                loss = criterion(outputs, y_batch)

                epoch_val_loss += loss.item() * X_num_batch.size(0)
                epoch_val_mae += mae_loss(outputs, y_batch).item() * X_num_batch.size(0)
                epoch_val_rmse += torch.sqrt(criterion(outputs, y_batch)).item() * X_num_batch.size(0)

        val_losses.append(epoch_val_loss / len(val_loader.dataset))
        val_mae.append(epoch_val_mae / len(val_loader.dataset))
        val_rmse.append(epoch_val_rmse / len(val_loader.dataset))

        scheduler.step(val_losses[-1])

        # Print epoch statistics
        if (epoch + 1) % 50 == 0:
            print(
                f"Epoch [{epoch+1}/{epochs}], "
                f"Train Loss: {train_losses[-1]:.4f}, Val Loss: {val_losses[-1]:.4f}, "
                f"Train MAE: {train_mae[-1]:.4f}, Val MAE: {val_mae[-1]:.4f}, "
                f"Train RMSE: {train_rmse[-1]:.4f}, Val RMSE: {val_rmse[-1]:.4f}"
            )

        # Early stopping
        #early_stopping(val_losses[-1])
        #if early_stopping.early_stop:
        #    print("Early stopping")
        #    break

    return train_losses, val_losses, train_mae, val_mae, train_rmse, val_rmse

# --- Model Evaluation ---
def evaluate_model(model, data_loader, device):
    model.eval()
    criterion = nn.MSELoss()
    mae_loss = nn.L1Loss()

    total_loss = 0
    total_mae = 0
    total_rmse = 0
    with torch.no_grad():
        for X_num_batch, X_cat_batch, y_batch in data_loader:
            X_num_batch = X_num_batch.to(device)
            X_cat_batch = X_cat_batch.to(device)
            y_batch = y_batch.to(device)

            outputs = model(X_num_batch, X_cat_batch)
            loss = criterion(outputs, y_batch)
            total_loss += loss.item() * X_num_batch.size(0)
            total_mae += mae_loss(outputs, y_batch).item() * X_num_batch.size(0)
            total_rmse += torch.sqrt(criterion(outputs, y_batch)).item() * X_num_batch.size(0)

    avg_loss = total_loss / len(data_loader.dataset)
    avg_mae = total_mae / len(data_loader.dataset)
    avg_rmse = total_rmse / len(data_loader.dataset)
    return avg_loss, avg_mae, avg_rmse

# --- Main Function ---
def main():
    # Device configuration
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    # Load data
    df = load_data("gpu_data.csv")

    # Preprocess data
    X_num, X_cat, y, scaler = preprocess_data(df)

    # Split data into training and validation sets
    X_num_train, X_num_val, X_cat_train, X_cat_val, y_train, y_val = train_test_split(
        X_num, X_cat, y, test_size=0.1, random_state=42
    )

    # Create datasets and loaders
    train_dataset = GPUDataset(X_num_train, X_cat_train, y_train)
    val_dataset = GPUDataset(X_num_val, X_cat_val, y_val)
    batch_size = 16
    train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
    val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False)

    # Model Parameters
    input_size = X_num.shape[1]
    num_architectures = df['Architecture'].nunique()
    # Training Parameters
    epochs = 1000
    learning_rate = 0.0011

    # Initialize model
    model = GPUPerformanceModel(
        input_size=input_size,
        num_architectures=num_architectures
    ).to(device)

    # Train model
    train_losses, val_losses, train_mae, val_mae, train_rmse, val_rmse = train_model(
        model, train_loader, val_loader, epochs=epochs, learning_rate=learning_rate, device=device
    )

    # Evaluate model on validation set
    val_dataset_full = GPUDataset(X_num_val, X_cat_val, y_val)
    val_loader_full = DataLoader(val_dataset_full, batch_size=len(val_dataset_full), shuffle=False)
    loss, mae, rmse = evaluate_model(model, val_loader_full, device)
    print(f"Validation Loss: {loss:.4f}, Validation MAE: {mae:.4f}, Validation RMSE: {rmse:.4f}")

    # Save model and scaler
    model_name = "GPU-PERF-Improved"
    torch.save(model.state_dict(), f"{model_name}.pth")
    joblib.dump(scaler, f"{model_name}_scaler.pkl")

    # Save model configuration
    model_config = {
        "model_params": {
            "input_size": input_size,
            "num_architectures": num_architectures,
            "embedding_dim": 4,
            "layers": [128, 64, 1],
            "dropout": 0.125,
        },
        "training_params": {
            "epochs": epochs,
            "optimizer": "Adam",
            "learning_rate": learning_rate,
            "loss_function": "MSELoss",
            "batch_size": batch_size,
            "early_stopping_patience": 50,
            "scheduler": "ReduceLROnPlateau",
        },
        "validation_loss": loss,
        "validation_mae": mae,
        "validation_rmse": rmse,
    }
    with open(f"{model_name}.json", "w") as f:
        json.dump(model_config, f)

    # Plot training and validation loss curves
    plt.figure(figsize=(10, 5))
    plt.plot(train_losses, label="Training Loss")
    plt.plot(val_losses, label="Validation Loss")
    plt.xlabel("Epochs")
    plt.ylabel("Loss")
    plt.legend()
    plt.title("Training and Validation Loss Curves")
    plt.savefig(f"{model_name}_loss_curve.png")
    plt.show()

    # Plot training and validation MAE curves
    plt.figure(figsize=(10, 5))
    plt.plot(train_mae, label="Training MAE")
    plt.plot(val_mae, label="Validation MAE")
    plt.xlabel("Epochs")
    plt.ylabel("MAE")
    plt.legend()
    plt.title("Training and Validation MAE Curves")
    plt.savefig(f"{model_name}_mae_curve.png")
    plt.show()

    # Plot predicted vs actual performance
    model.eval()
    with torch.no_grad():
        X_num_val = X_num_val.to(device)
        X_cat_val = X_cat_val.to(device)
        y_val_pred = model(X_num_val, X_cat_val).cpu().numpy()
    y_val_true = y_val.cpu().numpy()

    plt.figure(figsize=(10, 5))
    plt.scatter(y_val_true, y_val_pred, color="blue", label="Predictions", marker='x')
    plt.xlabel("Actual Performance")
    plt.ylabel("Predicted Performance")
    plt.legend()
    plt.title("Predicted vs Actual GPU Performance")
    plt.savefig(f"{model_name}_pred_vs_actual.png")
    plt.show()

if __name__ == "__main__":
    main()   ; gui.py : import sys
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from PyQt6.QtWidgets import (
    QApplication,
    QWidget,
    QLabel,
    QSlider,
    QLineEdit,
    QPushButton,
    QVBoxLayout,
    QHBoxLayout,
    QMessageBox,
    QFileDialog,
    QComboBox,
    QGroupBox,
    QProgressBar,
    QButtonGroup,
    QRadioButton,
    QListWidget,
    QScrollArea,
)
from PyQt6.QtGui import (
    QFont,
    QPalette,
    QColor,
    QDoubleValidator,
    QIntValidator,
)
from PyQt6.QtCore import Qt
import joblib
import json
import os
import torch.optim as optim

# Define architecture mapping here for consistency
ARCHITECTURE_MAPPING = {
    "PASCAL": 0,
    "TURING": 1,
    "AMPERE": 2,
    "ADA": 3,
    "GCN5": 4,
    "RDNA": 5,
    "RDNA2": 6,
    "RDNA3": 7,
}

NUMERIC_FEATURES = [
    "Num Shaders",
    "Clock Speed (GHz)",
    "ROPs",
    "TMUs",
    "L2 Cache (MB)",
    "VRAM (GB)",
    "Bandwidth (GB/s)",
    "TFlops",
]

CATEGORICAL_FEATURES = ["Architecture"]

DEFAULT_MODEL_PATH = "GPU-PERF-Improved.pth"
DEFAULT_SCALER_PATH = "GPU-PERF-Improved_scaler.pkl"
GPU_DATA_PATH = "gpu_data.csv"  # Path to your GPU data CSV

# --- Model Architecture ---
class GPUPerformanceModel(nn.Module):
    def __init__(self, input_size, num_architectures):
        super(GPUPerformanceModel, self).__init__()
        self.arch_embedding = nn.Embedding(num_architectures, embedding_dim=4)
        self.model = nn.Sequential(
            nn.Linear(input_size + 4, 128),
            nn.ReLU(),
            nn.Dropout(0.125),
            nn.Linear(128, 64),
            nn.ReLU(),
            nn.Dropout(0.125),
            nn.Linear(64, 1)
        )

    def forward(self, x_num, x_cat):
        x_cat = self.arch_embedding(x_cat)
        x = torch.cat([x_num, x_cat], dim=1)
        return self.model(x)

class PredictionApp(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("GPU Performance Predictor")
        self.setFixedSize(900, 900)  # Adjusted size for two columns

        self.model = None
        self.scaler = None
        self.model_config = None  # Store model config
        self.target_performance = 1.0  # Initial target performance

        # --- Input Fields with Validation ---
        self.create_input_fields()

        # --- Architecture Dropdown ---
        self.architecture_label = QLabel("Architecture:")
        self.architecture_combo = QComboBox()
        self.architecture_combo.addItems(list(ARCHITECTURE_MAPPING.keys()))

        # --- Buttons ---
        self.load_model_button = QPushButton("Load Model")
        self.load_model_button.clicked.connect(self.load_model)

        self.load_scaler_button = QPushButton("Load Scaler")
        self.load_scaler_button.clicked.connect(self.load_scaler)

        self.predict_button = QPushButton("Predict Performance")
        self.predict_button.clicked.connect(self.predict_performance)

        # --- Performance Slider ---
        self.performance_slider = QSlider(Qt.Orientation.Horizontal, self)
        self.performance_slider.setMinimum(7)  # Representing 0.7
        self.performance_slider.setMaximum(70)  # Representing 7.0
        self.performance_slider.setValue(int(self.target_performance * 10))
        self.performance_slider.valueChanged.connect(self.update_target_performance)
        self.target_performance_label = QLabel(
            f"Target Performance: {self.target_performance:.2f}"
        )

        # --- Output ---
        self.output_label = QLabel("Predicted Relative Performance:")
        self.output_label.setFont(QFont("Arial", 14))
        self.output_label.setAlignment(Qt.AlignmentFlag.AlignCenter)

        self.performance_bar = QProgressBar(self)
        self.performance_bar.setGeometry(50, 450, 450, 20)
        self.performance_bar.setTextVisible(False)  # Hide the percentage text

        # --- Mode Selection Radio Buttons ---
        self.mode_group = QButtonGroup(self)
        self.forward_mode_radio = QRadioButton("Forward Prediction", self)
        self.reverse_mode_radio = QRadioButton("Reverse Configuration (Alpha)", self)
        self.mode_group.addButton(self.forward_mode_radio)
        self.mode_group.addButton(self.reverse_mode_radio)
        self.forward_mode_radio.setChecked(True)  # Start in Forward Mode
        self.forward_mode_radio.toggled.connect(self.update_mode)
        self.reverse_mode_radio.toggled.connect(self.update_mode)

        # --- Find Config Button ---
        self.find_config_button = QPushButton("Find GPU Config")
        self.find_config_button.clicked.connect(self.find_gpu_config)

        # --- GPU Selection Drawer ---
        self.gpu_list_widget = QListWidget()
        self.gpu_list_widget.itemClicked.connect(self.gpu_selection_changed)

        # --- Layout ---
        main_layout = QHBoxLayout()  # Main layout is now horizontal

        # --- Left Column Layout ---
        left_column_layout = QVBoxLayout()

        # Model Loading Group
        model_group = QGroupBox("Model Loading")
        model_layout = QVBoxLayout()  # Vertical layout for model buttons
        model_layout.addWidget(self.load_model_button)
        model_layout.addWidget(self.load_scaler_button)
        model_group.setLayout(model_layout)
        left_column_layout.addWidget(model_group)

        # Prediction Mode
        mode_group = QGroupBox("Prediction Mode")
        mode_layout = QVBoxLayout()  # Vertical layout for radio buttons
        mode_layout.addWidget(self.forward_mode_radio)
        mode_layout.addWidget(self.reverse_mode_radio)
        mode_group.setLayout(mode_layout)
        left_column_layout.addWidget(mode_group)

        # Performance Slider Group
        slider_group = QGroupBox("Target Performance")
        slider_layout = QVBoxLayout()
        slider_layout.addWidget(self.target_performance_label)
        slider_layout.addWidget(self.performance_slider)
        slider_group.setLayout(slider_layout)
        left_column_layout.addWidget(slider_group)

        # GPU Selection Drawer
        gpu_drawer = QGroupBox("Select Existing GPU")
        gpu_drawer_layout = QVBoxLayout()
        self.populate_gpu_list()  # Populate the list with GPU names
        scroll_area = QScrollArea(widgetResizable=True)
        scroll_area.setWidget(self.gpu_list_widget)
        scroll_area.setWidgetResizable(True)
        gpu_drawer_layout.addWidget(scroll_area)
        gpu_drawer.setLayout(gpu_drawer_layout)
        left_column_layout.addWidget(gpu_drawer)

        left_column_layout.addWidget(self.find_config_button)
        left_column_layout.addStretch(1)  # Push elements to the top

        # --- Right Column Layout ---
        right_column_layout = QVBoxLayout()

        # Input Fields Group
        input_group = QGroupBox("GPU Specifications")
        input_layout = QVBoxLayout()

        # Performance Characteristics Group
        perf_group = QGroupBox("Performance Characteristics")
        perf_layout = QVBoxLayout()
        perf_layout.addWidget(self.num_shaders_edit)
        perf_layout.addWidget(self.clock_speed_edit)
        perf_layout.addWidget(self.rops_edit)
        perf_layout.addWidget(self.tmus_edit)
        perf_layout.addWidget(self.tflops_edit)
        perf_group.setLayout(perf_layout)
        input_layout.addWidget(perf_group)

        # Memory Characteristics Group
        memory_group = QGroupBox("Memory Characteristics")
        memory_layout = QVBoxLayout()
        memory_layout.addWidget(self.l2_cache_edit)
        memory_layout.addWidget(self.vram_edit)
        memory_layout.addWidget(self.bandwidth_edit)
        memory_group.setLayout(memory_layout)
        input_layout.addWidget(memory_group)

        input_layout.addWidget(self.architecture_label)
        input_layout.addWidget(self.architecture_combo)
        input_group.setLayout(input_layout)
        right_column_layout.addWidget(input_group)

        right_column_layout.addWidget(self.predict_button)
        right_column_layout.addWidget(self.output_label)
        right_column_layout.addWidget(self.performance_bar)

        right_column_layout.addStretch(1)

        # --- Combine Columns ---
        main_layout.addLayout(left_column_layout)
        main_layout.addLayout(right_column_layout)

        self.setLayout(main_layout)
        self.apply_dark_mode()

        # --- Load Default Model ---
        self.load_default_model()

        # --- Initially set the mode ---
        self.update_mode()

    def find_gpu_config(self):
        try:
            target_performance = self.target_performance
            architecture_str = self.architecture_combo.currentText()
            fixed_arch = ARCHITECTURE_MAPPING[architecture_str]

            # --- Optimization to find matching config ---
            # Randomly initialize input parameters within plausible ranges
            input_params = torch.tensor([
                np.random.uniform(256, 24576),   # Num Shaders
                np.random.uniform(1.0, 3.0),     # Clock Speed (GHz)
                np.random.uniform(16, 256),      # ROPs
                np.random.uniform(24, 640),      # TMUs
                np.random.uniform(0.5, 128.0),   # L2 Cache (MB)
                np.random.uniform(1.0, 48.0),    # VRAM (GB)
                np.random.uniform(24, 1800),     # Bandwidth (GB/s)
                np.random.uniform(1.0, 101.0),   # TFlops
            ], requires_grad=True)

            learning_rate = 0.001
            optimizer = optim.Adam([input_params], lr=learning_rate)

            for _ in range(2000):
                optimizer.zero_grad()
                # Prepare inputs
                x_num = input_params.unsqueeze(0)
                x_num_scaled = torch.tensor(self.scaler.transform(x_num.detach().numpy()), dtype=torch.float32)
                x_cat = torch.tensor([fixed_arch], dtype=torch.long)
                prediction = self.model(x_num_scaled, x_cat)
                loss = (prediction - target_performance) ** 2
                loss.backward()
                optimizer.step()

                # Apply constraints
                with torch.no_grad():
                    input_params.data[0] = torch.clamp(input_params.data[0], 256, 24576)    # Num Shaders
                    input_params.data[1] = torch.clamp(input_params.data[1], 1.0, 3.0)      # Clock Speed (GHz)
                    input_params.data[2] = torch.clamp(input_params.data[2], 16, 256)       # ROPs
                    input_params.data[3] = torch.clamp(input_params.data[3], 24, 640)       # TMUs
                    input_params.data[4] = torch.clamp(input_params.data[4], 0.5, 128.0)    # L2 Cache (MB)
                    input_params.data[5] = torch.clamp(input_params.data[5], 1.0, 48.0)     # VRAM (GB)
                    input_params.data[6] = torch.clamp(input_params.data[6], 24, 1800)      # Bandwidth (GB/s)
                    input_params.data[7] = torch.clamp(input_params.data[7], 1.0, 101.0)    # TFlops

            optimized_params = input_params.detach().numpy()

            # Update input fields with found configuration
            self.update_input_fields(optimized_params)

        except Exception as e:
            QMessageBox.critical(
                self, "Error", f"Error finding GPU config: {str(e)}"
            )

    def update_input_fields(self, values):
        fields = [
            self.num_shaders_edit,
            self.clock_speed_edit,
            self.rops_edit,
            self.tmus_edit,
            self.l2_cache_edit,
            self.vram_edit,
            self.bandwidth_edit,
            self.tflops_edit,
        ]

        for field, value in zip(fields, values):
            field.layout().itemAt(1).widget().setText(str(round(value, 2)))

    def create_input_fields(self):
        self.num_shaders_edit = self.create_validated_field(
            "Num Shaders:", validator=QIntValidator(0, 100000)
        )
        self.clock_speed_edit = self.create_validated_field(
            "Clock Speed (GHz):", validator=QDoubleValidator(0.0, 5.0, 4)
        )
        self.rops_edit = self.create_validated_field(
            "ROPs:", validator=QIntValidator(0, 256)
        )
        self.tmus_edit = self.create_validated_field(
            "TMUs:", validator=QIntValidator(0, 640)
        )
        self.l2_cache_edit = self.create_validated_field(
            "L2 Cache (MB):", validator=QDoubleValidator(0.0, 128.0, 3)
        )
        self.vram_edit = self.create_validated_field(
            "VRAM (GB):", validator=QDoubleValidator(0.0, 128.0, 2)
        )
        self.bandwidth_edit = self.create_validated_field(
            "Bandwidth (GB/s):", validator=QDoubleValidator(0.0, 2000.0, 2)
        )
        self.tflops_edit = self.create_validated_field(
            "TFlops:", validator=QDoubleValidator(0.0, 100.0, 4)
        )

    def create_validated_field(self, label_text, validator):
        layout = QVBoxLayout()  # Vertical layout

        label = QLabel(label_text)
        label.setToolTip(self.get_tooltip(label_text))
        layout.addWidget(label)  # Add the label above the field

        edit = QLineEdit()
        edit.setValidator(validator)
        edit.textChanged.connect(lambda: self.validate_input(edit))  # Validation feedback
        layout.addWidget(edit)

        widget = QWidget()
        widget.setLayout(layout)
        return widget

    def get_tooltip(self, field_name):
        tooltips = {
            "Num Shaders:": "The number of shader cores in the GPU.",
            "Clock Speed (GHz):": "The clock speed of the GPU's core, in GHz.",
            "ROPs:": "Render Output Units: Responsible for writing pixels to memory.",
            "TMUs:": "Texture Mapping Units: Handle texture operations.",
            "TFlops:": "Teraflops: A measure of theoretical computing performance.",
            "L2 Cache (MB):": "The size of the GPU's L2 cache, in MB.",
            "VRAM (GB):": "The amount of video memory on the GPU, in GB.",
            "Bandwidth (GB/s):": "The speed of data transfer between the GPU and memory, in GB/s."
        }
        return tooltips.get(field_name, "")

    def apply_dark_mode(self):
        palette = QPalette()
        palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53))
        palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25))
        palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53))
        palette.setColor(QPalette.ColorRole.ToolTipBase, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53))
        palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white)
        palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red)
        palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218))
        palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218))
        palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.black)
        self.setPalette(palette)

    def update_mode(self):
        if self.forward_mode_radio.isChecked():
            self.set_forward_mode()
        else:
            self.set_reverse_mode()

    def update_target_performance(self, value):
        self.target_performance = value / 10.0
        self.target_performance_label.setText(
            f"Target Performance: {self.target_performance:.2f}"
        )

    def set_forward_mode(self):
        # Enable input fields, "Predict" button, disable others
        self.num_shaders_edit.setEnabled(True)
        self.clock_speed_edit.setEnabled(True)
        self.rops_edit.setEnabled(True)
        self.tmus_edit.setEnabled(True)
        self.tflops_edit.setEnabled(True)
        self.l2_cache_edit.setEnabled(True)
        self.vram_edit.setEnabled(True)
        self.bandwidth_edit.setEnabled(True)
        self.architecture_combo.setEnabled(True)
        self.predict_button.setEnabled(True)

        self.performance_slider.setEnabled(False)
        self.find_config_button.setEnabled(False)

    def set_reverse_mode(self):
        # Disable most input fields, "Find Config" button, enable others
        self.num_shaders_edit.setEnabled(False)
        self.clock_speed_edit.setEnabled(False)
        self.rops_edit.setEnabled(False)
        self.tmus_edit.setEnabled(False)
        self.tflops_edit.setEnabled(False)
        self.l2_cache_edit.setEnabled(False)
        self.vram_edit.setEnabled(False)
        self.bandwidth_edit.setEnabled(False)
        self.architecture_combo.setEnabled(True)
        self.predict_button.setEnabled(False)

        self.performance_slider.setEnabled(True)
        self.find_config_button.setEnabled(True)

    def validate_input(self, edit):
        if not edit.hasAcceptableInput():
            edit.setStyleSheet("background-color: pink;")
        else:
            edit.setStyleSheet("")

    def load_model(self):
        options = QFileDialog.Option.ReadOnly
        file_dialog = QFileDialog(self, "Load Model", "", "PyTorch Models (.pth)", options=options)
        if file_dialog.exec():
            model_path = file_dialog.selectedFiles()[0]
            self.load_model_from_path(model_path)

    def load_scaler(self):
        options = QFileDialog.Option.ReadOnly
        file_dialog = QFileDialog(self, "Load Scaler", "", "Scaler Files (.pkl)", options=options)
        if file_dialog.exec():
            scaler_path = file_dialog.selectedFiles()[0]
            try:
                self.scaler = joblib.load(scaler_path)
                QMessageBox.information(self, "Success", "Scaler loaded successfully!")
            except Exception as e:
                QMessageBox.critical(self, "Error", f"Error loading scaler: {str(e)}")

    def load_model_from_path(self, model_path):
        try:
            config_path = model_path.replace(".pth", ".json")
            with open(config_path, "r") as f:
                self.model_config = json.load(f)

            input_size = self.model_config["model_params"]["input_size"]
            num_architectures = self.model_config["model_params"]["num_architectures"]

            self.model = GPUPerformanceModel(input_size, num_architectures)
            self.model.load_state_dict(torch.load(model_path))
            self.model.eval()
            QMessageBox.information(self, "Success", "Model and config loaded successfully!")
        except Exception as e:
            QMessageBox.critical(self, "Error", f"Error loading model or config: {str(e)}")

    def load_default_model(self):
        if os.path.exists(DEFAULT_MODEL_PATH) and os.path.exists(DEFAULT_SCALER_PATH):
            self.load_model_from_path(DEFAULT_MODEL_PATH)
            self.scaler = joblib.load(DEFAULT_SCALER_PATH)

    def predict_performance(self):
        try:
            # Check if model and scaler are loaded
            if self.model is None:
                QMessageBox.warning(self, "Warning", "Please load a model file.")
                return
            if self.scaler is None:
                QMessageBox.warning(self, "Warning", "Please load a scaler file.")
                return

            # Get input values with validation
            num_shaders = int(self.num_shaders_edit.layout().itemAt(1).widget().text())
            clock_speed = float(self.clock_speed_edit.layout().itemAt(1).widget().text())
            rops = int(self.rops_edit.layout().itemAt(1).widget().text())
            tmus = int(self.tmus_edit.layout().itemAt(1).widget().text())
            l2_cache = float(self.l2_cache_edit.layout().itemAt(1).widget().text())
            vram = float(self.vram_edit.layout().itemAt(1).widget().text())
            bandwidth = float(self.bandwidth_edit.layout().itemAt(1).widget().text())
            tflops = float(self.tflops_edit.layout().itemAt(1).widget().text())
            architecture = ARCHITECTURE_MAPPING[self.architecture_combo.currentText()]

            # Prepare input data
            input_data_num = np.array([[num_shaders, clock_speed, rops, tmus, l2_cache, vram, bandwidth, tflops]])
            input_data_num_scaled = self.scaler.transform(input_data_num)
            input_data_num_tensor = torch.tensor(input_data_num_scaled, dtype=torch.float32)

            input_data_cat_tensor = torch.tensor([architecture], dtype=torch.long)

            # Make prediction
            with torch.no_grad():
                prediction = self.model(input_data_num_tensor, input_data_cat_tensor).item()

            # Display the prediction
            self.output_label.setText(
                f"Predicted Relative Performance: {prediction:.2f} (Higher is better)"
            )

            # Update the progress bar
            performance_percentage = int(min(max(prediction / 7.0 * 100, 0), 100))
            self.performance_bar.setValue(performance_percentage)

        except ValueError as e:
            QMessageBox.critical(self, "Error", f"Invalid input: {str(e)}")
        except Exception as e:
            QMessageBox.critical(self, "Error", f"An error occurred during prediction: {str(e)}")

    def populate_gpu_list(self):
        try:
            df = pd.read_csv(GPU_DATA_PATH)
            gpu_names = df["GPU model name"].tolist()  # Assuming column name is "GPU model name"
            self.gpu_list_widget.clear()
            self.gpu_list_widget.addItems(gpu_names)
        except FileNotFoundError:
            QMessageBox.warning(self, "Warning", "GPU data CSV not found.")
        except Exception as e:
            QMessageBox.critical(self, "Error", f"Error populating GPU list: {str(e)}")

    def gpu_selection_changed(self, item):
        gpu_name = item.text()
        try:
            df = pd.read_csv(GPU_DATA_PATH)
            gpu_data = df[df["GPU model name"] == gpu_name].iloc[0]  # Get the row
            self.update_input_fields_from_gpu_data(gpu_data)
        except Exception as e:
            QMessageBox.critical(self, "Error", f"Error retrieving GPU data: {str(e)}")

    def update_input_fields_from_gpu_data(self, gpu_data):
        fields = [
            self.num_shaders_edit,
            self.clock_speed_edit,
            self.rops_edit,
            self.tmus_edit,
            self.l2_cache_edit,
            self.vram_edit,
            self.bandwidth_edit,
            self.tflops_edit,
        ]

        for field, feature_name in zip(fields, NUMERIC_FEATURES):
            field.layout().itemAt(1).widget().setText(str(gpu_data[feature_name]))
        # Set architecture
        arch_index = gpu_data["Architecture"]
        arch_name = [key for key, value in ARCHITECTURE_MAPPING.items() if value == arch_index][0]
        self.architecture_combo.setCurrentText(arch_name)

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = PredictionApp()
    window.show()
    sys.exit(app.exec())  ; Training dataset (gpu_data.csv) : Architecture,Num Shaders,Clock Speed (GHz),ROPs,TMUs,TFlops,L2 Cache (MB),VRAM (GB),Bandwidth (GB/s),Relative Performance, GPU model name
PASCAL,384,1.468, 16, 24, 1.127, 0.5, 2, 48.06, 0.25, GT 1030
PASCAL,640,1.455, 32, 40, 1.862, 1, 2, 112.1, 0.50, GTX 1050
PASCAL,768,1.392, 32, 48, 2.138, 1, 4, 112.1, 0.63, GTX 1050 Ti
PASCAL,1152,1.708, 48, 72, 3.935, 1.5, 3, 192.2, 0.90, GTX 1060 3GB
PASCAL,1280,1.671, 48, 80, 4.278, 1.5, 6, 192.2, 0.98, GTX 1060M 6GB
PASCAL,1280,1.709, 48, 80, 4.375, 1.5, 6, 192.2, 1.0, GTX 1060 6GB
PASCAL,1920,1.683,64,120,6.463,2,8,256.3,1.35, GTX 1070
PASCAL,2048,1.645,64,128,6.738,2,8,256.3,1.41, GTX 1070M
PASCAL,2432,1.683,64,152,8.186,2,8,256.3,1.52, GTX 1070 Ti
PASCAL,2560,1.114,64,160,5.704,2,8,192.3,1.02, Tesla P4
PASCAL,2560,1.734,64,160,8.878,2,8,320.3,1.59, GTX 1080M
PASCAL,2560,1.733,64,160,8.873,2,8,320.3,1.59, GTX 1080
PASCAL,3584,1.582,88,224,11.34,2.75,11,484.4,2.02, GTX 1080 Ti
PASCAL,3584,1.443,96,224,10.34,4,16,732.2,1.85, Quadro GP100
PASCAL,3584,1.531,96,224,10.97,3,12,480.4,1.96, Titan X Pascal
PASCAL,3584,1.329,96,224,9.526,4,16,732.2,1.69, Tesla P100 PCIe
PASCAL,3840,1.582,96,240,12.15,3,12,547.6,2.17, Titan Xp
PASCAL,3840,1.645,96,240,12.63,3,24,432.8,2.27, Quadro P6000
GCN5,3584,1.471,64,224,10.54,4,8,409.6,1.43, RX Vega 56
GCN5,4096,1.500,64,224,12.29,4,16,436.2,1.51, Radeon INSTINCT MI25
GCN5,4096,1.546,64,256,12.66,4,8,483.8,1.56, RX Vega 64
GCN5,4096,1.600,64,256,13.11,4,8,483.8,1.61, Radeon Vega Frontier Edition
GCN5,4096,1.677,64,256,13.74,4,8,483.8,1.69, RX Vega 64 Liquid Cooling
GCN5,3840,1.750,64,240,13.44,4,16,1024,1.90, Radeon VII
TURING,384,1.425,16,24,1.094,1,2,80.0,0.306, T400
TURING,640,1.335,32,40,1.709,1,4,160.0,0.48, T600
TURING,512,1.785,16,32,1.828,1,4,96.0,0.51, GTX 1630
TURING,896,1.590,32,56,2.849,1,4,192.0,0.75, GTX 1650 GDDR6
TURING,896,1.665,32,56,2.984,1,4,128.1,0.79, GTX 1650
TURING,1280,1.725,32,80,4.416,1,4,192.0,1.06, GTX 1650 Super
TURING,1408,1.785,48,88,5.027,1.5,6,192.1,1.17, GTX 1660
TURING,1408,1.785,48,88,5.027,1.5,6,336.0,1.27, GTX 1660 Super
TURING,1536,1.770,48,96,5.437,1.5,6,288.0,1.35, GTX 1660 Ti
TURING,1920,1.680,48,120,6.451,3,6,336.0,1.59, RTX 2060
TURING,1920,1.200,48,120,4.608,3,6,336.0,1.13, RTX 2060 Mobile
TURING,2176,1.650,48,136,7.181,3,12,336.0,1.78, RTX 2060 12GB
TURING,2176,1.650,64,136,7.181,4,8,448.0,1.78, RTX 2060 Super
TURING,2304,1.440,64,144,6.636,4,8,448.0,1.61, RTX 2070 Mobile
TURING,2304,1.545,64,144,7.119,4,8,416.0,1.72, Quadro RTX 4000
TURING,2304,1.620,64,144,7.465,4,8,448.0,1.80, RTX 2070
TURING,2560,1.770,64,160,9.062,4,8,448.0,2.04, RTX 2070 Super
TURING,2560,1.590,64,160,8.141,4,16,320.0,1.82, Tesla T4
TURING,2944,1.590,64,184,9.362,4,8,448.0,2.04, RTX 2080 Mobile
TURING,2944,1.710,64,184,10.07,4,8,448.0,2.20, RTX 2080
TURING,3072,1.815,64,192,11.15,4,8,495.9,2.30, RTX 2080 Super
TURING,3072,1.815,64,192,11.15,4,16,448.0,2.32, Quadro RTX 5000
TURING,4352,1.545,88,272,13.45,5.5,11,616.0,2.64, RTX 2080 Ti
TURING,4608,1.560,96,288,14.38,6,24,672.0,2.81, Quadro RTX 6000 Passive
TURING,4608,1.770,96,288,16.31,6,24,672.0,3.21, TITAN RTX
RDNA,1408,1.645,32,88,4.632,1.5,3,168.0,0.90, RX 5300 OEM
RDNA,1408,1.845,32,88,5.196,2,8,224.0,1.03, RX 5500 XT
RDNA,2048,1.560,64,128,6.390,3,6,288.0,1.36, RX 5600 OEM
RDNA,2304,1.560,64,144,7.188,3,6,288.0,1.54, RX 5600 XT
RDNA,2304,1.725,64,144,7.949,4,8,448.0,1.65, RX 5700
RDNA,2560,1.905,64,160,9.754,4,8,448.0,1.81, RX 5700 XT
RDNA,2560,1.980,64,160,10.14,4,8,448.0,1.88, RX 5700 XT 50th Anniversary
AMPERE,768,1.762,16,24,2.706,2,4,96.00,0.40, RTX A400
AMPERE,1280,1.770,32,40,4.531,2,16,200.1,0.68, A2 PCIe
AMPERE,2048,1.245,32,64,5.100,2,4,112.0,0.77, RTX 2050 Mobile
AMPERE,2304,1.462,32,72,6.737,2,8,192.0,1.01, RTX A1000
AMPERE,2048,1.740,32,64,7.127,2,4,192.0,1.07, RTX 3050 4GB
AMPERE,2304,1.470,32,72,6.774,2,6,168.0,1.02, RTX 3050 6GB
AMPERE,2560,1.777,32,80,9.098,2,8,224.0,1.37, RTX 3050
AMPERE,3328,1.200,48,104,7.987,3,6,288.0,1.15, RTX A2000
AMPERE,3584,1.777,48,112,12.74,3,8,240.0,1.84, RTX 3060 8GB
AMPERE,3584,1.777,48,112,12.74,3,12,360.0,1.84, RTX 3060 12GB
AMPERE,4864,1.665,80,152,16.20,4,8,448.0,2.36, RTX 3060 Ti
AMPERE,4864,1.665,80,152,16.20,4,8,608.3,2.38, RTX 3060 Ti GDDR6X
AMPERE,5888,1.725,96,184,20.31,4,8,448.0,2.76, RTX 3070
AMPERE,6144,1.770,96,192,21.75,4,8,608.3,2.96, RTX 3070 Ti
AMPERE,6144,1.560,96,192,19.17,4,16,448.0,2.63, RTX A4000
AMPERE,8192,1.695,96,256,27.77,6,24,768.0,3.35, RTX A5000
AMPERE,8192,1.710,96,272,29.77,5,10,760.3,3.59, RTX 3080
AMPERE,8960,1.710,96,280,30.64,5,12,912.4,3.70, RTX 3080 12GB
AMPERE,10240,1.665,112,320,34.10,6,12,912.4,4.02, RTX 3080 Ti
AMPERE,10496,1.695,112,328,35.58,6,24,936.2,4.10, RTX 3090
AMPERE,10752,1.800,112,336,38.71,6,48,768.0,4.61, RTX A6000
AMPERE,10752,1.860,112,336,40.00,6,24,1008,4.75, RTX 3090 Ti
RDNA2,768,2.040,32,48,3.133,8,2,64.00,0.67, RX 6300
RDNA2,768,2.321,32,48,3.565,16,4,128.0,0.77, RX 6400
RDNA2,1024,2.815,32,64,5.765,16,4,143.9,1.04, RX 6500 XT
RDNA2,1792,2.491,64,112,8.928,32,8,224.0,1.72, RX 6600
RDNA2,2048,2.589,64,128,10.60,32,8,256.0,1.98, RX 6600 XT
RDNA2,2048,2.635,64,128,10.79,32,8,280.3,2.15, RX 6650 XT
RDNA2,2304,2.450,64,144,11.29,80,10,320.0,2.10, RX 6700
RDNA2,2560,2.581,64,160,13.21,96,12,384.0,2.44, RX 6700 XT
RDNA2,2560,2.600,64,160,13.31,96,12,432.0,2.59, RX 6750 XT
RDNA2,3840,2.105,96,240,16.17,128,16,512.0,3.05, RX 6800
RDNA2,4608,2.250,128,288,20.74,128,16,512.0,3.46, RX 6800 XT
RDNA2,5120,2.250,128,320,23.04,128,16,512.0,3.73, RX 6900 XT
RDNA2,5120,2.310,128,320,23.65,128,16,576.0,4.03, RX 6950 XT
ADA,2816,2.130,48,88,12.00,12,16,256.0,1.72, RTX 2000 Ada Generation
ADA,3072,2.460,48,96,15.11,24,8,272.0,2.18, RTX 4060
ADA,4352,2.535,48,136,22.06,32,8,288.0,2.62, RTX 4060 Ti
ADA,7424,2.040,80,240,30.29,48,24,300.1,3.33, L4
ADA,5888,2.475,64,184,29.15,36,12,504.2,3.37, RTX 4070
ADA,6144,1.560,80,192,19.17,48,20,280.0,2.21, RTX 4000 SFF Ada Generation
ADA,6144,2.175,80,192,26.73,48,20,360.0,3.12, RTX 4000 Ada Generation 
ADA,7168,2.475,80,224,35.48,48,12,504.2,3.91, RTX 4070 Super
ADA,7680,2.580,80,240,39.63,48,24,432.0,4.13, RTX 4500 Ada Generation
ADA,7680,2.610,80,240,40.09,48,12,504.2,4.20, RTX 4070 Ti
ADA,8448,2.610,96,264,44.10,48,16,672.3,4.59, RTX 4070 Ti Super
ADA,9728,2.505,112,304,48.74,64,16,716.8,5.34, RTX 4080
ADA,10240,2.550,112,320,52.22,64,16,736.3,5.42, RTX 4080 Super
ADA,14080,2.460,176,440,69.27,72,48,864.0,5.63, RTX 5880 Ada Generation
ADA,16384,2.520,176,512,82.58,72,24,1008,6.70, RTX 4090
ADA,18176,2.505,176,568,91.06,96,48,960.0,7.36, RTX 6000 Ada Generation
RDNA3,1792,1.700,64,112,12.19,32,8,172.0,1.22, Radeon Pro W7500
RDNA3,2048,2.440,64,128,19.99,32,8,288.0,1.99, Radeon Pro W7600
RDNA3,2048,2.655,64,128,21.75,32,8,288.0,2.17, RX 7600
RDNA3,2048,2.755,64,128,22.57,32,16,288.0,2.24, RX 7600 XT
RDNA3,3072,2.600,96,192,31.95,64,16,576.0,2.72, Radeon Pro W7700
RDNA3,3456,2.544,96,216,35.17,48,12,432.0,2.99, RX 7700 XT
RDNA3,3840,2.430,96,240,37.32,64,16,624.1,3.63, RX 7800 XT
RDNA3,5120,2.245,160,320,45.98,64,16,576.0,3.99, RX 7900 GRE
RDNA3,4480,2.499,128,280,44.78,64,32,576.0,4.35, Radeon Pro W7800
RDNA3,5376,2.394,192,336,51.48,80,20,800.0,4.71, RX 7900 XT
RDNA3,6144,2.495,192,384,61.32,96,48,864.0,5.45, Radeon Pro W7900
RDNA3,6144,2.498,192,384,61.39,96,24,960.0,5.47, RX 7900 XTX